Scope unread inbox + dashboard queries to the viewer's enclaves (LC-604) #560

Merged
longjacksonle merged 1 commit from fix/lc604-inbox-enclave-scoping into main 2026-07-19 03:27:01 +02:00

Closes LC-604.

db::inbox::list_unread and db::chat::list_room_unread_counts filtered on room_type = 'public' OR room_members, with no enclave condition. That reads "public" as public to the whole instance - but a room is only public within its enclave, and is_room_accessible (which backs require_room_access, and therefore the 403s) requires enclave membership first. The two predicates had drifted, and the list queries were the permissive side.

Impact

A user refused a room with HTTP 403 was still shown, on /inbox and the home dashboard, that room's name, its unread count, a deep link to it, and the body of its newest message.

Verified live before the fix. A fresh account (bob) in no enclave:

  • GET /room/3 -> 403
  • dashboard rendered general, the preview filler message 24, a 29 unread pill, and href="/room/3"
  • /inbox showed the same

After the fix, same account, same rooms: 403 unchanged, and both pages show no room link, no room name, and no message body.

The fix

Both queries now share accessible_rooms_sql, one fragment mirroring is_room_accessible branch for branch:

  • site admins: every non-DM room, plus DMs they belong to
  • everyone else: enclave membership for a channel, plus room membership when it is private
  • DMs: room membership
  • a channel with no enclave_id stays unreachable for non-admins, as there

Notably the search query at chat.rs:1802 had already worked this rule out and encoded it inline. The fragment matches it, which is good corroboration that this is the intended semantics and not my invention - and future consumers now inherit it instead of hand-rolling a fourth copy.

The admin branch is unchanged in effect: its old form was room_type = 'public' OR room_type != 'dm' OR member, where the first clause is subsumed by the second.

Tests

server/tests/inbox_enclave_scoping.rs. I confirmed the two leak tests fail against the unfixed code and pass with it, so they are real regression coverage rather than decoration:

test inbox_hides_rooms_from_enclaves_the_viewer_is_not_in ... FAILED   (before)
test dashboard_counts_hide_rooms_from_enclaves_the_viewer_is_not_in ... FAILED   (before)

Three further tests guard against over-correcting: an enclave member still sees the public room, enclave membership alone still does not unlock a private channel, and a site admin still sees every channel. Live check agrees - alice (a member) still sees room 3 and its messages.

just check clean. Full suite unchanged apart from the known pre-existing routes_drafts::sidebar_shows_draft_pencil_for_room_with_draft failure.

Side effect for LC-603

With visibility correctly scoped, a user in no enclave genuinely has no accessible room, so the LC-372 onboarding empty state renders for them again - confirmed live for bob. That resolves part of LC-603 as a consequence rather than by separate change.

Same class, deliberately not fixed here

Found while auditing; each needs its own decision, and I did not want to bundle behaviour changes into a security fix:

  • db::chat::list_rooms (chat.rs:151) has the same unscoped predicate. It feeds the forward-message destination picker (forward.rs:42) and api.rs:98, so a user can see room names from enclaves they are not in. Not a write vector: the forward POST re-validates the destination with is_room_accessible (forward.rs:108) and would reject it. Names only.
  • db::activity.rs (three queries, lines 67/103/139) is also unscoped, but each is keyed to the viewer's own involvement (mentioned, replied to, reacted to), so exposure needs someone with access to act on you deliberately - or for you to have been removed from an enclave after posting. Narrower, still worth closing.

I will file these as a follow-up.

🤖 Generated with Claude Code

https://claude.ai/code/session_01P2Lh6DKB15inZTb5z8miu7

