Fix organization scoping in Explore::ProjectsController#load_projects

What does this MR do and why?

Fix a bug where Explore::ProjectsController#load_projects passed current_organization: as a finder param key, but ProjectsFinder#by_organization reads params[:organization]. The wrong key name meant the Explore project list was never organization-scoped through the finder.

This is a pre-existing bug identified during the StrongParams refactor in !246815 (merged). The fix is a one-key rename: current_organization: to organization:.

A regression spec is added to spec/controllers/explore/projects_controller_spec.rb asserting that GET #topic excludes projects outside the current organization — this is the spec gap that let the original bug pass CI undetected.

Dashboard::ProjectsController was also inspected; it does not pass any organization key to ProjectsFinder, so no change is needed there.

Re: #620536 (closed)

Screenshots or screen recordings

backend-only change, no UI impact.

How to set up and validate locally

  1. Check out this branch.
  2. In a Rails console, set Current.organization to an organization.
  3. Visit /explore/projects/topic/<topic_name> — projects from other organizations should no longer appear.
  4. Run the spec: bundle exec rspec spec/controllers/explore/projects_controller_spec.rb

Database review

This MR does not add or change a migration. It does change one query: the rename makes ProjectsFinder#by_organization execute in this code path for the first time, since the previous current_organization: key was never read by the finder.

The before/after queries are identical except for a single added predicate, AND projects.organization_id = <id>, inserted after the visibility filter. The topic subselect, both NOT EXISTS checks on namespaces.traversal_ids, and the ORDER BY projects.last_activity_at DESC are unchanged.

The review question is selectivity rather than the cost of the predicate itself. root_group_organization_backfill does not exist in production, so root groups have not been moved out of the default organization and most projects are expected to still share the same organization_id. The filter therefore removes almost no rows, and the risk is that the planner switches to index_projects_on_organization_id_and_id instead of the index currently serving the visibility filter and the sort.

Raw SQL

Generated with ProjectsFinder#execute.to_sql for the topic action. The SELECT list is abbreviated to projects.*; literals come from a local GDK, so the plans below are run with production-representative values instead.

Anonymous visitor, before:

SELECT projects.* FROM "projects"
LEFT OUTER JOIN "namespaces" ON "namespaces"."type" = 'Group'
  AND "namespaces"."id" = "projects"."namespace_id" AND "namespaces"."type" = 'Group'
WHERE "projects"."visibility_level" = 20
  AND "projects"."pending_delete" = FALSE
  AND "projects"."id" IN (SELECT "project_topics"."project_id" FROM "project_topics"
        INNER JOIN "topics" "topic" ON "topic"."id" = "project_topics"."topic_id"
        WHERE "topic"."name" = 'ruby'
        GROUP BY "project_topics"."project_id"
        HAVING (COUNT(DISTINCT "topic"."name") = 1))
  AND "projects"."archived" = FALSE
  AND NOT (EXISTS (SELECT 1 FROM "namespace_settings"
        WHERE "namespace_settings"."namespace_id" = ANY (namespaces.traversal_ids)
          AND "namespace_settings"."archived" = TRUE))
  AND "projects"."marked_for_deletion_at" IS NULL
  AND NOT (EXISTS (SELECT 1 FROM "group_deletion_schedules"
        WHERE "group_deletion_schedules"."group_id" = ANY (namespaces.traversal_ids)))
  AND "projects"."pending_delete" = FALSE
  AND "projects"."hidden" = FALSE
ORDER BY "projects"."last_activity_at" DESC

Anonymous visitor, after — same query with one predicate added:

WHERE "projects"."visibility_level" = 20
  AND "projects"."organization_id" = 1
  AND "projects"."pending_delete" = FALSE
  ...

Authenticated non-admin user, before:

SELECT projects.* FROM "projects"
LEFT OUTER JOIN "namespaces" ON "namespaces"."type" = 'Group'
  AND "namespaces"."id" = "projects"."namespace_id" AND "namespaces"."type" = 'Group'
WHERE (EXISTS (SELECT 1 FROM "project_authorizations"
               WHERE "project_authorizations"."user_id" = 2
                 AND (project_authorizations.project_id = projects.id))
       OR projects.visibility_level IN (10, 20))
  AND "projects"."pending_delete" = FALSE
  AND "projects"."id" IN (SELECT "project_topics"."project_id" FROM "project_topics"
        INNER JOIN "topics" "topic" ON "topic"."id" = "project_topics"."topic_id"
        WHERE "topic"."name" = 'ruby'
        GROUP BY "project_topics"."project_id"
        HAVING (COUNT(DISTINCT "topic"."name") = 1))
  AND "projects"."archived" = FALSE
  AND NOT (EXISTS (SELECT 1 FROM "namespace_settings"
        WHERE "namespace_settings"."namespace_id" = ANY (namespaces.traversal_ids)
          AND "namespace_settings"."archived" = TRUE))
  AND "projects"."marked_for_deletion_at" IS NULL
  AND NOT (EXISTS (SELECT 1 FROM "group_deletion_schedules"
        WHERE "group_deletion_schedules"."group_id" = ANY (namespaces.traversal_ids)))
  AND "projects"."pending_delete" = FALSE
  AND "projects"."hidden" = FALSE
ORDER BY "projects"."last_activity_at" DESC

Authenticated non-admin user, after — same query with one predicate added:

       OR projects.visibility_level IN (10, 20))
  AND "projects"."organization_id" = 1
  AND "projects"."pending_delete" = FALSE
  ...

Query plans

TODO before requesting review: add postgres.ai Database Lab explain links for each of the four queries above, run against the default organization and a user with a large project_authorizations set.

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Edited by Jennifer Li

Merge request reports

Loading
Loading