Skip to content

Bulk replace attributes for groups and projects

What does this MR do and why?

This MR adds the missing REPLACE mode functionality to the security attributes bulk update feature, completing the implementation outlined in issue #577577.

Solution

This MR implements the REPLACE mode which:

  • Removes all existing security attributes from the target projects/groups
  • Adds the specified new attributes
  • Performs both operations atomically for each project

Implementation Details

The REPLACE mode works by:

  1. For each target project, identifying all currently assigned security attributes
  2. Removing all existing attributes (using the existing removal logic)
  3. Adding the new specified attributes (using the existing addition logic)
  4. Processing everything through the same UpdateProjectAttributesService for consistency

Key Changes

  • GraphQL Mutation: Updated description to include REPLACE mode
  • Enum Definition: REPLACE mode already defined in BulkUpdateModeEnum
  • Worker Logic: Enhanced BulkUpdateWorker to handle REPLACE mode by calculating which attributes to remove
  • Service Integration: Leverages existing UpdateProjectAttributesService for atomic operations

Changelog: added EE: true

References

issue #577578

How to set up and validate locally

  1. Enable the feature flag:

    Feature.enable(:security_categories_and_attributes)
  2. Create test data through the UI:

    • Create a group
    • Inside the group above, create:
      • One project (note its ID)
      • One subgroup with a project inside it (note the subgroup ID)
    • Go to Group → Secure → Security Configuration
    • Create 2 security attributes in the Category: "Application"
    • Get the attribute IDs from console: Security::Attribute.last(2).map(&:id)
  3. Assign the attributes via the ADD GraphQL mutation:

    mutation bulkUpdateSecurityAttributes {
      bulkUpdateSecurityAttributes(input: {
        items: [
          "gid://gitlab/Group/176",    # subgroup ID
          "gid://gitlab/Project/77"    # project in root namespace ID
        ],
        attributes: [
          "gid://gitlab/Security::Attribute/44"  # first attribute ID from step 2
        ],
        mode: ADD
      }) {
        errors
      }
    }
  4. Test the REPLACE mode:

    mutation bulkUpdateSecurityAttributes {
      bulkUpdateSecurityAttributes(input: {
        items: [
          "gid://gitlab/Project/176",
          "gid://gitlab/Project/77"
        ],
        attributes: [
          "gid://gitlab/Security::Attribute/45"  # second attribute ID from step 2
        ],
        mode: REPLACE
      }) {
        errors
      }
    }
  5. Verify REPLACE mode:

    • Open each project → Secure → Security configuration → Security attributes
    • Check that the attributes were assigned to both projects

Files Modified

Core Implementation

  • ee/app/graphql/mutations/security/attributes/bulk_update.rb - Updated description to include REPLACE mode
  • ee/app/workers/security/attributes/bulk_update_worker.rb - Enhanced logic to handle REPLACE mode
  • ee/spec/workers/security/attributes/bulk_update_worker_spec.rb - Fixed test setup for proper traversal_ids handling

Key Implementation Details

The REPLACE mode leverages the existing infrastructure:

  • Uses the same BulkUpdateModeEnum (REPLACE was already defined)
  • Reuses UpdateProjectAttributesService for atomic operations
  • Maintains all existing authorization and validation logic
  • Preserves audit logging and error handling

Query plans

Project.by_ids(project_ids).inc_routes.with_namespaces.with_project_to_security_attributes

Projects query

