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
- Check out this branch.
- In a Rails console, set
Current.organizationto an organization. - Visit
/explore/projects/topic/<topic_name>— projects from other organizations should no longer appear. - 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" DESCAnonymous 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" DESCAuthenticated 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.