de321fbbb5
This column used to be a 32 bits integer, allowing for only a maximum of 2 147 483 647 rows. Given enough users one can hit this limit pretty quickly, as was the case for GitLab.com. Changing this type to bigint (= 64 bits) would give us more space, but we'd eventually hit the same limit given enough users and projects. A much more sustainable solution is to simply drop the "id" column. There were only 2 lines of code depending on this column being present, and neither truly required it to be present. Instead the code now uses the "project_id" column combined with the "user_id" column. This means that instead of something like this: DELETE FROM project_authorizations WHERE user_id = X AND id = Y; We now run the following when removing rows: DELETE FROM project_authorizations WHERE user_id = X AND project_id = Y; Since both user_id and project_id are indexed this should not slow down the DELETE query. This commit also removes the "dependent: destroy" clause from the "project_authorizations" relation in the User and Project models. Keeping this prevents Rails from being able to remove data as it relies on an "id" column being present. Since the "project_authorizations" table has proper foreign keys set up (with cascading removals) we don't need to depend on any Rails logic.
128 lines
4.3 KiB
Ruby
128 lines
4.3 KiB
Ruby
module Users
|
|
# Service for refreshing the authorized projects of a user.
|
|
#
|
|
# This particular service class can not be used to update data for the same
|
|
# user concurrently. Doing so could lead to an incorrect state. To ensure this
|
|
# doesn't happen a caller must synchronize access (e.g. using
|
|
# `Gitlab::ExclusiveLease`).
|
|
#
|
|
# Usage:
|
|
#
|
|
# user = User.find_by(username: 'alice')
|
|
# service = Users::RefreshAuthorizedProjectsService.new(some_user)
|
|
# service.execute
|
|
class RefreshAuthorizedProjectsService
|
|
attr_reader :user
|
|
|
|
LEASE_TIMEOUT = 1.minute.to_i
|
|
|
|
# user - The User for which to refresh the authorized projects.
|
|
def initialize(user)
|
|
@user = user
|
|
|
|
# We need an up to date User object that has access to all relations that
|
|
# may have been created earlier. The only way to ensure this is to reload
|
|
# the User object.
|
|
user.reload
|
|
end
|
|
|
|
# This method returns the updated User object.
|
|
def execute
|
|
current = current_authorizations_per_project
|
|
fresh = fresh_access_levels_per_project
|
|
|
|
remove = current.each_with_object([]) do |(project_id, row), array|
|
|
# rows not in the new list or with a different access level should be
|
|
# removed.
|
|
if !fresh[project_id] || fresh[project_id] != row.access_level
|
|
array << row.project_id
|
|
end
|
|
end
|
|
|
|
add = fresh.each_with_object([]) do |(project_id, level), array|
|
|
# rows not in the old list or with a different access level should be
|
|
# added.
|
|
if !current[project_id] || current[project_id].access_level != level
|
|
array << [user.id, project_id, level]
|
|
end
|
|
end
|
|
|
|
update_with_lease(remove, add)
|
|
end
|
|
|
|
# Updates the list of authorizations using an exclusive lease.
|
|
def update_with_lease(remove = [], add = [])
|
|
lease_key = "refresh_authorized_projects:#{user.id}"
|
|
lease = Gitlab::ExclusiveLease.new(lease_key, timeout: LEASE_TIMEOUT)
|
|
|
|
until uuid = lease.try_obtain
|
|
# Keep trying until we obtain the lease. If we don't do so we may end up
|
|
# not updating the list of authorized projects properly. To prevent
|
|
# hammering Redis too much we'll wait for a bit between retries.
|
|
sleep(1)
|
|
end
|
|
|
|
begin
|
|
update_authorizations(remove, add)
|
|
ensure
|
|
Gitlab::ExclusiveLease.cancel(lease_key, uuid)
|
|
end
|
|
end
|
|
|
|
# Updates the list of authorizations for the current user.
|
|
#
|
|
# remove - The IDs of the authorization rows to remove.
|
|
# add - Rows to insert in the form `[user id, project id, access level]`
|
|
def update_authorizations(remove = [], add = [])
|
|
return if remove.empty? && add.empty? && user.authorized_projects_populated
|
|
|
|
User.transaction do
|
|
user.remove_project_authorizations(remove) unless remove.empty?
|
|
ProjectAuthorization.insert_authorizations(add) unless add.empty?
|
|
user.set_authorized_projects_column
|
|
end
|
|
|
|
# Since we batch insert authorization rows, Rails' associations may get
|
|
# out of sync. As such we force a reload of the User object.
|
|
user.reload
|
|
end
|
|
|
|
def fresh_access_levels_per_project
|
|
fresh_authorizations.each_with_object({}) do |row, hash|
|
|
hash[row.project_id] = row.access_level
|
|
end
|
|
end
|
|
|
|
def current_authorizations_per_project
|
|
current_authorizations.each_with_object({}) do |row, hash|
|
|
hash[row.project_id] = row
|
|
end
|
|
end
|
|
|
|
def current_authorizations
|
|
user.project_authorizations.select(:project_id, :access_level)
|
|
end
|
|
|
|
def fresh_authorizations
|
|
ProjectAuthorization.
|
|
unscoped.
|
|
select('project_id, MAX(access_level) AS access_level').
|
|
from("(#{project_authorizations_union.to_sql}) #{ProjectAuthorization.table_name}").
|
|
group(:project_id)
|
|
end
|
|
|
|
private
|
|
|
|
# Returns a union query of projects that the user is authorized to access
|
|
def project_authorizations_union
|
|
relations = [
|
|
user.personal_projects.select("#{user.id} AS user_id, projects.id AS project_id, #{Gitlab::Access::MASTER} AS access_level"),
|
|
user.groups_projects.select_for_project_authorization,
|
|
user.projects.select_for_project_authorization,
|
|
user.groups.joins(:shared_projects).select_for_project_authorization
|
|
]
|
|
|
|
Gitlab::SQL::Union.new(relations)
|
|
end
|
|
end
|
|
end
|