ExternalUsernameSanitizer incorrectly appends suffix to LDAP/OAuth username due to nested ProjectNamespace path collision

Summary

When a user logs in for the first time via LDAP (or OAuth), GitLab may incorrectly append a numeric suffix (e.g. 1) to their username even though no conflicting top-level user or group namespace exists. The cause is that ExternalUsernameSanitizer#unique_by_namespace queries Namespace.all, which includes ProjectNamespace records whose path column stores only the last path segment — not the full path.

Steps to reproduce

  1. Create a project at root/test (this creates a ProjectNamespace with path = 'test').
  2. Configure LDAP with a user whose username is test.
  3. Have that user log in via LDAP for the first time.
  4. Observe that the created GitLab account has username test1 instead of test.

Expected behavior

The user is created with username test, because root/test is a nested project namespace and does not conflict with a top-level user namespace.

Actual behavior

The user is created with username test1.

Root cause

In ExternalUsernameSanitizer#unique_by_namespace:

def unique_by_namespace(slug)
  path = Namespaces::RandomizedSuffixPath.new(slug).to_s
  Gitlab::Utils::Uniquify.new.string(path) do |s|
    Namespace.all.find_by_path_or_name(s)  # ← queries ALL namespace types
  end
end

Namespace.all.find_by_path_or_name('test') matches the ProjectNamespace for root/test because its path column value is test (just the last segment). This causes Uniquify to increment the counter and produce test1.

The query should be scoped to top-level non-project namespaces only, consistent with how Namespace.username_reserved? works:

# app/models/namespace.rb
def username_reserved?(username)
  without_project_namespaces.top_level.find_by_path_or_name(username).present?
end

Proposed fix

Change unique_by_namespace in ExternalUsernameSanitizer to scope the query the same way:

def unique_by_namespace(slug)
  path = Namespaces::RandomizedSuffixPath.new(slug).to_s
  Gitlab::Utils::Uniquify.new.string(path) do |s|
    Namespace.without_project_namespaces.top_level.find_by_path_or_name(s)
  end
end

Verification (Rails console)

# Reproduces the collision — finds a ProjectNamespace, not a user/group
Namespace.all.find_by_path_or_name('test')
#=> #<Namespaces::ProjectNamespace id:... @root/test>

# Correct scoping — returns nil, no conflict
Namespace.without_project_namespaces.top_level.find_by_path_or_name('test')
#=> nil

Environment

  • Affects: LDAP first-time login, OAuth first-time login (any provider using ExternalUsernameSanitizer)
Edited by 🤖 GitLab Bot 🤖