Organization group transfer leaves organization-sharded rows behind

Human verification & rescope (@dblessing)

I human-verified the AI audit below (preserved verbatim under Original AI audit) and am rescoping this issue. The audit's findings are largely correct, but its framing of UsersService as a legitimate-but-dormant support path is wrong, which changes what this issue should own.

Ownership model

A group transfers independently of its users. Organizations::Transfer::UsersService runs only on a user ownership change — not as part of a group transfer. So:

  • If a group moves and org-sharded data is left behind, the defect is a group-transfer gap, not "UsersService didn't run." The data should move with the group transfer.
  • A row whose organization_id is anchored to a namespace/project/item parent must move in GroupsService keyed by that parent. UsersService should never touch it. Crediting it to "User transfer: creator/updater" in the table below is a mis-attribution, not real support.
  • The one legitimate exception is users the group itself owns — its bots and service accounts. For those, GroupsService should invoke UsersService inline on an explicit, group-scoped user set.
  • A row whose organization_id is anchored to a namespace/project/item parent must move in GroupsService keyed by that parent. UsersService should never touch it. Crediting it to "User transfer: creator/updater" in the table below is a mis-attribution, not real support.

In scope for this issue

Group-owned bots and service accounts and their user-owned dependents:

  • GroupsService enumerates only the bots/service accounts owned by the group and its descendant projects — group.provisioned_users.service_account (app/models/group.rb:61,789) plus descendant projects' provisioned service accounts/bots (app/models/project.rb:413,1528). It must not pass the organization or let UsersService auto-discover bots org-wide (users_service.rb:251,273), which would wrongly move bots owned by sibling groups.
  • GroupsService invokes UsersService inline on that explicit user set. Their user-owned dependents follow via that same scoped run: personal_access_tokens, personal_access_token_granular_scopes + granular_scopes, deploy keys.

Namespace-owned, no service moves it today:

  • import_placeholder_user_details — namespace-owned; move directly in GroupsService (not user-owned, so not via the inline UsersService path).

Registry honesty:

  • config/organizations/transfer_support.yml records only a per-table status and cannot express which transfer provides support, so group-transfer gaps hide behind a bare supported. Track support per transfer type (group vs. user), and add a spec that fails when a parent-owned model is auto-discovered by migratable_models.

Deferred to #635815

These rows are parent/org-owned and belong with the UsersService discovery-heuristic fix tracked in #635815. Listed here for completeness; see that issue for analysis.

Table Real owner Tracked in
ai_catalog_item_versions Item #635815
ai_catalog_item_version_dependencies Version #635815
ai_catalog_item_stars Item #635815
custom_dashboard_versions Dashboard (also double-transferred) #635815
abuse_report_events Abuse report #635815
ai_catalog_mcp_servers Organization #635815
ai_catalog_mcp_servers_users MCP server #635815
organization_cluster_agent_mappings Organization #635815

Original AI audit (preserved)

Problem

Moving a top-level group to another organization leaves some organization-sharded rows behind, and the transfer support registry reports them as supported.

  • Organizations::Transfer::GroupsService lists every table it moves by hand. It is the only transfer that runs in production.
  • Organizations::Transfer::UsersService auto-discovers models with exactly one User and one Organizations::Organization association (migratable_models), but nothing outside its specs calls it.
  • config/organizations/transfer_support.yml records supported without saying which transfer provides that support. Several tables are supported only through UsersService, so no real transfer moves them.
  • Some auto-discovered models get their organization from a non-user parent (item, dashboard, server). If UsersService starts running, it will move them with the wrong parent.

Related: #635512 (closed)

Affected tables

Table Moves with Should move with Severity
ai_catalog_item_versions Group transfer: nothing. User transfer: version creator (created_by) Item High
ai_catalog_item_version_dependencies Group transfer: nothing. User transfer: version creator Version High
ai_catalog_item_stars Group transfer: nothing. User transfer: user who starred Item (the model validates this) Medium
users (the group's bots and service accounts) Nothing. New accounts go to the new org, so the group ends up with accounts in both orgs Owning group or project High
personal_access_tokens (of those bots) Nothing. Follows the bot, so also split across orgs Bot user, which follows the owning group or project High
personal_access_token_granular_scopes and their granular_scopes Nothing. Rows for moved namespaces now fail GranularScope#organization_match Token Medium
keys (deploy keys created for moved projects) Nothing The projects the key is attached to Low today, High once isolated
import_placeholder_user_details Nothing Namespace Low
ai_catalog_mcp_servers User transfer: creator Organization (a shared org-level resource) Low (no caller today)
ai_catalog_mcp_servers_users User transfer: user MCP server Low (no caller today)
custom_dashboard_versions User transfer: last updater (updated_by) Dashboard Low (no caller today)
organization_cluster_agent_mappings User transfer: user who created the mapping Organization Low (no caller today)
abuse_report_events User transfer: user who acted Abuse report (unverified) Low (no caller today)

"No caller today" rows become higher severity once something calls UsersService.

Impact and evidence
  • AI catalog versions: ee/app/services/ee/organizations/transfer/groups_service.rb#transfer_ai_catalog_items moves Ai::Catalog::Item only. Ai::Catalog::ItemVersion#populate_organization copies the item's organization, so after a move the item is in the new organization and its versions are not. Ai::Catalog::ItemVersionsFinder filters by organization, so aiCatalogItemVersions returns nothing for those items. In an isolated organization the data isolation filter would hide the versions everywhere.
  • Version dependencies: only EE::Organizations::Transfer::UsersService#transfer_ai_catalog_item_version_dependencies handles them, keyed by the version creator.
  • Stars: Ai::Catalog::ItemStar uses populate_sharding_key :organization_id, source: :item and validates organization_matches_item.
  • Bots and service accounts: group and project service accounts are created with the resource's organization (lib/api/group_service_accounts.rb:63, lib/api/project_service_accounts.rb:59). GroupsService does not touch users, so accounts created before the move stay in the old organization. PersonalAccessToken embeds organization_id in the routable token payload (app/models/personal_access_token.rb:33).
  • Granular scopes: GroupsService#transfer_oauth_application_granular_scopes moves OAuth application scopes only. PAT granular scopes for namespaces in the moved group keep the token's organization and fail Authz::GranularScope#organization_match (app/models/authz/granular_scope.rb:72).
  • Deploy keys: POST /projects/:id/deploy_keys creates the key in Current.organization (lib/api/deploy_keys.rb:172). Key.within_organization_boundary hides keys from other organizations when the project's organization is isolated.
  • Placeholder user details: Gitlab::Import::PlaceholderUserCreator sets organization: namespace.organization.
  • No caller for UsersService: searching app, ee/app, lib, ee/lib, and config finds no reference outside the class and its specs. ActivateService only calls OrganizationUsersService, which upserts organization_users rows.
  • Method: UsersService.migratable_models was evaluated with rails runner against every supported table in the registry, and the group-transfer services and workers were searched for each model and table. Models whose organization could come from a group, project, or item were read by hand.

Proposal

  1. Move ai_catalog_item_versions, ai_catalog_item_version_dependencies, and ai_catalog_item_stars in transfer_ai_catalog_items, keyed by item ID.
  2. Decide how group-owned bots and service accounts, their tokens, and PAT granular scopes move with a group, and implement it in GroupsService.
  3. Add the models in the table whose organization comes from a non-user parent to UsersService's skipped models, or move them explicitly with their parent.
  4. Track transfer support per transfer type (group and user) in config/organizations/transfer_support.yml. Add a spec that fails when migratable_models includes a model that fills its organization from a non-user source.
Edited by Peter Hegman