21700: Install Bundler system-wide in Rails postinst
[arvados.git] / services / api / app / models / api_client_authorization.rb
1 # Copyright (C) The Arvados Authors. All rights reserved.
2 #
3 # SPDX-License-Identifier: AGPL-3.0
4
5 class ApiClientAuthorization < ArvadosModel
6   include HasUuid
7   include KindAndEtag
8   include CommonApiTemplate
9   include Rails.application.routes.url_helpers
10   extend CurrentApiClient
11   extend DbCurrentTime
12
13   belongs_to :api_client, optional: true
14   belongs_to :user, optional: true
15   after_initialize :assign_random_api_token
16   serialize :scopes, Array
17
18   before_validation :clamp_token_expiration
19
20   api_accessible :user, extend: :common do |t|
21     t.add :owner_uuid
22     t.add :user_id
23     t.add :api_client_id
24     # NB the "api_token" db column is a misnomer in that it's only the
25     # "secret" part of a token: a v1 token is just the secret, but a
26     # v2 token is "v2/uuid/secret".
27     t.add :api_token
28     t.add :created_by_ip_address
29     t.add :default_owner_uuid
30     t.add :expires_at
31     t.add :last_used_at
32     t.add :last_used_by_ip_address
33     t.add :scopes
34   end
35
36   UNLOGGED_CHANGES = ['last_used_at', 'last_used_by_ip_address', 'updated_at']
37
38   def assign_random_api_token
39     begin
40       self.api_token ||= rand(2**256).to_s(36)
41     rescue ActiveModel::MissingAttributeError
42       # Ignore the case where self.api_token doesn't exist, which happens when
43       # the select=[...] is used.
44     end
45   end
46
47   def owner_uuid
48     self.user.andand.uuid
49   end
50   def owner_uuid_was
51     self.user_id_changed? ? User.where(id: self.user_id_was).first.andand.uuid : self.user.andand.uuid
52   end
53   def owner_uuid_changed?
54     self.user_id_changed?
55   end
56
57   def modified_by_client_uuid
58     nil
59   end
60   def modified_by_client_uuid=(x) end
61
62   def modified_by_user_uuid
63     nil
64   end
65   def modified_by_user_uuid=(x) end
66
67   def modified_at
68     nil
69   end
70   def modified_at=(x) end
71
72   def scopes_allow?(req_s)
73     scopes.each do |scope|
74       return true if (scope == 'all') or (scope == req_s) or
75         ((scope.end_with? '/') and (req_s.start_with? scope))
76     end
77     false
78   end
79
80   def scopes_allow_request?(request)
81     method = request.request_method
82     if method == 'GET' and request.path == url_for(controller: 'arvados/v1/api_client_authorizations', action: 'current', only_path: true)
83       true
84     elsif method == 'HEAD'
85       (scopes_allow?(['HEAD', request.path].join(' ')) ||
86        scopes_allow?(['GET', request.path].join(' ')))
87     else
88       scopes_allow?([method, request.path].join(' '))
89     end
90   end
91
92   def logged_attributes
93     super.except 'api_token'
94   end
95
96   def self.default_orders
97     ["#{table_name}.id desc"]
98   end
99
100   def self.remote_host(uuid_prefix:)
101     (Rails.configuration.RemoteClusters[uuid_prefix].andand["Host"]) ||
102       (Rails.configuration.RemoteClusters["*"]["Proxy"] &&
103        uuid_prefix+".arvadosapi.com")
104   end
105
106   def self.make_http_client(uuid_prefix:)
107     clnt = HTTPClient.new
108
109     if uuid_prefix && (Rails.configuration.RemoteClusters[uuid_prefix].andand.Insecure ||
110                        Rails.configuration.RemoteClusters['*'].andand.Insecure)
111       clnt.ssl_config.verify_mode = OpenSSL::SSL::VERIFY_NONE
112     else
113       # Use system CA certificates
114       ["/etc/ssl/certs/ca-certificates.crt",
115        "/etc/pki/tls/certs/ca-bundle.crt"]
116         .select { |ca_path| File.readable?(ca_path) }
117         .each { |ca_path| clnt.ssl_config.add_trust_ca(ca_path) }
118     end
119     clnt
120   end
121
122   def self.check_anonymous_user_token(token:, remote:)
123     case token[0..2]
124     when 'v2/'
125       _, token_uuid, secret, optional = token.split('/')
126       unless token_uuid.andand.length == 27 && secret.andand.length.andand > 0 &&
127              token_uuid == Rails.configuration.ClusterID+"-gj3su-anonymouspublic"
128         # invalid v2 token, or v2 token for another user
129         return nil
130       end
131     else
132       # v1 token
133       secret = token
134     end
135
136     # Usually, the secret is salted
137     salted_secret = OpenSSL::HMAC.hexdigest('sha1', Rails.configuration.Users.AnonymousUserToken, remote)
138
139     # The anonymous token could be specified as a full v2 token in the config,
140     # but the config loader strips it down to the secret part.
141     # The anonymous token content and minimum length is verified in lib/config
142     if secret.length >= 0 && (secret == Rails.configuration.Users.AnonymousUserToken || secret == salted_secret)
143       return ApiClientAuthorization.new(user: User.find_by_uuid(anonymous_user_uuid),
144                                         uuid: Rails.configuration.ClusterID+"-gj3su-anonymouspublic",
145                                         api_token: secret,
146                                         api_client: anonymous_user_token_api_client,
147                                         scopes: ['GET /'])
148     else
149       return nil
150     end
151   end
152
153   def self.check_system_root_token token
154     if token == Rails.configuration.SystemRootToken
155       return ApiClientAuthorization.new(user: User.find_by_uuid(system_user_uuid),
156                                         uuid: Rails.configuration.ClusterID+"-gj3su-000000000000000",
157                                         api_token: token,
158                                         api_client: system_root_token_api_client)
159     else
160       return nil
161     end
162   end
163
164   def self.validate(token:, remote: nil)
165     return nil if token.nil? or token.empty?
166     remote ||= Rails.configuration.ClusterID
167
168     auth = self.check_anonymous_user_token(token: token, remote: remote)
169     if !auth.nil?
170       return auth
171     end
172
173     auth = self.check_system_root_token(token)
174     if !auth.nil?
175       return auth
176     end
177
178     token_uuid = ''
179     secret = token
180     stored_secret = nil         # ...if different from secret
181     optional = nil
182
183     case token[0..2]
184     when 'v2/'
185       _, token_uuid, secret, optional = token.split('/')
186       unless token_uuid.andand.length == 27 && secret.andand.length.andand > 0
187         return nil
188       end
189
190       if !optional.nil?
191         # if "optional" is a container uuid, check that it
192         # matches expections.
193         c = Container.where(uuid: optional).first
194         if !c.nil?
195           if !c.auth_uuid.nil? and c.auth_uuid != token_uuid
196             # token doesn't match the container's token
197             return nil
198           end
199           if !c.runtime_token.nil? and "v2/#{token_uuid}/#{secret}" != c.runtime_token
200             # token doesn't match the container's token
201             return nil
202           end
203           if ![Container::Locked, Container::Running].include?(c.state)
204             # container isn't locked or running, token shouldn't be used
205             return nil
206           end
207         end
208       end
209
210       # fast path: look up the token in the local database
211       auth = ApiClientAuthorization.
212              includes(:user, :api_client).
213              where('uuid=? and (expires_at is null or expires_at > CURRENT_TIMESTAMP)', token_uuid).
214              first
215       if auth && auth.user &&
216          (secret == auth.api_token ||
217           secret == OpenSSL::HMAC.hexdigest('sha1', auth.api_token, remote))
218         # found it
219         if token_uuid[0..4] != Rails.configuration.ClusterID
220           Rails.logger.debug "found cached remote token #{token_uuid} with secret #{secret} in local db"
221         end
222         return auth
223       end
224
225       upstream_cluster_id = token_uuid[0..4]
226       if upstream_cluster_id == Rails.configuration.ClusterID
227         # Token is supposedly issued by local cluster, but if the
228         # token were valid, we would have been found in the database
229         # in the above query.
230         return nil
231       elsif upstream_cluster_id.length != 5
232         # malformed
233         return nil
234       end
235
236     else
237       # token is not a 'v2' token. It could be just the secret part
238       # ("v1 token") -- or it could be an OpenIDConnect access token,
239       # in which case either (a) the controller will have inserted a
240       # row with api_token = hmac(systemroottoken,oidctoken) before
241       # forwarding it, or (b) we'll have done that ourselves, or (c)
242       # we'll need to ask LoginCluster to validate it for us below,
243       # and then insert a local row for a faster lookup next time.
244       hmac = OpenSSL::HMAC.hexdigest('sha256', Rails.configuration.SystemRootToken, token)
245       auth = ApiClientAuthorization.
246                includes(:user, :api_client).
247                where('api_token in (?, ?) and (expires_at is null or expires_at > CURRENT_TIMESTAMP)', token, hmac).
248                first
249       if auth && auth.user
250         return auth
251       elsif !Rails.configuration.Login.LoginCluster.blank? && Rails.configuration.Login.LoginCluster != Rails.configuration.ClusterID
252         # An unrecognized non-v2 token might be an OIDC Access Token
253         # that can be verified by our login cluster in the code
254         # below. If so, we'll stuff the database with hmac instead of
255         # the real OIDC token.
256         upstream_cluster_id = Rails.configuration.Login.LoginCluster
257         stored_secret = hmac
258       else
259         return nil
260       end
261     end
262
263     # Invariant: upstream_cluster_id != Rails.configuration.ClusterID
264     #
265     # In other words the remaining code in this method decides
266     # whether to accept a token that was issued by a remote cluster
267     # when the token is absent or expired in our database.  To
268     # begin, we need to ask the cluster that issued the token to
269     # [re]validate it.
270     clnt = ApiClientAuthorization.make_http_client(uuid_prefix: upstream_cluster_id)
271
272     host = remote_host(uuid_prefix: upstream_cluster_id)
273     if !host
274       Rails.logger.warn "remote authentication rejected: no host for #{upstream_cluster_id.inspect}"
275       return nil
276     end
277     remote_url = URI::parse("https://#{host}/")
278     remote_query = {"remote" => Rails.configuration.ClusterID}
279     remote_headers = {"Authorization" => "Bearer #{token}"}
280
281     # First get the current token. This query is not limited by token scopes,
282     # and tells us the user's UUID via owner_uuid, so this gives us enough
283     # information to load a local user record from the database if one exists.
284     remote_token = nil
285     begin
286       remote_token = SafeJSON.load(
287         clnt.get_content(
288           remote_url.merge("arvados/v1/api_client_authorizations/current"),
289           remote_query, remote_headers,
290         ))
291       Rails.logger.debug "retrieved remote token #{remote_token.inspect}"
292       token_uuid = remote_token['uuid']
293       if !token_uuid.match(HasUuid::UUID_REGEX) || token_uuid[0..4] != upstream_cluster_id
294         raise "remote cluster #{upstream_cluster_id} returned invalid token uuid #{token_uuid.inspect}"
295       end
296     rescue HTTPClient::BadResponseError => e
297       if e.res.status_code >= 400 && e.res.status_code < 500
298         # Remote cluster does not accept this token.
299         return nil
300       end
301       # CurrentApiToken#call and ApplicationController#render_error will
302       # propagate the status code from the #http_status method, so define
303       # that here.
304       def e.http_status
305         self.res.status_code
306       end
307       raise
308     # TODO #20927: Catch network exceptions and assign a 5xx status to them so
309     # the client knows they're a temporary problem.
310     rescue => e
311       Rails.logger.warn "error getting remote token details for #{token.inspect}: #{e}"
312       return nil
313     end
314
315     # Next, load the token's user record from the database (might be nil).
316     remote_user_prefix, remote_user_suffix = remote_token['owner_uuid'].split('-', 2)
317     if anonymous_user_uuid.end_with?(remote_user_suffix)
318       # Special case: map the remote anonymous user to local anonymous user
319       remote_user_uuid = anonymous_user_uuid
320     else
321       remote_user_uuid = remote_token['owner_uuid']
322     end
323     user = User.find_by_uuid(remote_user_uuid)
324
325     # Next, try to load the remote user. If this succeeds, we'll use this
326     # information to update/create the local database record as necessary.
327     # If this fails for any reason, but we successfully loaded a user record
328     # from the database, we'll just rely on that information.
329     remote_user = nil
330     begin
331       remote_user = SafeJSON.load(
332         clnt.get_content(
333           remote_url.merge("arvados/v1/users/current"),
334           remote_query, remote_headers,
335         ))
336     rescue HTTPClient::BadResponseError => e
337       # If user is defined, we will use that alone for auth, see below.
338       if user.nil?
339         # See rationale in the previous BadResponseError rescue.
340         def e.http_status
341           self.res.status_code
342         end
343         raise
344       end
345     # TODO #20927: Catch network exceptions and assign a 5xx status to them so
346     # the client knows they're a temporary problem.
347     rescue => e
348       Rails.logger.warn "getting remote user with token #{token.inspect} failed: #{e}"
349     else
350       # Check the response is well formed.
351       if !remote_user.is_a?(Hash) || !remote_user['uuid'].is_a?(String)
352         Rails.logger.warn "malformed remote user=#{remote_user.inspect}"
353         remote_user = nil
354       # Clusters can only authenticate for their own users.
355       elsif remote_user_prefix != upstream_cluster_id
356         Rails.logger.warn "remote user rejected: claimed remote user #{remote_user_prefix} but token was issued by #{upstream_cluster_id}"
357         remote_user = nil
358       # Force our local copy of a remote root to have a static name
359       elsif system_user_uuid.end_with?(remote_user_suffix)
360         remote_user.update(
361           "first_name" => "root",
362           "last_name" => "from cluster #{remote_user_prefix}",
363         )
364       end
365     end
366
367     if user.nil? and remote_user.nil?
368       Rails.logger.warn "remote token #{token.inspect} rejected: cannot get owner #{remote_user_uuid} from database or remote cluster"
369       return nil
370     end
371
372     # Invariant:    remote_user_prefix == upstream_cluster_id
373     # therefore:    remote_user_prefix != Rails.configuration.ClusterID
374     # Add or update user and token in local database so we can
375     # validate subsequent requests faster.
376
377     act_as_system_user do
378       if remote_user && remote_user_uuid != anonymous_user_uuid
379         # Sync user record if we loaded a remote user.
380         user = User.update_remote_user remote_user
381       end
382
383       # If stored_secret is set, we save stored_secret in the database
384       # but return the real secret to the caller. This way, if we end
385       # up returning the auth record to the client, they see the same
386       # secret they supplied, instead of the HMAC we saved in the
387       # database.
388       stored_secret = stored_secret || secret
389
390       # We will accept this token (and avoid reloading the user
391       # record) for 'RemoteTokenRefresh' (default 5 minutes).
392       exp = [db_current_time + Rails.configuration.Login.RemoteTokenRefresh,
393              remote_token.andand['expires_at']].compact.min
394       scopes = remote_token.andand['scopes'] || ['all']
395       begin
396         retries ||= 0
397         auth = ApiClientAuthorization.find_or_create_by(uuid: token_uuid) do |auth|
398           auth.user = user
399           auth.api_token = stored_secret
400           auth.api_client_id = 0
401           auth.scopes = scopes
402           auth.expires_at = exp
403         end
404       rescue ActiveRecord::RecordNotUnique
405         Rails.logger.debug("cached remote token #{token_uuid} already exists, retrying...")
406         # Another request won the race (trying to find_or_create the
407         # same token UUID) ...and/or... there is an expired entry with
408         # the same secret but a different UUID (e.g., the token is an
409         # OIDC access token and [a] our database has an expired cached
410         # row that was not used above, and [b] the remote cluster had
411         # deleted its expired cached row so it assigned a new UUID).
412         #
413         # Delete any conflicting row if any. Retry twice (in case we
414         # hit both of those situations at once), then give up.
415         if (retries += 1) <= 2
416           ApiClientAuthorization.where('api_token=? and uuid<>?', stored_secret, token_uuid).delete_all
417           retry
418         else
419           Rails.logger.warn("cannot find or create cached remote token #{token_uuid}")
420           return nil
421         end
422       end
423       auth.update!(user: user,
424                               api_token: stored_secret,
425                               api_client_id: 0,
426                               scopes: scopes,
427                               expires_at: exp)
428       Rails.logger.debug "cached remote token #{token_uuid} with secret #{stored_secret} and scopes #{scopes} in local db"
429       auth.api_token = secret
430       return auth
431     end
432
433     return nil
434   end
435
436   def token
437     v2token
438   end
439
440   def v1token
441     api_token
442   end
443
444   def v2token
445     'v2/' + uuid + '/' + api_token
446   end
447
448   def salted_token(remote:)
449     if remote.nil?
450       token
451     end
452     'v2/' + uuid + '/' + OpenSSL::HMAC.hexdigest('sha1', api_token, remote)
453   end
454
455   protected
456
457   def clamp_token_expiration
458     if Rails.configuration.API.MaxTokenLifetime > 0
459       max_token_expiration = db_current_time + Rails.configuration.API.MaxTokenLifetime
460       if (self.new_record? || self.expires_at_changed?) && (self.expires_at.nil? || (self.expires_at > max_token_expiration && !current_user.andand.is_admin))
461         self.expires_at = max_token_expiration
462       end
463     end
464   end
465
466   def permission_to_create
467     current_user.andand.is_admin or (current_user.andand.id == self.user_id)
468   end
469
470   def permission_to_update
471     permission_to_create && !uuid_changed? &&
472       (current_user.andand.is_admin || !user_id_changed?)
473   end
474
475   def log_update
476     super unless (saved_changes.keys - UNLOGGED_CHANGES).empty?
477   end
478 end