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_idis anchored to a namespace/project/item parent must move inGroupsServicekeyed by that parent.UsersServiceshould 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,
GroupsServiceshould invokeUsersServiceinline on an explicit, group-scoped user set. - A row whose
organization_idis anchored to a namespace/project/item parent must move inGroupsServicekeyed by that parent.UsersServiceshould 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:
GroupsServiceenumerates 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 letUsersServiceauto-discover bots org-wide (users_service.rb:251,273), which would wrongly move bots owned by sibling groups.GroupsServiceinvokesUsersServiceinline 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, deploykeys.
Namespace-owned, no service moves it today:
import_placeholder_user_details— namespace-owned; move directly inGroupsService(not user-owned, so not via the inlineUsersServicepath).
Registry honesty:
config/organizations/transfer_support.ymlrecords only a per-table status and cannot express which transfer provides support, so group-transfer gaps hide behind a baresupported. Track support per transfer type (group vs. user), and add a spec that fails when a parent-owned model is auto-discovered bymigratable_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::GroupsServicelists every table it moves by hand. It is the only transfer that runs in production.Organizations::Transfer::UsersServiceauto-discovers models with exactly oneUserand oneOrganizations::Organizationassociation (migratable_models), but nothing outside its specs calls it.config/organizations/transfer_support.ymlrecordssupportedwithout saying which transfer provides that support. Several tables aresupportedonly throughUsersService, so no real transfer moves them.- Some auto-discovered models get their organization from a non-user parent (item, dashboard, server). If
UsersServicestarts 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_itemsmovesAi::Catalog::Itemonly.Ai::Catalog::ItemVersion#populate_organizationcopies the item's organization, so after a move the item is in the new organization and its versions are not.Ai::Catalog::ItemVersionsFinderfilters by organization, soaiCatalogItemVersionsreturns 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_dependencieshandles them, keyed by the version creator. - Stars:
Ai::Catalog::ItemStarusespopulate_sharding_key :organization_id, source: :itemand validatesorganization_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).GroupsServicedoes not touchusers, so accounts created before the move stay in the old organization.PersonalAccessTokenembedsorganization_idin the routable token payload (app/models/personal_access_token.rb:33). - Granular scopes:
GroupsService#transfer_oauth_application_granular_scopesmoves OAuth application scopes only. PAT granular scopes for namespaces in the moved group keep the token's organization and failAuthz::GranularScope#organization_match(app/models/authz/granular_scope.rb:72). - Deploy keys:
POST /projects/:id/deploy_keyscreates the key inCurrent.organization(lib/api/deploy_keys.rb:172).Key.within_organization_boundaryhides keys from other organizations when the project's organization is isolated. - Placeholder user details:
Gitlab::Import::PlaceholderUserCreatorsetsorganization: namespace.organization. - No caller for
UsersService: searchingapp,ee/app,lib,ee/lib, andconfigfinds no reference outside the class and its specs.ActivateServiceonly callsOrganizationUsersService, which upsertsorganization_usersrows. - Method:
UsersService.migratable_modelswas evaluated withrails runneragainst everysupportedtable 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
- Move
ai_catalog_item_versions,ai_catalog_item_version_dependencies, andai_catalog_item_starsintransfer_ai_catalog_items, keyed by item ID. - Decide how group-owned bots and service accounts, their tokens, and PAT granular scopes move with a group, and implement it in
GroupsService. - 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. - Track transfer support per transfer type (group and user) in
config/organizations/transfer_support.yml. Add a spec that fails whenmigratable_modelsincludes a model that fills its organization from a non-user source.