X-Git-Url: https://git.arvados.org/arvados.git/blobdiff_plain/a66462de44bf709365aaa562570c9b6b68dd92b7..HEAD:/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 f8454029d6..8311278676 100644 --- a/services/api/app/models/api_client_authorization.rb +++ b/services/api/app/models/api_client_authorization.rb @@ -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,