Skip to content

Fix/user search endpoint#716

Merged
wdower merged 7 commits intomasterfrom
fix/user-search-endpoint
Apr 12, 2026
Merged

Fix/user search endpoint#716
wdower merged 7 commits intomasterfrom
fix/user-search-endpoint

Conversation

@wdower
Copy link
Copy Markdown
Contributor

@wdower wdower commented Apr 11, 2026

No description provided.

wdower added 4 commits April 9, 2026 23:57
Add GET /api/users/search to replace pre-loaded available_members
list. Prevents information disclosure of the full user directory to
project/component admins.

- New Api::UserSearchController with admin-only authorization
- Project#search_available_members combines exclusion + ILIKE search
- Minimum 2-char query, configurable limit (default 10, max 25)
- Returns only id, name, email via UserBlueprint pattern
- 10 request specs (TDD: auth, search, exclusion, limits, 404)

Signed-off-by: Will Dower <will@dower.dev>
- Remove available_members from project and component blueprints
  (no longer serialized to client)
- NewMembership.vue: async server-side search via /api/users/search
  with debounced vue-multiselect (replaces client-side filtering)
- MembershipsTable.vue: derive pending access request users from
  access_requests data directly (no longer cross-references
  available_members)
- ProjectMembersModal.vue: remove available_members prop pass-through
- Update test fixtures for nested user data in access requests

Signed-off-by: Will Dower <will@dower.dev>
- Replace b-form-select with vue-multiselect + debounced server-side
  search in MembersModal (component member addition)
- Remove availableMemberOptions computed (was pre-loaded user list)
- Add api/user_search#index to auth coverage AUTHENTICATE_ONLY_ACTIONS
- Update MembersModal spec for new search state management

Signed-off-by: Will Dower <will@dower.dev>
- Add scope=members parameter to /api/users/search for searching
  within existing members (PoC selection, any member can use)
- Add Project#search_members method
- Remove all_users from ComponentBlueprint and jbuilder
- Update UpdateComponentDetailsModal PoC dropdown to async search
  via /api/users/search?scope=members
- Relaxed auth for member-scoped search (any member, not just admin)

Signed-off-by: Will Dower <will@dower.dev>
@wdower wdower requested a review from Copilot April 11, 2026 21:30
@aaronlippold aaronlippold temporarily deployed to vulcan-fix-user-search--z2zopr April 11, 2026 21:32 Inactive
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a server-side user search endpoint for membership-related UI flows and removes preloading of large user lists to reduce information disclosure and DOM bloat.

Changes:

  • Introduces GET /api/users/search with authorization rules and query/limit handling.
  • Updates membership/PoC selection UIs to use remote search via vue-multiselect + debounced API calls.
  • Removes available_members / all_users from serialized payloads and updates related tests.

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
spec/requests/authorization_coverage_spec.rb Documents the new API endpoint’s authorization coverage.
spec/requests/api/user_search_spec.rb Adds request coverage for the new search endpoint (Project-focused).
spec/javascript/components/memberships/MembershipsTable.spec.js Updates pending access request fixtures/expectations.
spec/javascript/components/components/MembersModal.spec.js Updates modal tests for server-side search state.
config/routes.rb Adds /api/users/search route.
app/views/components/show.json.jbuilder Removes component available_members / all_users from JSON payload.
app/models/project.rb Adds member/non-member search helpers for Projects.
app/javascript/components/project/ProjectMembersModal.vue Stops passing preloaded available_members.
app/javascript/components/memberships/NewMembership.vue Switches user selection to server-side search.
app/javascript/components/memberships/MembershipsTable.vue Derives pending-request users from access request payload.
app/javascript/components/components/UpdateComponentDetailsModal.vue Switches PoC selection to server-side member search.
app/javascript/components/components/MembersModal.vue Switches “Add Member” to server-side search.
app/controllers/api/user_search_controller.rb Implements the API endpoint (target selection + authorization + response shaping).
app/blueprints/project_blueprint.rb Removes available_members association from Project serialization.
app/blueprints/component_blueprint.rb Removes available_members / all_users from Component serialization.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +21 to +26
limit = params.fetch(:limit, 10).to_i.clamp(1, 25)
users = if params[:scope] == 'members'
@target.search_members(query, limit: limit)
else
@target.search_available_members(query, limit: limit)
end
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

