Merge branch '21666-provision-test-improvement'
[arvados.git] / services / api / app / models / api_client_authorization.rb
index f8454029d6b8cf2561505080ac5b74b8d57b8c70..83112786764d64377f3e6b7983fb1e3310ca59db 100644 (file)
@@ -6,11 +6,12 @@ class ApiClientAuthorization < ArvadosModel
   include HasUuid
   include KindAndEtag
   include CommonApiTemplate
+  include Rails.application.routes.url_helpers
   extend CurrentApiClient
   extend DbCurrentTime
 
-  belongs_to :api_client
-  belongs_to :user
+  belongs_to :api_client, optional: true
+  belongs_to :user, optional: true
   after_initialize :assign_random_api_token
   serialize :scopes, Array
 
@@ -35,7 +36,12 @@ class ApiClientAuthorization < ArvadosModel
   UNLOGGED_CHANGES = ['last_used_at', 'last_used_by_ip_address', 'updated_at']
 
   def assign_random_api_token
-    self.api_token ||= rand(2**256).to_s(36)
+    begin
+      self.api_token ||= rand(2**256).to_s(36)
+    rescue ActiveModel::MissingAttributeError
+      # Ignore the case where self.api_token doesn't exist, which happens when
+      # the select=[...] is used.
+    end
   end
 
   def owner_uuid
@@ -73,7 +79,9 @@ class ApiClientAuthorization < ArvadosModel
 
   def scopes_allow_request?(request)
     method = request.request_method
-    if method == 'HEAD'
+    if method == 'GET' and request.path == url_for(controller: 'arvados/v1/api_client_authorizations', action: 'current', only_path: true)
+      true
+    elsif method == 'HEAD'
       (scopes_allow?(['HEAD', request.path].join(' ')) ||
        scopes_allow?(['GET', request.path].join(' ')))
     else
@@ -111,7 +119,7 @@ class ApiClientAuthorization < ArvadosModel
     clnt
   end
 
-  def self.check_anonymous_user_token token
+  def self.check_anonymous_user_token(token:, remote:)
     case token[0..2]
     when 'v2/'
       _, token_uuid, secret, optional = token.split('/')
@@ -125,12 +133,18 @@ class ApiClientAuthorization < ArvadosModel
       secret = token
     end
 
+    # Usually, the secret is salted
+    salted_secret = OpenSSL::HMAC.hexdigest('sha1', Rails.configuration.Users.AnonymousUserToken, remote)
+
+    # The anonymous token could be specified as a full v2 token in the config,
+    # but the config loader strips it down to the secret part.
     # The anonymous token content and minimum length is verified in lib/config
-    if secret.length >= 0 && secret == Rails.configuration.Users.AnonymousUserToken
+    if secret.length >= 0 && (secret == Rails.configuration.Users.AnonymousUserToken || secret == salted_secret)
       return ApiClientAuthorization.new(user: User.find_by_uuid(anonymous_user_uuid),
                                         uuid: Rails.configuration.ClusterID+"-gj3su-anonymouspublic",
-                                        api_token: token,
-                                        api_client: anonymous_user_token_api_client)
+                                        api_token: secret,
+                                        api_client: anonymous_user_token_api_client,
+                                        scopes: ['GET /'])
     else
       return nil
     end
@@ -151,7 +165,7 @@ class ApiClientAuthorization < ArvadosModel
     return nil if token.nil? or token.empty?
     remote ||= Rails.configuration.ClusterID
 
-    auth = self.check_anonymous_user_token(token)
+    auth = self.check_anonymous_user_token(token: token, remote: remote)
     if !auth.nil?
       return auth
     end
@@ -260,136 +274,110 @@ class ApiClientAuthorization < ArvadosModel
       Rails.logger.warn "remote authentication rejected: no host for #{upstream_cluster_id.inspect}"
       return nil
     end
+    remote_url = URI::parse("https://#{host}/")
+    remote_query = {"remote" => Rails.configuration.ClusterID}
+    remote_headers = {"Authorization" => "Bearer #{token}"}
 
