[Backend][Privacy] Add-member search lists every account on the instance by display name #702

Closed
opened 2026-09-19 04:42:18 +00:00 by claude-bot · 2 comments
Collaborator

What happens today

GET /api/campaigns/{id}/members/search?q= (webapp/backend/app/routers/campaigns.py, campaign_service.search_non_members) matches q as a substring against every account's display name and returns up to ten non-members, each with display_name and avatar_url. min_length=1, so a single letter works. Any GM or owner of any campaign can call it.

Two parts of the UI use it:

  • Add someone on Members (CA-033)
  • Assign to account for imported stubs (CA-050)

On a private self-hosted server this is fine, because everyone knows everyone. On a hosted instance it lets any GM list strangers: type "a", then "e", and so on.

Decision (D56, owner, 2026-09-18)

The instance decides. Add an admin setting, drawn on Admin → Users (ADMIN-1, lane 7), with two options:

  • anyone on this server: today's behaviour, and the natural default for self-hosted instances
  • people you have played with: accounts that share at least one campaign with the searching GM

The line under the search box says which scope is in force, because the scope changes what "no account found" means. When nothing is found, the invite link is offered in its place (CA-225). The lane 2 backfill draws both scopes on the Members page.

Catalogue row: IS-382 (CAMP-8 + ADMIN-1).

Scope of the server change

  • A new instance setting, with a default that suits each deployment type.
  • search_non_members filters on the setting.
    • In "played with" mode, it restricts to users who share a CampaignMember row with the caller in some other campaign.
  • Consider raising min_length to 2 in both modes. The redesign's search already waits for a second letter.
  • Tests for both modes. Also test that a non-GM caller is still refused.

Related: #677, another case of member data exposed more widely than intended.

## What happens today `GET /api/campaigns/{id}/members/search?q=` (`webapp/backend/app/routers/campaigns.py`, `campaign_service.search_non_members`) matches `q` as a substring against every account's display name and returns up to ten non-members, each with `display_name` and `avatar_url`. `min_length=1`, so a single letter works. Any GM or owner of any campaign can call it. Two parts of the UI use it: - **Add someone** on Members (`CA-033`) - **Assign to account** for imported stubs (`CA-050`) On a private self-hosted server this is fine, because everyone knows everyone. On a hosted instance it lets any GM list strangers: type "a", then "e", and so on. ## Decision (D56, owner, 2026-09-18) **The instance decides.** Add an admin setting, drawn on Admin → Users (ADMIN-1, lane 7), with two options: - **anyone on this server**: today's behaviour, and the natural default for self-hosted instances - **people you have played with**: accounts that share at least one campaign with the searching GM The line under the search box says which scope is in force, because the scope changes what "no account found" means. When nothing is found, the invite link is offered in its place (`CA-225`). The lane 2 backfill draws both scopes on the Members page. Catalogue row: `IS-382` (CAMP-8 + ADMIN-1). ## Scope of the server change - A new instance setting, with a default that suits each deployment type. - `search_non_members` filters on the setting. - In "played with" mode, it restricts to users who share a `CampaignMember` row with the caller in some other campaign. - Consider raising `min_length` to 2 in both modes. The redesign's search already waits for a second letter. - Tests for both modes. Also test that a non-GM caller is still refused. Related: #677, another case of member data exposed more widely than intended.
Author
Collaborator

Status check against main, 1 Oct: mostly built, staying open for three small things.

Built. The instance setting member_search_scope ("anyone on this server" or "people you have played with") in Admin → Users, audited. campaign_service.search_non_members filters on it, counting archived shared campaigns and not binned ones, and the "attach to an account" search follows the same scope. The Members page says which scope is in force. Both modes are tested (test_campaign_member_search.py), and the setting's round trip (test_server_owner.py).

Remaining.

  1. No test that a caller who isn't a GM or owner is refused on /members/search. The route has require_membership_manager; nothing asserts it.
  2. The search still accepts one letter (min_length=1), so under "anyone" a single letter lists strangers. The issue suggested two.
  3. The default is "anyone" on every server. Whether a hosted server should default to "people you have played with" is a product question that belongs with the hosted design (#642).

I'm doing 1 and 2 now; 3 is noted on #642's side for the owner and doesn't block closing this.

Status check against main, 1 Oct: mostly built, staying open for three small things. **Built.** The instance setting `member_search_scope` ("anyone on this server" or "people you have played with") in Admin → Users, audited. `campaign_service.search_non_members` filters on it, counting archived shared campaigns and not binned ones, and the "attach to an account" search follows the same scope. The Members page says which scope is in force. Both modes are tested (`test_campaign_member_search.py`), and the setting's round trip (`test_server_owner.py`). **Remaining.** 1. No test that a caller who isn't a GM or owner is refused on `/members/search`. The route has `require_membership_manager`; nothing asserts it. 2. The search still accepts one letter (`min_length=1`), so under "anyone" a single letter lists strangers. The issue suggested two. 3. The default is "anyone" on every server. Whether a hosted server should default to "people you have played with" is a product question that belongs with the hosted design (#642). I'm doing 1 and 2 now; 3 is noted on #642's side for the owner and doesn't block closing this.
Author
Collaborator

Done: PR #1006 merged (main ba816fad, CI green on 2437c91c).

The add-member search answers nothing for fewer than two letters in both scopes, % and _ are literal (they used to match every account and get round the floor), the page doesn't ask until two letters are typed, and the refusals are tested (a plain player 403, a non-member 403, signed out 401).

Left for the owner, not blocking: whether a hosted server should default to "people you have played with"; it belongs with #642. Not on dev until the next deploy.

**Done: PR #1006 merged** (main `ba816fad`, CI green on `2437c91c`). The add-member search answers nothing for fewer than two letters in both scopes, `%` and `_` are literal (they used to match every account and get round the floor), the page doesn't ask until two letters are typed, and the refusals are tested (a plain player 403, a non-member 403, signed out 401). **Left for the owner**, not blocking: whether a hosted server should default to "people you have played with"; it belongs with #642. Not on dev until the next deploy.
Sign in to join this conversation.
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
rbrooks/Quest-Board#702
No description provided.