Raw SQL
SELECT "projects"."id", "projects"."name", "projects"."path", "projects"."description", "projects"."created_at", "projects"."updated_at", "projects"."creator_id", "projects"."namespace_id", "projects"."last_activity_at", "projects"."import_url", "projects"."visibility_level", "projects"."archived", "projects"."avatar", "projects"."merge_requests_template", "projects"."star_count", "projects"."merge_requests_rebase_enabled", "projects"."import_type", "projects"."import_source", "projects"."approvals_before_merge", "projects"."reset_approvals_on_push", "projects"."merge_requests_ff_only_enabled", "projects"."issues_template", "projects"."mirror", "projects"."mirror_last_update_at", "projects"."mirror_last_successful_update_at", "projects"."mirror_user_id", "projects"."shared_runners_enabled", "projects"."runners_token", "projects"."build_allow_git_fetch", "projects"."build_timeout", "projects"."mirror_trigger_builds", "projects"."pending_delete", "projects"."public_builds", "projects"."last_repository_check_failed", "projects"."last_repository_check_at", "projects"."only_allow_merge_if_pipeline_succeeds", "projects"."has_external_issue_tracker", "projects"."repository_storage", "projects"."repository_read_only", "projects"."request_access_enabled", "projects"."has_external_wiki", "projects"."ci_config_path", "projects"."lfs_enabled", "projects"."description_html", "projects"."only_allow_merge_if_all_discussions_are_resolved", "projects"."repository_size_limit", "projects"."printing_merge_request_link_enabled", "projects"."auto_cancel_pending_pipelines", "projects"."service_desk_enabled", "projects"."cached_markdown_version", "projects"."delete_error", "projects"."last_repository_updated_at", "projects"."disable_overriding_approvers_per_merge_request", "projects"."storage_version", "projects"."resolve_outdated_diff_discussions", "projects"."remote_mirror_available_overridden", "projects"."only_mirror_protected_branches", "projects"."pull_mirror_available_overridden", "projects"."jobs_cache_index", "projects"."external_authorization_classification_label", "projects"."mirror_overwrites_diverged_branches", "projects"."pages_https_only", "projects"."external_webhook_token", "projects"."packages_enabled", "projects"."merge_requests_author_approval", "projects"."pool_repository_id", "projects"."runners_token_encrypted", "projects"."bfg_object_map", "projects"."detected_repository_languages", "projects"."merge_requests_disable_committers_approval", "projects"."require_password_to_approve", "projects"."max_pages_size", "projects"."max_artifacts_size", "projects"."pull_mirror_branch_prefix", "projects"."remove_source_branch_after_merge", "projects"."marked_for_deletion_at", "projects"."marked_for_deletion_by_user_id", "projects"."autoclose_referenced_issues", "projects"."suggestion_commit_message", "projects"."project_namespace_id", "projects"."hidden", "projects"."organization_id" 
FROM "projects" 
WHERE "projects"."id" IN (13, 14, 15, 16, 17, 18, 19, 20, 22, 24, 26, 27, 29, 30, 31, 32, 33, 34, 35, 36, 38, 39, 40, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 64, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78);
Plan

See full plan here.

 Index Scan using projects_pkey on public.projects  (cost=0.56..164.35 rows=50 width=824) (actual time=2.765..36.640 rows=25 loops=1)
   Index Cond: (projects.id = ANY ('{13,14,15,16,17,18,19,20,22,24,26,27,29,30,31,32,33,34,35,36,38,39,40,50,51,52,53,54,55,56,57,58,59,60,61,62,64,66,67,68,69,70,71,72,73,74,75,76,77,78}'::integer[]))
   Buffers: shared hit=199 read=29
   I/O Timings: read=36.194 write=0.000
Settings: effective_cache_size = '472585MB', jit = 'off', random_page_cost = '1.5', seq_page_cost = '4', work_mem = '100MB'

Project routes query

Raw SQL
SELECT "routes".* 
FROM "routes" 
WHERE "routes"."source_type" = 'Project' 
AND "routes"."source_id" IN (77, 78, 67, 60, 75, 27, 56, 22, 71, 33, 76);
Plan

See full plan here.

 Index Scan using index_routes_on_source_type_and_source_id on public.routes  (cost=0.57..34.87 rows=9 width=95) (actual time=7.963..12.114 rows=4 loops=1)
   Index Cond: (((routes.source_type)::text = 'Project'::text) AND (routes.source_id = ANY ('{77,78,67,60,75,27,56,22,71,33,76}'::integer[])))
   Buffers: shared hit=42 read=8
   I/O Timings: read=11.923 write=0.000
Settings: work_mem = '100MB', effective_cache_size = '472585MB', jit = 'off', random_page_cost = '1.5', seq_page_cost = '4'

Namespaces query

