16736: Limit token's expires_at depending on the cluster config and user type.
[arvados.git] / services / api / app / models / api_client_authorization.rb
index 7645d1597ca726579dd91ead5285a9f0253c3873..4218645d5daf3014369f693ea7faa68fed9546dd 100644 (file)
@@ -13,6 +13,8 @@ class ApiClientAuthorization < ArvadosModel
   after_initialize :assign_random_api_token
   serialize :scopes, Array
 
+  before_validation :clamp_token_expiration
+
   api_accessible :user, extend: :common do |t|
     t.add :owner_uuid
     t.add :user_id
@@ -87,19 +89,56 @@ class ApiClientAuthorization < ArvadosModel
   end
 
   def self.remote_host(uuid_prefix:)
-    (Rails.configuration.RemoteClusters[uuid_prefix].andand.Host) ||
-      (Rails.configuration.RemoteClusters["*"].Proxy &&
+    (Rails.configuration.RemoteClusters[uuid_prefix].andand["Host"]) ||
+      (Rails.configuration.RemoteClusters["*"]["Proxy"] &&
        uuid_prefix+".arvadosapi.com")
   end
 
+  def self.make_http_client(uuid_prefix:)
+    clnt = HTTPClient.new
+
+    if uuid_prefix && (Rails.configuration.RemoteClusters[uuid_prefix].andand.Insecure ||
+                       Rails.configuration.RemoteClusters['*'].andand.Insecure)
+      clnt.ssl_config.verify_mode = OpenSSL::SSL::VERIFY_NONE
+    else
+      # Use system CA certificates
+      ["/etc/ssl/certs/ca-certificates.crt",
+       "/etc/pki/tls/certs/ca-bundle.crt"]
+        .select { |ca_path| File.readable?(ca_path) }
+        .each { |ca_path| clnt.ssl_config.add_trust_ca(ca_path) }
+    end
+    clnt
+  end
+
+  def self.check_system_root_token token
+    if token == Rails.configuration.SystemRootToken
+      return ApiClientAuthorization.new(user: User.find_by_uuid(system_user_uuid),
+                                        uuid: Rails.configuration.ClusterID+"-gj3su-000000000000000",
+                                        api_token: token,
+                                        api_client: system_root_token_api_client)
+    else
+      return nil
+    end
+  end
+
   def self.validate(token:, remote: nil)
-    return nil if !token
+    return nil if token.nil? or token.empty?
     remote ||= Rails.configuration.ClusterID
 
+    auth = self.check_system_root_token(token)
+    if !auth.nil?
+      return auth
+    end
+
+    token_uuid = ''
+    secret = token
+    stored_secret = nil         # ...if different from secret
+    optional = nil
+
     case token[0..2]
     when 'v2/'
-      _, uuid, secret, optional = token.split('/')
-      unless uuid.andand.length == 27 && secret.andand.length.andand > 0
+      _, token_uuid, secret, optional = token.split('/')
+      unless token_uuid.andand.length == 27 && secret.andand.length.andand > 0
         return nil
       end
 
@@ -108,11 +147,11 @@ class ApiClientAuthorization < ArvadosModel
         # matches expections.
         c = Container.where(uuid: optional).first
         if !c.nil?
-          if !c.auth_uuid.nil? and c.auth_uuid != uuid
+          if !c.auth_uuid.nil? and c.auth_uuid != token_uuid
             # token doesn't match the container's token
             return nil
           end
-          if !c.runtime_token.nil? and "v2/#{uuid}/#{secret}" != c.runtime_token
+          if !c.runtime_token.nil? and "v2/#{token_uuid}/#{secret}" != c.runtime_token
             # token doesn't match the container's token
             return nil
           end
@@ -123,110 +162,206 @@ class ApiClientAuthorization < ArvadosModel
         end
       end
 
+      # fast path: look up the token in the local database
       auth = ApiClientAuthorization.
              includes(:user, :api_client).
-             where('uuid=? and (expires_at is null or expires_at > CURRENT_TIMESTAMP)', uuid).
+             where('uuid=? and (expires_at is null or expires_at > CURRENT_TIMESTAMP)', token_uuid).
              first
       if auth && auth.user &&
          (secret == auth.api_token ||
           secret == OpenSSL::HMAC.hexdigest('sha1', auth.api_token, remote))
+        # found it
+        if token_uuid[0..4] != Rails.configuration.ClusterID
+          Rails.logger.debug "found cached remote token #{token_uuid} with secret #{secret} in local db"
+        end
         return auth
       end
 
-      uuid_prefix = uuid[0..4]
-      if uuid_prefix == Rails.configuration.ClusterID
-        # If the token were valid, we would have validated it above
+      upstream_cluster_id = token_uuid[0..4]
+      if upstream_cluster_id == Rails.configuration.ClusterID
+        # Token is supposedly issued by local cluster, but if the
+        # token were valid, we would have been found in the database
+        # in the above query.
         return nil
-      elsif uuid_prefix.length != 5
+      elsif upstream_cluster_id.length != 5
         # malformed
         return nil
       end
 
-      host = remote_host(uuid_prefix: uuid_prefix)
-      if !host
-        Rails.logger.warn "remote authentication rejected: no host for #{uuid_prefix.inspect}"
+    else
+      # token is not a 'v2' token. It could be just the secret part
+      # ("v1 token") -- or it could be an OpenIDConnect access token,
+      # in which case either (a) the controller will have inserted a
+      # row with api_token = hmac(systemroottoken,oidctoken) before
+      # forwarding it, or (b) we'll have done that ourselves, or (c)
+      # we'll need to ask LoginCluster to validate it for us below,
+      # and then insert a local row for a faster lookup next time.
+      hmac = OpenSSL::HMAC.hexdigest('sha256', Rails.configuration.SystemRootToken, token)
+      auth = ApiClientAuthorization.
+               includes(:user, :api_client).
+               where('api_token in (?, ?) and (expires_at is null or expires_at > CURRENT_TIMESTAMP)', token, hmac).
+               first
+      if auth && auth.user
+        return auth
+      elsif !Rails.configuration.Login.LoginCluster.blank? && Rails.configuration.Login.LoginCluster != Rails.configuration.ClusterID
+        # An unrecognized non-v2 token might be an OIDC Access Token
+        # that can be verified by our login cluster in the code
+        # below. If so, we'll stuff the database with hmac instead of
+        # the real OIDC token.
+        upstream_cluster_id = Rails.configuration.Login.LoginCluster
+        stored_secret = hmac
+      else
         return nil
       end
+    end
+
+    # Invariant: upstream_cluster_id != Rails.configuration.ClusterID
+    #
+    # In other words the remaining code in this method decides
+    # whether to accept a token that was issued by a remote cluster
+    # when the token is absent or expired in our database.  To
+    # begin, we need to ask the cluster that issued the token to
+    # [re]validate it.
+    clnt = ApiClientAuthorization.make_http_client(uuid_prefix: upstream_cluster_id)
+
+    host = remote_host(uuid_prefix: upstream_cluster_id)
+    if !host
+      Rails.logger.warn "remote authentication rejected: no host for #{upstream_cluster_id.inspect}"
+      return nil
+    end
 
-      # Token was issued by a different cluster. If it's expired or
-      # missing in our database, ask the originating cluster to
-      # [re]validate it.
+    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]
+
+    if token_uuid == ''
+      # Use the same UUID as the remote when caching the token.
       begin
