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