Index project visibility on project snippet documents
What does this MR do and why?
Advanced Search never returns public or internal project snippets to users who are not project members. The same is true for admins without admin mode. Elastic::Latest::SnippetClassProxy#filter_project_snippets only matches snippets in user.authorized_projects. Basic search does return these snippets.
The snippet documents in the main index have the snippet's own visibility_level. They have nothing about the parent project. So the filter cannot check the project's visibility or its snippets feature setting.
This MR is step 1 of 3. It changes no search results on its own. It only puts the data in the index that the later steps need.
What changes:
- Adds a
project_visibility_levelshortfield to the main index mapping inee/lib/elastic/latest/config.rb. - Adds the Advanced Search migration
AddProjectVisibilityLevelToSnippets(usingUpdateMappingsHelper) to add that mapping to existing indices. Elastic::Latest::SnippetInstanceProxynow writesproject_visibility_levelandsnippets_access_levelon project snippet documents.- It writes them only after the migration has finished. The main index mapping is
dynamic: 'strict', so writing an unmapped field would fail. snippets_access_levelis already mapped, because project documents use it.- Personal snippets get neither field.
SCHEMA_VERSIONgoes from23_08to26_01. Documents keep23_08until the migration has finished, so no document claims a schema version whose fields it doesn't carry.Elastic::Latest::NoteInstanceProxydoes the same.
- It writes them only after the migration has finished. The main index mapping is
SnippetClassProxy#preload_indexing_datanow preloadsproject: :project_featurethrough a newSnippet.preload_for_indexingscope, likeMilestone,Epic, andProject. This stops bulk indexing from running a query per snippet.- A project's snippets are reindexed when the project's
visibility_levelchanges (throughelastic_index_dependant_association, once the migration has finished). They are also reindexed when itssnippets_access_levelchanges (inEE::ProjectFeature), also only once the migration has finished. - Snippets are indexed even for projects outside indexing limits (
Elastic::SnippetsSearch#use_elasticsearch?). The refresh paths were gated on the project being indexed, so on an instance that limits indexing, a non-indexed project's snippet documents would keep a stale visibility. Three changes close that:ElasticAssociationIndexerWorkerkeeps thesnippetsassociation when the object is not indexed, and drops the others as before.EE::ProjectFeatureenqueues the snippets refresh for non-indexed projects too.ProcessInitialBookkeepingService.backfill_projects!re-tracks the snippets of a non-indexed project. A group transfer that lowers project visibility goes through it, becauseupdate_allskips the model callbacks.
Next steps:
- Step 2, !259752 (merged): a backfill migration for existing snippet documents. It reindexes every snippet below schema version
26_01, which also covers changes made before the mapping migration finished. - Step 3, !259759: adds a public and internal project branch to
filter_project_snippets, gated on the backfill. It also removes the 8 pending rows inee/spec/services/ee/search/snippet_service_spec.rb.
Both are stacked on this MR and merge after it.
Access rule for step 3
I checked this rule against Ability.allowed?(user, :read_snippet, snippet). I covered project visibility, snippets access level, snippet visibility, and user type. I found no differences.
A non-member can read a project snippet when both of these are true:
- The project's visibility and the snippet's visibility are both in
Gitlab::VisibilityLevel.levels_for_user(user). snippets_access_levelis enabled.
Advanced Search migration
- Runtime: one
update_mappingcall on the main index. No batching or throttling. - Reversibility: Elasticsearch can't remove a field from an existing mapping, so the migration can't be reverted. An unused mapped field is harmless. To drop it, remove it from
config.rband reindex. - Multi-version compatibility: code from before this MR never writes the field, so it can't fail against the strict mapping. New code writes it only after the migration has finished.
snippets_access_levelis already mapped.
Database
No schema change. This MR adds read queries in two places.
Snippet reindex on a project visibility or snippets access level change. ElasticAssociationIndexerWorker reads the project's snippets with find_in_batches:
SELECT "snippets".* FROM "snippets"
WHERE "snippets"."type" = 'ProjectSnippet' AND "snippets"."project_id" = <project_id>
ORDER BY "snippets"."id" ASC LIMIT 1000;snippets has no (project_id, id) index. The planner reads the project's rows through index_snippets_on_project_id_and_visibility_level and sorts them by id, so it does not walk snippets_pkey, even when the project has no snippets.
| Project | Rows | Buffers | Time | Plan |
|---|---|---|---|---|
gitlab-org/gitlab |
603 | 606 | 688 ms (cold) | 166294 |
gitlab-org/cli |
3 | 9 | 15 ms | 166295 |
| A project ID with no snippets | 0 | 6 | 8 ms | 166296 |
Preload during bulk indexing. Snippet.preload_for_indexing adds two primary-key lookups per batch of snippets:
SELECT "projects".* FROM "projects" WHERE "projects"."id" IN (<project ids>);
SELECT "project_features".* FROM "project_features" WHERE "project_features"."project_id" IN (<project ids>);They use projects_pkey and index_project_features_on_project_id. For 10 project IDs: 9 rows, 54 buffers, 65 ms (166297) and 9 rows, 49 buffers, 50 ms (166298). They replace one query per snippet that as_indexed_json would otherwise run.
Tests
The specs assert that:
project_visibility_levelandsnippets_access_levelare written for project snippets once the migration has finished.- Neither field is written for personal snippets.
- Neither field is written before the migration has finished, and documents keep the previous schema version until then.
- The migration adds a
shortproject_visibility_levelmapping that matchesconfig.rb. The shared'migration adds mapping'example now checks this for every mapping migration. - Indexing preloads the project and the project feature.
Projectdeclares its snippets as dependants ofvisibility_level.- Changing a project's
snippets_access_levelreindexes its snippets, including for a project that isn't indexed, and doesn't reindex them before the migration has finished. - A project visibility change queues
snippetsalongside the other associations. ElasticAssociationIndexerWorkerstill updates snippets for an object that isn't indexed, and skips the other associations.backfill_projects!tracks the snippets of a project that isn't indexed.
Removing each change makes its spec fail.
References
- Issue: #35760
Screenshots or screen recordings
Not applicable. This is a backend-only change.
How to set up and validate locally
-
Enable Advanced Search in GDK.
-
In a Rails console, run
Elastic::MigrationWorker.new.performuntil this returnstrue. The worker runs pending migrations in order, so other pending migrations may run first:Elastic::DataMigrationService.migration_has_finished?(:add_project_visibility_level_to_snippets) -
Pick a project snippet, or create one in a public project if your GDK has none, and reindex it:
snippet.maintain_elasticsearch_update Elastic::ProcessBookkeepingService.new.execute -
Fetch its document:
Snippet.__elasticsearch__.client.get(index: Snippet.__elasticsearch__.index_name, id: "snippet_#{snippet.id}") -
Check that the document has
project_visibility_levelandsnippets_access_level. -
Change the project's visibility, then repeat steps 3 to 5.
project_visibility_levelshould change.
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.