Raw SQL
SELECT "namespaces"."id", "namespaces"."name", "namespaces"."path", "namespaces"."owner_id", "namespaces"."created_at", "namespaces"."updated_at", "namespaces"."type", "namespaces"."avatar", "namespaces"."membership_lock", "namespaces"."share_with_group_lock", "namespaces"."visibility_level", "namespaces"."request_access_enabled", "namespaces"."ldap_sync_status", "namespaces"."ldap_sync_error", "namespaces"."ldap_sync_last_update_at", "namespaces"."ldap_sync_last_successful_update_at", "namespaces"."ldap_sync_last_sync_at", "namespaces"."lfs_enabled", "namespaces"."parent_id", "namespaces"."shared_runners_minutes_limit", "namespaces"."repository_size_limit", "namespaces"."require_two_factor_authentication", "namespaces"."two_factor_grace_period", "namespaces"."project_creation_level", "namespaces"."runners_token", "namespaces"."file_template_project_id", "namespaces"."saml_discovery_token", "namespaces"."runners_token_encrypted", "namespaces"."custom_project_templates_group_id", "namespaces"."auto_devops_enabled", "namespaces"."extra_shared_runners_minutes_limit", "namespaces"."last_ci_minutes_notification_at", "namespaces"."last_ci_minutes_usage_notification_level", "namespaces"."subgroup_creation_level", "namespaces"."max_pages_size", "namespaces"."max_artifacts_size", "namespaces"."mentions_disabled", "namespaces"."default_branch_protection", "namespaces"."max_personal_access_token_lifetime", "namespaces"."push_rule_id", "namespaces"."shared_runners_enabled", "namespaces"."allow_descendants_override_disabled_shared_runners", "namespaces"."traversal_ids", "namespaces"."organization_id", "namespaces"."state" 
FROM "namespaces" 
WHERE "namespaces"."id" IN (174, 176, 158, 137, 1, 133, 165, 172);
Plan

See full plan here.

 Index Scan using namespaces_pkey on public.namespaces  (cost=0.57..21.55 rows=8 width=373) (actual time=4.809..11.097 rows=8 loops=1)
   Index Cond: (namespaces.id = ANY ('{174,176,158,137,1,133,165,172}'::integer[]))
   Buffers: shared hit=31 read=12
   I/O Timings: read=10.937 write=0.000
Settings: random_page_cost = '1.5', seq_page_cost = '4', work_mem = '100MB', effective_cache_size = '472585MB', jit = 'off'

Namespace routes query

Raw SQL
SELECT "routes".* 
FROM "routes" 
WHERE "routes"."source_type" = 'Namespace' 
AND "routes"."source_id" IN (1, 133, 137, 158, 165, 172, 174, 176);
Plan

See full plan here.

 Index Scan using index_routes_on_source_type_and_source_id on public.routes  (cost=0.57..22.07 rows=4 width=95) (actual time=8.012..8.458 rows=8 loops=1)
   Index Cond: (((routes.source_type)::text = 'Namespace'::text) AND (routes.source_id = ANY ('{1,133,137,158,165,172,174,176}'::integer[])))
   Buffers: shared hit=32 read=5
   I/O Timings: read=8.361 write=0.000
Settings: random_page_cost = '1.5', seq_page_cost = '4', work_mem = '100MB', effective_cache_size = '472585MB', jit = 'off'

Project to security attributes query

Raw SQL
SELECT "project_to_security_attributes".* 
FROM "project_to_security_attributes" 
WHERE "project_to_security_attributes"."project_id" IN (9970, 66027821, 96981785,97058478,98867842,100569068);
Plan

See full plan here.

 Seq Scan on public.project_to_security_attributes  (cost=0.00..4.25 rows=6 width=72) (actual time=0.009..0.010 rows=0 loops=1)
   Filter: (project_to_security_attributes.project_id = ANY ('{9970,66027821,96981785,97058478,98867842,100569068}'::bigint[]))
   Rows Removed by Filter: 16
   Buffers: shared hit=1
   I/O Timings: read=0.000 write=0.000
Settings: effective_cache_size = '338688MB', jit = 'off', seq_page_cost = '4', work_mem = '100MB', random_page_cost = '1.5'

project.project_to_security_attributes.pluck_security_attribute_id
Raw SQL
SELECT "project_to_security_attributes"."security_attribute_id" 
FROM "project_to_security_attributes" 
WHERE "project_to_security_attributes"."project_id" = 66027821
LIMIT 1000;
Plan

See full plan here.

 Limit  (cost=0.14..1.65 rows=1 width=8) (actual time=0.078..0.079 rows=0 loops=1)
   Buffers: shared hit=3 read=1
   I/O Timings: read=0.029 write=0.000
   ->  Index Only Scan using index_project_security_attributes_project_id_unique on public.project_to_security_attributes  (cost=0.14..1.65 rows=1 width=8) (actual time=0.077..0.078 rows=0 loops=1)
         Index Cond: (project_to_security_attributes.project_id = 66027821)
         Heap Fetches: 0
         Buffers: shared hit=3 read=1
         I/O Timings: read=0.029 write=0.000
Settings: effective_cache_size = '338688MB', jit = 'off', seq_page_cost = '4', work_mem = '100MB', random_page_cost = '1.5'

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist.

Edited by Nicolae Rotaru

Merge request reports

Loading