-        clnt = HTTPClient.new
-        if Rails.configuration.TLS.Insecure
-          clnt.ssl_config.verify_mode = OpenSSL::SSL::VERIFY_NONE
-        else
-          # Use system CA certificates
-          ["/etc/ssl/certs/ca-certificates.crt",
-           "/etc/pki/tls/certs/ca-bundle.crt"]
-            .select { |ca_path| File.readable?(ca_path) }
-            .each { |ca_path| clnt.ssl_config.add_trust_ca(ca_path) }
-        end
-        remote_user = SafeJSON.load(
-          clnt.get_content('https://' + host + '/arvados/v1/users/current',
+        remote_token = SafeJSON.load(
+          clnt.get_content('https://' + host + '/arvados/v1/api_client_authorizations/current',
                            {'remote' => Rails.configuration.ClusterID},
                            {'Authorization' => 'Bearer ' + token}))
+        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 => e
-        Rails.logger.warn "remote authentication with token #{token.inspect} failed: #{e}"
+        Rails.logger.warn "error getting remote token details for #{token.inspect}: #{e}"
         return nil
       end
-      if !remote_user.is_a?(Hash) || !remote_user['uuid'].is_a?(String) || remote_user['uuid'][0..4] != uuid[0..4]
-        Rails.logger.warn "remote authentication rejected: remote_user=#{remote_user.inspect}"
-        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}"
+      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
-      act_as_system_user do
-        # Add/update user and token in our database so we can
-        # validate subsequent requests faster.
-
-        user = User.find_or_create_by(uuid: remote_user['uuid']) do |user|
-          # (this block runs for the "create" case, not for "find")
-          user.is_admin = false
-          user.email = remote_user['email']
-          if remote_user['username'].andand.length.andand > 0
-            user.set_initial_username(requested: remote_user['username'])
-          end
-        end
 
-        if Rails.configuration.Users.NewUsersAreActive ||
-           Rails.configuration.RemoteClusters[remote_user['uuid'][0..4]].andand["ActivateUsers"]
-          # Update is_active to whatever it is at the remote end
-          user.is_active = remote_user['is_active']
-        elsif !remote_user['is_active']
-          # Remote user is inactive; our mirror should be, too.
-          user.is_active = false
-        end
+      if remote_user['uuid'][-22..-1] == '-tpzed-000000000000000'
+        user.first_name = "root"
+        user.last_name = "from cluster #{remote_user_prefix}"
+      end
 
-        %w[first_name last_name email prefs].each do |attr|
-          user.send(attr+'=', remote_user[attr])
+      user.save!
+
+      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
 
-        user.save!
+        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
 
-        auth = ApiClientAuthorization.find_or_create_by(uuid: uuid) do |auth|
-          auth.user = user
-          auth.api_token = secret
-          auth.api_client_id = 0
+        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
+      end
 
-        # Accept this token (and don't reload the user record) for
-        # 5 minutes. TODO: Request the actual api_client_auth
-        # record from the remote server in case it wants the token
-        # to expire sooner.
-        auth.update_attributes!(user: user,
-                                api_token: secret,
-                                api_client_id: 0,
-                                expires_at: Time.now + 5.minutes)
+      # We will accept this token (and avoid reloading the user
+      # record) for 'RemoteTokenRefresh' (default 5 minutes).
+      # Possible todo:
+      # Request the actual api_client_auth record from the remote
+      # server in case it wants the token to expire sooner.
+      auth = ApiClientAuthorization.find_or_create_by(uuid: token_uuid) do |auth|
+        auth.user = user
+        auth.api_client_id = 0
       end
+      # If stored_secret is set, we save stored_secret in the database
+      # but return the real secret to the caller. This way, if we end
+      # up returning the auth record to the client, they see the same
+      # secret they supplied, instead of the HMAC we saved in the
+      # database.
+      stored_secret = stored_secret || secret
+      auth.update_attributes!(user: user,
+                              api_token: stored_secret,
+                              api_client_id: 0,
+                              expires_at: Time.now + Rails.configuration.Login.RemoteTokenRefresh)
+      Rails.logger.debug "cached remote token #{token_uuid} with secret #{stored_secret} in local db"
+      auth.api_token = secret
       return auth
-    else
-      auth = ApiClientAuthorization.
-             includes(:user, :api_client).
-             where('api_token=? and (expires_at is null or expires_at > CURRENT_TIMESTAMP)', token).
-             first
-      if auth && auth.user
-        return auth
-      end
     end
+
     return nil
   end
 
@@ -251,6 +386,17 @@ class ApiClientAuthorization < ArvadosModel
 
   protected
 
+  def clamp_token_expiration
+    if !current_user.andand.is_admin && Rails.configuration.API.MaxTokenLifetime > 0
+      max_token_expiration = Time.now + Rails.configuration.API.MaxTokenLifetime
+      if self.new_record? && (self.expires_at.nil? || self.expires_at > max_token_expiration)
+        self.expires_at = max_token_expiration
+      elsif !self.new_record? && self.expires_at_changed? && (self.expires_at.nil? || self.expires_at > max_token_expiration)
+        self.expires_at = max_token_expiration
+      end
+    end
+  end
+
   def permission_to_create
     current_user.andand.is_admin or (current_user.andand.id == self.user_id)
   end
@@ -261,6 +407,6 @@ class ApiClientAuthorization < ArvadosModel
   end
 
   def log_update
-    super unless (changed - UNLOGGED_CHANGES).empty?
+    super unless (saved_changes.keys - UNLOGGED_CHANGES).empty?
   end
 end