Closes LC-604. `db::inbox::list_unread` and `db::chat::list_room_unread_counts` filtered on `room_type = 'public' OR room_members`, with no enclave condition. That reads "public" as public to the whole instance - but a room is only public *within its enclave*, and `is_room_accessible` (which backs `require_room_access`, and therefore the 403s) requires enclave membership first. The two predicates had drifted, and the list queries were the permissive side. ## Impact A user refused a room with HTTP 403 was still shown, on `/inbox` and the home dashboard, that room's name, its unread count, a deep link to it, and **the body of its newest message**. Verified live before the fix. A fresh account (`bob`) in no enclave: - `GET /room/3` -> **403** - dashboard rendered `general`, the preview `filler message 24`, a `29 unread` pill, and `href="/room/3"` - `/inbox` showed the same After the fix, same account, same rooms: `403` unchanged, and both pages show no room link, no room name, and no message body. ## The fix Both queries now share `accessible_rooms_sql`, one fragment mirroring `is_room_accessible` branch for branch: - site admins: every non-DM room, plus DMs they belong to - everyone else: enclave membership for a channel, plus room membership when it is private - DMs: room membership - a channel with no `enclave_id` stays unreachable for non-admins, as there Notably the search query at `chat.rs:1802` had **already** worked this rule out and encoded it inline. The fragment matches it, which is good corroboration that this is the intended semantics and not my invention - and future consumers now inherit it instead of hand-rolling a fourth copy. The admin branch is unchanged in effect: its old form was `room_type = 'public' OR room_type != 'dm' OR member`, where the first clause is subsumed by the second. ## Tests `server/tests/inbox_enclave_scoping.rs`. I confirmed the two leak tests **fail against the unfixed code** and pass with it, so they are real regression coverage rather than decoration: ``` test inbox_hides_rooms_from_enclaves_the_viewer_is_not_in ... FAILED (before) test dashboard_counts_hide_rooms_from_enclaves_the_viewer_is_not_in ... FAILED (before) ``` Three further tests guard against over-correcting: an enclave member still sees the public room, enclave membership alone still does not unlock a private channel, and a site admin still sees every channel. Live check agrees - `alice` (a member) still sees room 3 and its messages. `just check` clean. Full suite unchanged apart from the known pre-existing `routes_drafts::sidebar_shows_draft_pencil_for_room_with_draft` failure. ## Side effect for LC-603 With visibility correctly scoped, a user in no enclave genuinely has no accessible room, so the LC-372 onboarding empty state renders for them again - confirmed live for `bob`. That resolves part of LC-603 as a consequence rather than by separate change. ## Same class, deliberately not fixed here Found while auditing; each needs its own decision, and I did not want to bundle behaviour changes into a security fix: - **`db::chat::list_rooms`** (`chat.rs:151`) has the same unscoped predicate. It feeds the forward-message destination picker (`forward.rs:42`) and `api.rs:98`, so a user can see *room names* from enclaves they are not in. Not a write vector: the forward POST re-validates the destination with `is_room_accessible` (`forward.rs:108`) and would reject it. Names only. - **`db::activity.rs`** (three queries, lines 67/103/139) is also unscoped, but each is keyed to the viewer's own involvement (mentioned, replied to, reacted to), so exposure needs someone with access to act on you deliberately - or for you to have been removed from an enclave after posting. Narrower, still worth closing. I will file these as a follow-up. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01P2Lh6DKB15inZTb5z8miu7
fix(inbox): scope unread queries to the viewer's enclaves
All checks were successful
check-secrets / Nosey parker (push) Successful in 5s
check-secrets / Kingfisher (push) Successful in 8s
check-secrets / TruffleHog (push) Successful in 8s
check-secrets / TruffleHog (pull_request) Successful in 6s
check-secrets / Kingfisher (pull_request) Successful in 7s
check-secrets / Nosey parker (pull_request) Successful in 4s
Check / clippy + fmt + tests (pull_request) Successful in 6m15s
Create release / Create release from merged PR (pull_request) Has been skipped
550069c28a
`db::inbox::list_unread` and `db::chat::list_room_unread_counts` filtered on `room_type = 'public' OR room_members`, with no enclave condition. That reads "public" as public to the whole instance, but a room is only public *within its enclave*: `is_room_accessible` (which backs `require_room_access`, and so the 403s) requires enclave membership first. The two predicates had drifted, and the list queries were the permissive side.

A user who is refused a room with HTTP 403 was still shown, on `/inbox` and the home dashboard, that room's name, its unread count, a deep link to it, and the body of its newest message. Verified live before the fix: a fresh account in no enclave got 403 on `/room/3` while its dashboard rendered `general`, `filler message 24`, and a 29-unread pill.

Both queries now share `accessible_rooms_sql`, a single fragment mirroring `is_room_accessible` branch for branch: site admins get every non-DM room plus their own DMs; everyone else needs enclave membership for a channel, room membership when it is private, and room membership for a DM. A channel with no enclave_id stays unreachable for non-admins, as there. The search query at chat.rs:1802 had already worked this out and encoded the same rule inline; the fragment now matches it, and future consumers get it for free rather than hand-rolling a fourth copy.

The admin branch is unchanged in effect. Its old form was `room_type = 'public' OR room_type != 'dm' OR member`, where the first clause is subsumed by the second.

Adds tests that fail without the fix: an outsider must not see the room, its name, or the message body in either query. Plus guardrails that the change does not narrow too far - an enclave member still sees the public room, enclave membership alone still does not unlock a private channel, and a site admin still sees every channel.

Side effect worth noting for LC-603: with visibility scoped, a user in no enclave now genuinely has no accessible room, so the LC-372 onboarding empty state renders for them again.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P2Lh6DKB15inZTb5z8miu7
longjacksonle scheduled this pull request to auto merge when all checks succeed 2026-07-19 03:22:02 +02:00
longjacksonle deleted branch fix/lc604-inbox-enclave-scoping 2026-07-19 03:27:01 +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!560
No description provided.