membership_type=Component is supported in set_target, but index unconditionally calls @target.search_members / @target.search_available_members. Component currently only defines available_members (no search_* methods), so requests like /api/users/search?membership_type=Component... will raise NoMethodError.

Add search_available_members and search_members implementations to Component (matching the semantics you want: for scope=members it likely needs to search component.all_users/inherited members, not just component.users), or branch here to call different methods per target type.

Copilot uses AI. Check for mistakes.
Comment on lines +233 to +239
// Users with pending access request (derived from access_requests directly)
pendingProjectMembers: function () {
return this.available_members.filter((member) =>
this.localAccessRequests.some((request) => request.user_id === member.id),
);
return this.localAccessRequests.map((request) => ({
id: request.user.id,
name: request.user.name,
email: request.user.email,
}));
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pendingProjectMembers now derives members from request.user, but the rest of the component still assumes access requests have a top-level user_id. In particular, getAccessRequestId uses request.user_id === member.id, which will fail with the current API payload (ProjectBlueprint access_requests entries contain {id, user, project_id}), breaking Accept/Reject actions.

Update the access request lookup to key off request.user.id (and ideally tolerate both shapes for backward compatibility), or include the access_request id directly in each pendingProjectMembers item so you don’t need to re-find it.

Copilot uses AI. Check for mistakes.
Comment on lines 60 to 62
{ id: 100, user_id: 10, user: { id: 10, name: "Pending User", email: "pending@example.com" } },
];

Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This spec’s accessRequests fixture includes user_id, but the real project.access_requests payload built by ProjectBlueprint is { id, user: {...}, project_id } (no user_id). Keeping user_id here will let the test pass while the UI breaks in production.

Adjust the fixture to match the API shape and assert MembershipsTable can still resolve the access request id (e.g., via request.user.id or by carrying the request id through pendingProjectMembers).

Suggested change
{ id: 100, user_id: 10, user: { id: 10, name: "Pending User", email: "pending@example.com" } },
];
{
id: 100,
project_id: 1,
user: { id: 10, name: "Pending User", email: "pending@example.com" },
},
];
const pendingProjectMembers = accessRequests.map((request) => ({
...request.user,
access_request_id: request.id,
}));

Copilot uses AI. Check for mistakes.
Comment on lines +6 to +10
let_it_be(:admin) { create(:user, admin: false) }
let_it_be(:viewer) { create(:user, admin: false) }
let_it_be(:project) { create(:project) }
let_it_be(:searchable_user) { create(:user, name: 'Jane Findable', email: 'jane.findable@example.com') }
let_it_be(:other_user) { create(:user, name: 'Bob Visible', email: 'bob.visible@example.com') }
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because Settings.admin_bootstrap.first_user_admin defaults to enabled, the first create(:user, admin: false) in the suite can be auto-promoted to a site admin via User#promote_first_user_to_admin. In this spec that means admin may silently become a site admin, so the "project admin" authorization assertions won’t actually be exercising project-level permissions.

Create a dedicated let_it_be(:bootstrap_admin) { create(:user, admin: true) } before the non-admin users (or explicitly disable the bootstrap setting in this spec) so admin/viewer remain non-site-admins.

Copilot uses AI. Check for mistakes.
Comment on lines +21 to +26
describe 'GET /api/users/search' do
context 'when unauthenticated' do
it 'returns 401' do
get '/api/users/search', params: { q: 'jane', membership_type: 'Project', membership_id: project.id }
expect(response).to have_http_status(:unauthorized)
end
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The endpoint supports both membership_type=Project and membership_type=Component, but this request spec only covers Project behavior. Add coverage for Component searches (both default non-member search and scope=members) to prevent regressions, especially since Component member semantics differ (available_members excludes project admins; member scope likely needs inherited + direct members).

Copilot uses AI. Check for mistakes.
Comment on lines +65 to +66
# UserSearchController does admin authorization in set_and_authorize_target before_action
'api/user_search#index' => 'Admin-only search with custom authorization in before_action'
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The coverage note says this is "Admin-only search", but the controller allows scope=members searches for any member (viewer/author/etc.). Consider updating this description to reflect both modes (admin-only for non-members vs member-only for scope=members).

