X-Git-Url: https://git.arvados.org/arvados.git/blobdiff_plain/4bb449eb541e7bc22dfb09c31451d2258f189495..c7dfdc3f58e993abad5ef7fb898ac137cca62e02:/services/api/app/models/api_client_authorization.rb diff --git a/services/api/app/models/api_client_authorization.rb b/services/api/app/models/api_client_authorization.rb index 39253e1036..7c7ed759c6 100644 --- a/services/api/app/models/api_client_authorization.rb +++ b/services/api/app/models/api_client_authorization.rb @@ -7,12 +7,15 @@ class ApiClientAuthorization < ArvadosModel include KindAndEtag include CommonApiTemplate extend CurrentApiClient + extend DbCurrentTime belongs_to :api_client belongs_to :user 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 +90,56 @@ class ApiClientAuthorization < ArvadosModel end def self.remote_host(uuid_prefix:) - Rails.configuration.remote_hosts[uuid_prefix] || - (Rails.configuration.remote_hosts_via_dns && + (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 - remote ||= Rails.configuration.uuid_prefix + 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 +148,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,104 +163,246 @@ 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.uuid_prefix - # 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 - # 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 - clnt = HTTPClient.new - if Rails.configuration.sso_insecure - clnt.ssl_config.verify_mode = OpenSSL::SSL::VERIFY_NONE - end - remote_user = SafeJSON.load( - clnt.get_content('https://' + host + '/arvados/v1/users/current', - {'remote' => Rails.configuration.uuid_prefix}, - {'Authorization' => 'Bearer ' + token})) - rescue => e - Rails.logger.warn "remote authentication with token #{token.inspect} failed: #{e}" - return nil + # 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 + + 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. + 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})) + 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 - 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 + rescue HTTPClient::BadResponseError => e + if e.res.status != 401 + raise 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 + 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 + end + 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}" + 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 Rails.configuration.new_users_are_active || - Rails.configuration.auto_activate_users_from.include?(remote_user['uuid'][0..4]) - # 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 + 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 - %w[first_name last_name email prefs].each do |attr| - user.send(attr+'=', remote_user[attr]) + 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 - user.save! + 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 - auth = ApiClientAuthorization.find_or_create_by(uuid: uuid) do |auth| + # 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 + + # We will accept this token (and avoid reloading the user + # record) for 'RemoteTokenRefresh' (default 5 minutes). + exp = [db_current_time + Rails.configuration.Login.RemoteTokenRefresh, + remote_token.andand['expires_at']].compact.min + scopes = remote_token.andand['scopes'] || ['all'] + begin + retries ||= 0 + auth = ApiClientAuthorization.find_or_create_by(uuid: token_uuid) do |auth| auth.user = user - auth.api_token = secret + auth.api_token = stored_secret auth.api_client_id = 0 + auth.scopes = scopes + auth.expires_at = exp + 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 + retry + else + Rails.logger.warn("cannot find or create cached remote token #{token_uuid}") + return nil 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) end + auth.update_attributes!(user: user, + api_token: stored_secret, + api_client_id: 0, + scopes: scopes, + expires_at: exp) + Rails.logger.debug "cached remote token #{token_uuid} with secret #{stored_secret} and scopes #{scopes} 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 @@ -245,6 +427,15 @@ class ApiClientAuthorization < ArvadosModel protected + def clamp_token_expiration + if Rails.configuration.API.MaxTokenLifetime > 0 + max_token_expiration = db_current_time + Rails.configuration.API.MaxTokenLifetime + if (self.new_record? || self.expires_at_changed?) && (self.expires_at.nil? || (self.expires_at > max_token_expiration && !current_user.andand.is_admin)) + 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 @@ -255,6 +446,6 @@ class ApiClientAuthorization < ArvadosModel end def log_update - super unless (changed - UNLOGGED_CHANGES).empty? + super unless (saved_changes.keys - UNLOGGED_CHANGES).empty? end end