-    begin
-      remote_user = SafeJSON.load(
-        clnt.get_content('https://' + host + '/arvados/v1/users/current',
-                         {'remote' => Rails.configuration.ClusterID},
-                         {'Authorization' => 'Bearer ' + token}))
-    rescue => e
-      Rails.logger.warn "remote authentication with token #{token.inspect} failed: #{e}"
-      return nil
-    end
-
-    # Check the response is well formed.
-    if !remote_user.is_a?(Hash) || !remote_user['uuid'].is_a?(String)
-      Rails.logger.warn "remote authentication rejected: remote_user=#{remote_user.inspect}"
-      return nil
-    end
-
-    remote_user_prefix = remote_user['uuid'][0..4]
-
-    # Get token scope, and make sure we use the same UUID as the
-    # remote when caching the token.
+    # First get the current token. This query is not limited by token scopes,
+    # and tells us the user's UUID via owner_uuid, so this gives us enough
+    # information to load a local user record from the database if one exists.
     remote_token = nil
     begin
       remote_token = SafeJSON.load(
-        clnt.get_content('https://' + host + '/arvados/v1/api_client_authorizations/current',
-                         {'remote' => Rails.configuration.ClusterID},
-                         {'Authorization' => 'Bearer ' + token}))
+        clnt.get_content(
+          remote_url.merge("arvados/v1/api_client_authorizations/current"),
+          remote_query, remote_headers,
+        ))
       Rails.logger.debug "retrieved remote token #{remote_token.inspect}"
       token_uuid = remote_token['uuid']
       if !token_uuid.match(HasUuid::UUID_REGEX) || token_uuid[0..4] != upstream_cluster_id
         raise "remote cluster #{upstream_cluster_id} returned invalid token uuid #{token_uuid.inspect}"
       end
     rescue HTTPClient::BadResponseError => e
-      if e.res.status != 401
-        raise
+      if e.res.status_code >= 400 && e.res.status_code < 500
+        # Remote cluster does not accept this token.
+        return nil
       end
-      rev = SafeJSON.load(clnt.get_content('https://' + host + '/discovery/v1/apis/arvados/v1/rest'))['revision']
-      if rev >= '20010101' && rev < '20210503'
-        Rails.logger.warn "remote cluster #{upstream_cluster_id} at #{host} with api rev #{rev} does not provide token expiry and scopes; using scopes=['all']"
-      else
-        # remote server is new enough that it should have accepted
-        # this request if the token was valid
-        raise
+      # CurrentApiToken#call and ApplicationController#render_error will
+      # propagate the status code from the #http_status method, so define
+      # that here.
+      def e.http_status
+        self.res.status_code
       end
+      raise
+    # TODO #20927: Catch network exceptions and assign a 5xx status to them so
+    # the client knows they're a temporary problem.
     rescue => e
       Rails.logger.warn "error getting remote token details for #{token.inspect}: #{e}"
       return nil
     end
 
-    # Clusters can only authenticate for their own users.
-    if remote_user_prefix != upstream_cluster_id
-      Rails.logger.warn "remote authentication rejected: claimed remote user #{remote_user_prefix} but token was issued by #{upstream_cluster_id}"
+    # Next, load the token's user record from the database (might be nil).
+    remote_user_prefix, remote_user_suffix = remote_token['owner_uuid'].split('-', 2)
+    if anonymous_user_uuid.end_with?(remote_user_suffix)
+      # Special case: map the remote anonymous user to local anonymous user
+      remote_user_uuid = anonymous_user_uuid
+    else
+      remote_user_uuid = remote_token['owner_uuid']
+    end
+    user = User.find_by_uuid(remote_user_uuid)
+
+    # Next, try to load the remote user. If this succeeds, we'll use this
+    # information to update/create the local database record as necessary.
+    # If this fails for any reason, but we successfully loaded a user record
+    # from the database, we'll just rely on that information.
+    remote_user = nil
+    begin
+      remote_user = SafeJSON.load(
+        clnt.get_content(
+          remote_url.merge("arvados/v1/users/current"),
+          remote_query, remote_headers,
+        ))
+    rescue HTTPClient::BadResponseError => e
+      # If user is defined, we will use that alone for auth, see below.
+      if user.nil?
+        # See rationale in the previous BadResponseError rescue.
+        def e.http_status
+          self.res.status_code
+        end
+        raise
+      end
+    # TODO #20927: Catch network exceptions and assign a 5xx status to them so
+    # the client knows they're a temporary problem.
+    rescue => e
+      Rails.logger.warn "getting remote user with token #{token.inspect} failed: #{e}"
+    else
+      # Check the response is well formed.
+      if !remote_user.is_a?(Hash) || !remote_user['uuid'].is_a?(String)
+        Rails.logger.warn "malformed remote user=#{remote_user.inspect}"
+        remote_user = nil
+      # Clusters can only authenticate for their own users.
+      elsif remote_user_prefix != upstream_cluster_id
+        Rails.logger.warn "remote user rejected: claimed remote user #{remote_user_prefix} but token was issued by #{upstream_cluster_id}"
+        remote_user = nil
+      # Force our local copy of a remote root to have a static name
+      elsif system_user_uuid.end_with?(remote_user_suffix)
+        remote_user.update(
+          "first_name" => "root",
+          "last_name" => "from cluster #{remote_user_prefix}",
+        )
+      end
+    end
+
+    if user.nil? and remote_user.nil?
+      Rails.logger.warn "remote token #{token.inspect} rejected: cannot get owner #{remote_user_uuid} from database or remote cluster"
       return nil
     end
 
     # Invariant:    remote_user_prefix == upstream_cluster_id
     # therefore:    remote_user_prefix != Rails.configuration.ClusterID
-
     # Add or update user and token in local database so we can
     # validate subsequent requests faster.
 
-    if remote_user['uuid'][-22..-1] == '-tpzed-anonymouspublic'
-      # Special case: map the remote anonymous user to local anonymous user
-      remote_user['uuid'] = anonymous_user_uuid
-    end
-
-    user = User.find_by_uuid(remote_user['uuid'])
-
-    if !user
-      # Create a new record for this user.
-      user = User.new(uuid: remote_user['uuid'],
-                      is_active: false,
-                      is_admin: false,
-                      email: remote_user['email'],
-                      owner_uuid: system_user_uuid)
-      user.set_initial_username(requested: remote_user['username'])
-    end
-
-    # Sync user record.
     act_as_system_user do
-      %w[first_name last_name email prefs].each do |attr|
-        user.send(attr+'=', remote_user[attr])
-      end
-
-      if remote_user['uuid'][-22..-1] == '-tpzed-000000000000000'
-        user.first_name = "root"
-        user.last_name = "from cluster #{remote_user_prefix}"
-      end
-
-      begin
-        user.save!
-      rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
-        Rails.logger.debug("remote user #{remote_user['uuid']} already exists, retrying...")
-        # Some other request won the race: retry fetching the user record.
-        user = User.find_by_uuid(remote_user['uuid'])
-        if !user
-          Rails.logger.warn("cannot find or create remote user #{remote_user['uuid']}")
-          return nil
-        end
-      end
-
-      if user.is_invited && !remote_user['is_invited']
-        # Remote user is not "invited" state, they should be unsetup, which
-        # also makes them inactive.
-        user.unsetup
-      else
-        if !user.is_invited && remote_user['is_invited'] and
-          (remote_user_prefix == Rails.configuration.Login.LoginCluster or
-           Rails.configuration.Users.AutoSetupNewUsers or
-           Rails.configuration.Users.NewUsersAreActive or
-           Rails.configuration.RemoteClusters[remote_user_prefix].andand["ActivateUsers"])
-          user.setup
-        end
-
-        if !user.is_active && remote_user['is_active'] && user.is_invited and
-          (remote_user_prefix == Rails.configuration.Login.LoginCluster or
-           Rails.configuration.Users.NewUsersAreActive or
-           Rails.configuration.RemoteClusters[remote_user_prefix].andand["ActivateUsers"])
-          user.update_attributes!(is_active: true)
-        elsif user.is_active && !remote_user['is_active']
-          user.update_attributes!(is_active: false)
-        end
-
-        if remote_user_prefix == Rails.configuration.Login.LoginCluster and
-          user.is_active and
-          user.is_admin != remote_user['is_admin']
-          # Remote cluster controls our user database, including the
-          # admin flag.
-          user.update_attributes!(is_admin: remote_user['is_admin'])
-        end
+      if remote_user && remote_user_uuid != anonymous_user_uuid
+        # Sync user record if we loaded a remote user.
+        user = User.update_remote_user remote_user
       end
 
       # If stored_secret is set, we save stored_secret in the database
@@ -415,15 +403,24 @@ class ApiClientAuthorization < ArvadosModel
         end
       rescue ActiveRecord::RecordNotUnique
         Rails.logger.debug("cached remote token #{token_uuid} already exists, retrying...")
-        # Some other request won the race: retry just once before erroring out
-        if (retries += 1) <= 1
+        # Another request won the race (trying to find_or_create the
+        # same token UUID) ...and/or... there is an expired entry with
+        # the same secret but a different UUID (e.g., the token is an
+        # OIDC access token and [a] our database has an expired cached
+        # row that was not used above, and [b] the remote cluster had
+        # deleted its expired cached row so it assigned a new UUID).
+        #
+        # Delete any conflicting row if any. Retry twice (in case we
+        # hit both of those situations at once), then give up.
+        if (retries += 1) <= 2
+          ApiClientAuthorization.where('api_token=? and uuid<>?', stored_secret, token_uuid).delete_all
           retry
         else
           Rails.logger.warn("cannot find or create cached remote token #{token_uuid}")
           return nil
         end
       end
-      auth.update_attributes!(user: user,
+      auth.update!(user: user,
                               api_token: stored_secret,
                               api_client_id: 0,
                               scopes: scopes,