Suggested change
# UserSearchController does admin authorization in set_and_authorize_target before_action
'api/user_search#index' => 'Admin-only search with custom authorization in before_action'
# UserSearchController uses custom authorization in set_and_authorize_target before_action:
# admin-only for non-member searches, and member-accessible when scope=members.
'api/user_search#index' => 'Custom authorization in before_action: admin-only for non-member searches; member-only for scope=members'

Copilot uses AI. Check for mistakes.
- Add Component#search_available_members and #search_members
  (controller dispatches to @target.search_* but Component
  was missing both, causing NoMethodError for component flows)
- Fix MembershipsTable.getAccessRequestId to read request.user.id
  instead of stale request.user_id (would crash Accept/Reject)
- Remove user_id from accessRequests test fixture so it mirrors
  the real ProjectBlueprint payload shape
- Pre-create bootstrap_admin in user_search_spec to prevent the
  first_user_admin callback from silently promoting test admin
- Add Component coverage to user_search_spec (6 specs covering
  default search + scope=members, including inheritance)
- Update authorization_coverage_spec doc to mention both auth modes

Signed-off-by: Will Dower <will@dower.dev>
@wdower wdower temporarily deployed to vulcan-fix-user-search--z2zopr April 11, 2026 22:17 Inactive
wdower added 2 commits April 11, 2026 18:33
The PR removed all_users and available_members from ComponentBlueprint
and the SSR'd jbuilder, but three contract tests were still asserting
the old shape and broke CI:

- spec/blueprints/component_blueprint_spec.rb: editor view asserted
  both keys were present, plus shape tests for each
- spec/requests/rules_spec.rb: asserted &quot;all_users&quot; was
  embedded in the SSR'd component JSON
- spec/javascript/components/components/UpdateComponentDetailsModal.spec.js:
  fixture carried a stale all_users array

The blueprint and rules specs are inverted into regression guards
(not_to have_key / not_to include) so the security fix can't be undone
without tripping a test. The vitest fixture is just trimmed.

Signed-off-by: Will Dower <will@dower.dev>
… drift

components#show had two parallel code paths producing different shapes for
the same data. The HTML format used ComponentBlueprint :editor (memberships
decorated with name+email via MembershipBlueprint, no dead admins field).
The JSON format used show.json.jbuilder, which served raw
@component.memberships (no name/email since Membership#as_json no longer
overrides) and a dead json.admins field that no Vue consumer reads.

refreshComponent() in ProjectComponent.vue / RulesCodeEditorView.vue is
triggered after UpdateComponentDetailsModal, UpdateMetadataModal, and
AddQuestionsModal save successfully. After refresh, the local component's
memberships would be replaced with name/email-less stripped versions —
silently breaking MembersModal display until full page reload.

- ComponentsController#show editor JSON now renders ComponentBlueprint
  :editor directly, eliminating the parallel path
- show.json.jbuilder simplified to non-member only (still needed for
  BenchmarkViewer's lightweight rule shape)
- New components_spec contract tests assert refresh shape matches the
  blueprint exactly, includes name/email on memberships, and excludes
  the dead admins field plus the security-removed all_users /
  available_members

Signed-off-by: Will Dower <will@dower.dev>
@wdower wdower temporarily deployed to vulcan-fix-user-search--z2zopr April 11, 2026 22:52 Inactive
@sonarqubecloud
Copy link
Copy Markdown

@wdower wdower merged commit d321250 into master Apr 12, 2026
17 checks passed
@wdower wdower deleted the fix/user-search-endpoint branch April 12, 2026 01:00
wdower added a commit that referenced this pull request Apr 12, 2026
Release metadata for v2.3.5 covering PRs #715 (CI/release workflow
split) and #716 (server-side user search, information disclosure fix,
editor refresh shape drift fix).

- VERSION: v2.3.4 → v2.3.5
- package.json: 2.3.4 → 2.3.5
- CHANGELOG.md: new v2.3.5 section
- docs/release-notes/index.md: promote v2.3.4, add v2.3.5
- docs/release-notes/v2.3.5.md: new release notes

Signed-off-by: Will Dower <will@dower.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants