blob: 8f2e26e2e50b5f2a33365cd1f3bdce71da82cd51 [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001# Copyright 2018 The Chromium Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style
3# license that can be found in the LICENSE file or at
4# https://developers.google.com/open-source/licenses/bsd
5
6from __future__ import print_function
7from __future__ import division
8from __future__ import absolute_import
9
10import cgi
11import functools
12import logging
13import time
14import sys
15
16from google.oauth2 import id_token
17from google.auth.transport import requests as google_requests
18
19from google.appengine.api import oauth
20from google.appengine.api import users
21from google.appengine.api import app_identity
22from google.protobuf import json_format
23from components.prpc import codes
24from components.prpc import server
25
26from framework import monitoring
27
28import settings
29from api.v3 import converters
30from framework import authdata
31from framework import exceptions
32from framework import framework_constants
33from framework import monitoring
34from framework import monorailcontext
35from framework import ratelimiter
36from framework import permissions
37from framework import sql
38from framework import xsrf
39from services import client_config_svc
40from services import features_svc
41
42
43# Header for XSRF token to protect cookie-based auth users.
44XSRF_TOKEN_HEADER = 'x-xsrf-token'
45# Header for test account email. Only accepted for local dev server.
46TEST_ACCOUNT_HEADER = 'x-test-account'
47# Optional header to help us understand why certain calls were made.
48REASON_HEADER = 'x-reason'
49# Optional header to help prevent double updates.
50REQUEST_ID_HEADER = 'x-request-id'
51# Domain for service account emails.
52SERVICE_ACCOUNT_DOMAIN = 'gserviceaccount.com'
53
54
55def ConvertPRPCStatusToHTTPStatus(context):
56 """pRPC uses internal codes 0..16, but we want to report HTTP codes."""
57 return server._PRPC_TO_HTTP_STATUS.get(context._code, 500)
58
59
60def PRPCMethod(func):
61 @functools.wraps(func)
62 def wrapper(self, request, prpc_context, cnxn=None):
63 return self.Run(
64 func, request, prpc_context, cnxn=cnxn)
65
66 wrapper.wrapped = func
67 return wrapper
68
69
70class MonorailServicer(object):
71 """Abstract base class for API servicers.
72 """
73
74 def __init__(self, services, make_rate_limiter=True, xsrf_timeout=None):
75 self.services = services
76 if make_rate_limiter:
77 self.rate_limiter = ratelimiter.ApiRateLimiter()
78 else:
79 self.rate_limiter = None
80 # We allow subclasses to specify a different timeout. This allows the
81 # RefreshToken method to check the token with a longer expiration and
82 # generate a new one.
83 self.xsrf_timeout = xsrf_timeout or xsrf.TOKEN_TIMEOUT_SEC
84 self.converter = None
85
86 def Run(
87 self, handler, request, prpc_context,
88 cnxn=None, perms=None, start_time=None, end_time=None):
89 """Run a Do* method in an API context.
90
91 Args:
92 handler: API handler method to call with MonorailContext and request.
93 request: API Request proto object.
94 prpc_context: pRPC context object with status code.
95 cnxn: Optional connection to SQL database.
96 perms: PermissionSet passed in during testing.
97 start_time: Int timestamp passed in during testing.
98 end_time: Int timestamp passed in during testing.
99
100 Returns:
101 The response proto returned from the handler or None if that
102 method raised an exception that we handle.
103
104 Raises:
105 Only programming errors should be raised as exceptions. All
106 exceptions for permission checks and input validation that are
107 raised in the Do* method are converted into pRPC status codes.
108 """
109 start_time = start_time or time.time()
110 cnxn = cnxn or sql.MonorailConnection()
111 if self.services.cache_manager:
112 self.services.cache_manager.DoDistributedInvalidation(cnxn)
113
114 response = None
115 requester_auth = None
116 metadata = dict(prpc_context.invocation_metadata())
117 mc = monorailcontext.MonorailContext(self.services, cnxn=cnxn, perms=perms)
118 try:
119 self.AssertBaseChecks(request, metadata)
120 client_id, requester_auth = self.GetAndAssertRequesterAuth(
121 cnxn, metadata, self.services)
122 logging.info('request proto is:\n%r\n', request)
123 logging.info('requester is %r', requester_auth.email)
124 monitoring.IncrementAPIRequestsCount(
125 'v3', client_id, client_email=requester_auth.email)
126
127 # TODO(crbug.com/monorail/8161)We pass in a None client_id for rate
128 # limiting because CheckStart and CheckEnd will track and limit requests
129 # per email and client_id separately.
130 # So if there are many site users one day, we may end up rate limiting our
131 # own site. With a None client_id we are only rate limiting by emails.
132 if self.rate_limiter:
133 self.rate_limiter.CheckStart(None, requester_auth.email, start_time)
134 mc.auth = requester_auth
135 if not perms:
136 # NOTE(crbug/monorail/7614): We rely on servicer methods to call
137 # to call LookupLoggedInUserPerms() with a project when they need to.
138 mc.LookupLoggedInUserPerms(None)
139
140 self.converter = converters.Converter(mc, self.services)
141 response = handler(self, mc, request)
142
143 except Exception as e:
144 if not self.ProcessException(e, prpc_context, mc):
145 raise e.__class__, e, sys.exc_info()[2]
146 finally:
147 if mc:
148 mc.CleanUp()
149 if self.rate_limiter and requester_auth and requester_auth.email:
150 end_time = end_time or time.time()
151 self.rate_limiter.CheckEnd(
152 None, requester_auth.email, end_time, start_time)
153 self.RecordMonitoringStats(start_time, request, response, prpc_context)
154
155 return response
156
157 def CheckIDToken(self, cnxn, metadata):
158 # type: (MonorailConnection, Mapping[str, str])
159 # -> Tuple[Optional[str], Optional[authdata.AuthData]]
160 """Authenticate user from an ID token.
161
162 Args:
163 cnxn: connection to the SQL database.
164 metadata: metadata sent by the client.
165
166 Returns:
167 The audience (AKA client_id) and a new AuthData object representing
168 the user making the request or (None, None) if no ID token was found.
169
170 Raises:
171 permissions.PermissionException: If the token is invalid, the client ID
172 is not allowlisted, or no user email was found in the ID token.
173 """
174 bearer = metadata.get('authorization')
175 if not bearer:
176 return None, None
177 if bearer.lower().startswith('bearer '):
178 token = bearer[7:]
179 else:
180 raise permissions.PermissionException('Invalid authorization token.')
181 # TODO(crbug.com/monorail/7724): Use cachecontrol module to cache
182 # certification used for verification.
183 request = google_requests.Request()
184
185 try:
186 id_info = id_token.verify_oauth2_token(token, request)
187 logging.info('ID token info: %r' % id_info)
188 except ValueError:
189 raise permissions.PermissionException(
190 'Invalid bearer token.')
191
192 audience = id_info['aud']
193 email = id_info.get('email')
194 if not email:
195 raise permissions.PermissionException(
196 'No email found in token info. '
197 'Make sure requests are made with scopes `openid` and `email`')
198
199 auth_client_ids, service_account_emails = (
200 client_config_svc.GetClientConfigSvc().GetClientIDEmails())
201
202 if email.endswith(SERVICE_ACCOUNT_DOMAIN):
203 # For service accounts, the email must be allowlisted to call the
204 # API and we must confirm that the ID token was meant for
205 # Monorail by checking the audience.
206
207 # An API call to any <version>-dot-<service>-dot-<app_id>.appspot.com
208 # must have token audience of `https://<app_id>.appspot.com`
209 app_id = app_identity.get_application_id() # e.g. 'monorail-prod'
210 host = 'https://%s.appspot.com' % app_id
211 if audience != host:
212 raise permissions.PermissionException(
213 'Invalid token audience: %s.' % audience)
214 if email not in service_account_emails:
215 raise permissions.PermissionException(
216 'Account %s is not allowlisted' % email)
217 else:
218 # For users, the audience is the client_id of the site used to make
219 # the call to Monorail's API. The client_id must be allow-listed.
220 if audience not in auth_client_ids:
221 raise permissions.PermissionException(
222 'Client %s is not allowlisted' % audience)
223
224 # We must confirm the client/email is allowlisted before we
225 # potentially auto-create the user account in Monorail.
226 return audience, authdata.AuthData.FromEmail(
227 cnxn, email, self.services, autocreate=True)
228
229 def GetAndAssertRequesterAuth(self, cnxn, metadata, services):
230 # type: (MonorailConnection, Mapping[str, str], Services ->
231 # Tuple[str, authdata.AuthData]
232 """Gets the requester identity and checks if the user has permission
233 to make the request.
234 Any users successfully authenticated with oauth must be allowlisted or
235 have accounts with the domains in api_allowed_email_domains.
236 Users identified using cookie-based auth must have valid XSRF tokens.
237 Test accounts ending with @example.com are only allowed in the
238 local_mode.
239
240 Args:
241 cnxn: connection to the SQL database.
242 metadata: metadata sent by the client.
243 services: connections to backend services.
244
245 Returns:
246 The client ID and a new AuthData object representing a signed in or
247 anonymous user.
248
249 Raises:
250 exceptions.NoSuchUserException: If the requester does not exist
251 permissions.BannedUserException: If the user has been banned from the site
252 permissions.PermissionException: If the user is not authorized with the
253 Monorail scope, is not allowlisted, and has an invalid token.
254 """
255 # TODO(monorail:6538): Move different authentication methods into separate
256 # functions.
257 requester_auth = None
258 client_id = None
259 # When running on localhost, allow request to specify test account.
260 if TEST_ACCOUNT_HEADER in metadata:
261 if not settings.local_mode:
262 raise exceptions.InputException(
263 'x-test-account only accepted in local_mode')
264 # For local development, we accept any request.
265 # TODO(jrobbins): make this more realistic by requiring a fake XSRF token.
266 test_account = metadata[TEST_ACCOUNT_HEADER]
267 if not test_account.endswith('@example.com'):
268 raise exceptions.InputException(
269 'test_account must end with @example.com')
270 logging.info('Using test_account: %r' % test_account)
271 requester_auth = authdata.AuthData.FromEmail(cnxn, test_account, services)
272
273 # Oauth2 ID token auth.
274 if not requester_auth:
275 client_id, requester_auth = self.CheckIDToken(cnxn, metadata)
276
277 if client_id is None:
278 # TODO(crbug.com/monorail/8160): For site users, we temporarily use
279 # the host as the client_id, until we implement auth in the frontend
280 # to make API requests with ID tokens that include client_ids.
281 client_id = 'https://%s.appspot.com' % app_identity.get_application_id()
282
283
284 # Cookie-based auth for signed in and anonymous users.
285 if not requester_auth:
286 # Check for signed in user
287 user = users.get_current_user()
288 if user:
289 logging.info('Using cookie user: %r', user.email())
290 requester_auth = authdata.AuthData.FromEmail(
291 cnxn, user.email(), services)
292 else:
293 # Create AuthData for anonymous user.
294 requester_auth = authdata.AuthData.FromEmail(cnxn, None, services)
295
296 # Cookie-based auth signed-in and anon users need to have the XSRF
297 # token validate.
298 try:
299 token = metadata.get(XSRF_TOKEN_HEADER)
300 xsrf.ValidateToken(
301 token, requester_auth.user_id, xsrf.XHR_SERVLET_PATH,
302 timeout=self.xsrf_timeout)
303 except xsrf.TokenIncorrect:
304 raise permissions.PermissionException(
305 'Requester %s does not have permission to make this request.'
306 % requester_auth.email)
307
308 if permissions.IsBanned(requester_auth.user_pb, requester_auth.user_view):
309 raise permissions.BannedUserException(
310 'The user %s has been banned from using this site' %
311 requester_auth.email)
312
313 return (client_id, requester_auth)
314
315 def AssertBaseChecks(self, request, metadata):
316 """Reject requests that we refuse to serve."""
317 # TODO(jrobbins): Add read_only check as an exception raised in sql.py.
318 if (settings.read_only and
319 not request.__class__.__name__.startswith(('Get', 'List'))):
320 raise permissions.PermissionException(
321 'This request is not allowed in read-only mode')
322
323 if REASON_HEADER in metadata:
324 logging.info('Request reason: %r', metadata[REASON_HEADER])
325 if REQUEST_ID_HEADER in metadata:
326 # TODO(jrobbins): Ignore requests with duplicate request_ids.
327 logging.info('request_id: %r', metadata[REQUEST_ID_HEADER])
328
329 def ProcessException(self, e, prpc_context, mc):
330 """Return True if we convert an exception to a pRPC status code."""
331 logging.exception(e)
332 logging.info(e.message)
333 exc_type = type(e)
334 if exc_type == exceptions.NoSuchUserException:
335 prpc_context.set_code(codes.StatusCode.NOT_FOUND)
336 prpc_context.set_details('The user does not exist.')
337 elif exc_type == exceptions.NoSuchProjectException:
338 prpc_context.set_code(codes.StatusCode.NOT_FOUND)
339 prpc_context.set_details('The project does not exist.')
340 elif exc_type == exceptions.NoSuchTemplateException:
341 prpc_context.set_code(codes.StatusCode.NOT_FOUND)
342 prpc_context.set_details('The template does not exist.')
343 elif exc_type == exceptions.NoSuchIssueException:
344 prpc_context.set_code(codes.StatusCode.NOT_FOUND)
345 details = 'The issue does not exist.'
346 if e.message:
347 details = cgi.escape(e.message, quote=True)
348 prpc_context.set_details(details)
349 elif exc_type == exceptions.NoSuchIssueApprovalException:
350 prpc_context.set_code(codes.StatusCode.NOT_FOUND)
351 prpc_context.set_details('The issue approval does not exist.')
352 elif exc_type == exceptions.NoSuchCommentException:
353 prpc_context.set_code(codes.StatusCode.INVALID_ARGUMENT)
354 prpc_context.set_details('No such comment')
355 elif exc_type == exceptions.NoSuchComponentException:
356 prpc_context.set_code(codes.StatusCode.NOT_FOUND)
357 prpc_context.set_details('The component does not exist.')
358 elif exc_type == permissions.BannedUserException:
359 prpc_context.set_code(codes.StatusCode.PERMISSION_DENIED)
360 prpc_context.set_details('The requesting user has been banned.')
361 elif exc_type == permissions.PermissionException:
362 logging.info('perms is %r', mc.perms)
363 prpc_context.set_code(codes.StatusCode.PERMISSION_DENIED)
364 prpc_context.set_details('Permission denied.')
365 elif exc_type == exceptions.GroupExistsException:
366 prpc_context.set_code(codes.StatusCode.ALREADY_EXISTS)
367 prpc_context.set_details('The user group already exists.')
368 elif exc_type == features_svc.HotlistAlreadyExists:
369 prpc_context.set_code(codes.StatusCode.ALREADY_EXISTS)
370 prpc_context.set_details('A hotlist with that name already exists.')
371 elif exc_type == exceptions.ComponentDefAlreadyExists:
372 prpc_context.set_code(codes.StatusCode.ALREADY_EXISTS)
373 prpc_context.set_details('A component with that path already exists.')
374 elif exc_type == exceptions.ActionNotSupported:
375 prpc_context.set_code(codes.StatusCode.INVALID_ARGUMENT)
376 prpc_context.set_details('Requested action not supported.')
377 elif exc_type == exceptions.InvalidComponentNameException:
378 prpc_context.set_code(codes.StatusCode.INVALID_ARGUMENT)
379 prpc_context.set_details('That component name is invalid.')
380 elif exc_type == exceptions.FilterRuleException:
381 prpc_context.set_code(codes.StatusCode.INVALID_ARGUMENT)
382 prpc_context.set_details('Violates filter rule that should error.')
383 elif exc_type == exceptions.InputException:
384 prpc_context.set_code(codes.StatusCode.INVALID_ARGUMENT)
385 prpc_context.set_details(
386 'Invalid arguments: %s' % cgi.escape(e.message, quote=True))
387 elif exc_type == exceptions.OverAttachmentQuota:
388 prpc_context.set_code(codes.StatusCode.RESOURCE_EXHAUSTED)
389 prpc_context.set_details(
390 'The request would exceed the attachment quota limit.')
391 elif exc_type == ratelimiter.ApiRateLimitExceeded:
392 prpc_context.set_code(codes.StatusCode.PERMISSION_DENIED)
393 prpc_context.set_details('The requester has exceeded API quotas limit.')
394 elif exc_type == oauth.InvalidOAuthTokenError:
395 prpc_context.set_code(codes.StatusCode.UNAUTHENTICATED)
396 prpc_context.set_details(
397 'The oauth token was not valid or must be refreshed.')
398 elif exc_type == xsrf.TokenIncorrect:
399 logging.info('Bad XSRF token: %r', e.message)
400 prpc_context.set_code(codes.StatusCode.INVALID_ARGUMENT)
401 prpc_context.set_details('Bad XSRF token.')
402 elif exc_type == exceptions.PageTokenException:
403 prpc_context.set_code(codes.StatusCode.INVALID_ARGUMENT)
404 prpc_context.set_details(
405 'Page token invalid or incorrect for the accompanying request')
406 else:
407 prpc_context.set_code(codes.StatusCode.INTERNAL)
408 prpc_context.set_details('Potential programming error.')
409 return False # Re-raise any exception from programming errors.
410 return True # It if was one of the cases above, don't reraise.
411
412 def RecordMonitoringStats(
413 self, start_time, request, response, prpc_context, now=None):
414 """Record monitoring info about this request."""
415 now = now or time.time()
416 elapsed_ms = int((now - start_time) * 1000)
417 method_name = request.__class__.__name__
418 if method_name.endswith('Request'):
419 method_name = method_name[:-len('Request')]
420
421 fields = monitoring.GetCommonFields(
422 # pRPC uses its own statuses, but we report HTTP status codes.
423 ConvertPRPCStatusToHTTPStatus(prpc_context),
424 # Use the API name, not the request path, to prevent an explosion in
425 # possible field values.
426 'monorail.v3.' + method_name)
427 monitoring.AddServerDurations(elapsed_ms, fields)
428 monitoring.IncrementServerResponseStatusCount(fields)
429 monitoring.AddServerRequesteBytes(
430 len(json_format.MessageToJson(request)), fields)
431 response_length = 0
432 if response:
433 response_length = len(json_format.MessageToJson(response))
434 monitoring.AddServerResponseBytes(response_length, fields)