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
- Create a project at
root/test(this creates aProjectNamespacewithpath = 'test'). - Configure LDAP with a user whose username is
test. - Have that user log in via LDAP for the first time.
- Observe that the created GitLab account has username
test1instead oftest.
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
endNamespace.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?
endProposed 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
endVerification (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')
#=> nilEnvironment
- Affects: LDAP first-time login, OAuth first-time login (any provider using
ExternalUsernameSanitizer)