summaryrefslogtreecommitdiff
path: root/app/services/users/refresh_authorized_projects_service.rb
blob: 070713929e410f55ff12b471501983cc6a64c726 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
# frozen_string_literal: true

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, :source

    LEASE_TIMEOUT = 1.minute.to_i

    # user - The User for which to refresh the authorized projects.
    def initialize(user, source: nil, incorrect_auth_found_callback: nil, missing_auth_found_callback: nil)
      @user = user
      @source = source
      @incorrect_auth_found_callback = incorrect_auth_found_callback
      @missing_auth_found_callback = missing_auth_found_callback

      # 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.reset
    end

    def execute
      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(0.1)
      end

      begin
        execute_without_lease
      ensure
        Gitlab::ExclusiveLease.cancel(lease_key, uuid)
      end
    end

    # This method returns the updated User object.
    def execute_without_lease
      current = current_authorizations_per_project
      fresh = fresh_access_levels_per_project

      # Delete projects that have more than one authorizations associated with
      # the user. The correct authorization is added to the ``add`` array in the
      # next stage.
      remove = projects_with_duplicates
      current.except!(*projects_with_duplicates)

      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
          if incorrect_auth_found_callback
            incorrect_auth_found_callback.call(project_id, row.access_level)
          end

          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
          if missing_auth_found_callback
            missing_auth_found_callback.call(project_id, level)
          end

          array << [user.id, project_id, level]
        end
      end

      update_authorizations(remove, add)
    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 = [])
      log_refresh_details(remove, add)

      User.transaction do
        user.remove_project_authorizations(remove) unless remove.empty?
        ProjectAuthorization.insert_authorizations(add) unless add.empty?
      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.reset
    end

    def log_refresh_details(remove, add)
      Gitlab::AppJsonLogger.info(event: 'authorized_projects_refresh',
                                 user_id: user.id,
                                 'authorized_projects_refresh.source': source,
                                 'authorized_projects_refresh.rows_deleted_count': remove.length,
                                 'authorized_projects_refresh.rows_added_count': add.length,
                                 # most often there's only a few entries in remove and add, but limit it to the first 5
                                 # entries to avoid flooding the logs
                                 'authorized_projects_refresh.rows_deleted_slice': remove.first(5),
                                 'authorized_projects_refresh.rows_added_slice': add.first(5))
    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.index_by(&:project_id)
    end

    def current_authorizations
      @current_authorizations ||= user.project_authorizations.select(:project_id, :access_level)
    end

    def fresh_authorizations
      Gitlab::ProjectAuthorizations.new(user).calculate
    end

    private

    attr_reader :incorrect_auth_found_callback, :missing_auth_found_callback

    def projects_with_duplicates
      @projects_with_duplicates ||= current_authorizations
                                      .group_by(&:project_id)
                                      .select { |project_id, authorizations| authorizations.count > 1 }
                                      .keys
    end
  end
end