blob: d54f425391b0d8a0cb2799d4b836c20c0204af1f [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(
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200125 'v3',
126 client_id,
127 client_email=requester_auth.email,
128 handler=handler.func_name)
Copybara854996b2021-09-07 19:36:02 +0000129
130 # TODO(crbug.com/monorail/8161)We pass in a None client_id for rate
131 # limiting because CheckStart and CheckEnd will track and limit requests
132 # per email and client_id separately.
133 # So if there are many site users one day, we may end up rate limiting our
134 # own site. With a None client_id we are only rate limiting by emails.
135 if self.rate_limiter:
136 self.rate_limiter.CheckStart(None, requester_auth.email, start_time)
137 mc.auth = requester_auth
138 if not perms:
139 # NOTE(crbug/monorail/7614): We rely on servicer methods to call
140 # to call LookupLoggedInUserPerms() with a project when they need to.
141 mc.LookupLoggedInUserPerms(None)
142
143 self.converter = converters.Converter(mc, self.services)
144 response = handler(self, mc, request)
145
146 except Exception as e:
147 if not self.ProcessException(e, prpc_context, mc):
148 raise e.__class__, e, sys.exc_info()[2]
149 finally:
150 if mc:
151 mc.CleanUp()
152 if self.rate_limiter and requester_auth and requester_auth.email:
153 end_time = end_time or time.time()
154 self.rate_limiter.CheckEnd(
155 None, requester_auth.email, end_time, start_time)
156 self.RecordMonitoringStats(start_time, request, response, prpc_context)
157
158 return response
159
160 def CheckIDToken(self, cnxn, metadata):
161 # type: (MonorailConnection, Mapping[str, str])
162 # -> Tuple[Optional[str], Optional[authdata.AuthData]]
163 """Authenticate user from an ID token.
164
165 Args:
166 cnxn: connection to the SQL database.
167 metadata: metadata sent by the client.
168
169 Returns:
170 The audience (AKA client_id) and a new AuthData object representing
171 the user making the request or (None, None) if no ID token was found.
172
173 Raises:
174 permissions.PermissionException: If the token is invalid, the client ID
175 is not allowlisted, or no user email was found in the ID token.
176 """
177 bearer = metadata.get('authorization')
178 if not bearer:
179 return None, None
180 if bearer.lower().startswith('bearer '):
181 token = bearer[7:]
182 else:
183 raise permissions.PermissionException('Invalid authorization token.')
184 # TODO(crbug.com/monorail/7724): Use cachecontrol module to cache
185 # certification used for verification.
186 request = google_requests.Request()
187
188 try:
189 id_info = id_token.verify_oauth2_token(token, request)
190 logging.info('ID token info: %r' % id_info)
191 except ValueError:
192 raise permissions.PermissionException(
193 'Invalid bearer token.')
194
195 audience = id_info['aud']
196 email = id_info.get('email')
197 if not email:
198 raise permissions.PermissionException(
199 'No email found in token info. '
200 'Make sure requests are made with scopes `openid` and `email`')
201
202 auth_client_ids, service_account_emails = (
203 client_config_svc.GetClientConfigSvc().GetClientIDEmails())
204
205 if email.endswith(SERVICE_ACCOUNT_DOMAIN):
206 # For service accounts, the email must be allowlisted to call the
207 # API and we must confirm that the ID token was meant for
208 # Monorail by checking the audience.
209
210 # An API call to any <version>-dot-<service>-dot-<app_id>.appspot.com
211 # must have token audience of `https://<app_id>.appspot.com`
212 app_id = app_identity.get_application_id() # e.g. 'monorail-prod'
213 host = 'https://%s.appspot.com' % app_id
214 if audience != host:
215 raise permissions.PermissionException(
216 'Invalid token audience: %s.' % audience)
217 if email not in service_account_emails:
218 raise permissions.PermissionException(
219 'Account %s is not allowlisted' % email)
220 else:
221 # For users, the audience is the client_id of the site used to make
222 # the call to Monorail's API. The client_id must be allow-listed.
223 if audience not in auth_client_ids:
224 raise permissions.PermissionException(
225 'Client %s is not allowlisted' % audience)
226
227 # We must confirm the client/email is allowlisted before we
228 # potentially auto-create the user account in Monorail.
229 return audience, authdata.AuthData.FromEmail(
230 cnxn, email, self.services, autocreate=True)
231
232 def GetAndAssertRequesterAuth(self, cnxn, metadata, services):
233 # type: (MonorailConnection, Mapping[str, str], Services ->
234 # Tuple[str, authdata.AuthData]
235 """Gets the requester identity and checks if the user has permission
236 to make the request.
237 Any users successfully authenticated with oauth must be allowlisted or
238 have accounts with the domains in api_allowed_email_domains.
239 Users identified using cookie-based auth must have valid XSRF tokens.
240 Test accounts ending with @example.com are only allowed in the
241 local_mode.
242
243 Args:
244 cnxn: connection to the SQL database.
245 metadata: metadata sent by the client.
246 services: connections to backend services.
247
248 Returns:
249 The client ID and a new AuthData object representing a signed in or
250 anonymous user.
251
252 Raises:
253 exceptions.NoSuchUserException: If the requester does not exist
254 permissions.BannedUserException: If the user has been banned from the site
255 permissions.PermissionException: If the user is not authorized with the
256 Monorail scope, is not allowlisted, and has an invalid token.
257 """
258 # TODO(monorail:6538): Move different authentication methods into separate
259 # functions.
260 requester_auth = None
261 client_id = None
262 # When running on localhost, allow request to specify test account.
263 if TEST_ACCOUNT_HEADER in metadata:
264 if not settings.local_mode:
265 raise exceptions.InputException(
266 'x-test-account only accepted in local_mode')
267 # For local development, we accept any request.
268 # TODO(jrobbins): make this more realistic by requiring a fake XSRF token.
269 test_account = metadata[TEST_ACCOUNT_HEADER]
270 if not test_account.endswith('@example.com'):
271 raise exceptions.InputException(
272 'test_account must end with @example.com')
273 logging.info('Using test_account: %r' % test_account)
274 requester_auth = authdata.AuthData.FromEmail(cnxn, test_account, services)
275
276 # Oauth2 ID token auth.
277 if not requester_auth:
278 client_id, requester_auth = self.CheckIDToken(cnxn, metadata)
279
280 if client_id is None:
281 # TODO(crbug.com/monorail/8160): For site users, we temporarily use
282 # the host as the client_id, until we implement auth in the frontend
283 # to make API requests with ID tokens that include client_ids.
284 client_id = 'https://%s.appspot.com' % app_identity.get_application_id()
285
286
287 # Cookie-based auth for signed in and anonymous users.
288 if not requester_auth:
289 # Check for signed in user
290 user = users.get_current_user()
291 if user:
292 logging.info('Using cookie user: %r', user.email())
293 requester_auth = authdata.AuthData.FromEmail(
294 cnxn, user.email(), services)
295 else:
296 # Create AuthData for anonymous user.
297 requester_auth = authdata.AuthData.FromEmail(cnxn, None, services)
298
299 # Cookie-based auth signed-in and anon users need to have the XSRF
300 # token validate.
301 try:
302 token = metadata.get(XSRF_TOKEN_HEADER)
303 xsrf.ValidateToken(
304 token, requester_auth.user_id, xsrf.XHR_SERVLET_PATH,
305 timeout=self.xsrf_timeout)
306 except xsrf.TokenIncorrect:
307 raise permissions.PermissionException(
308 'Requester %s does not have permission to make this request.'
309 % requester_auth.email)
310
311 if permissions.IsBanned(requester_auth.user_pb, requester_auth.user_view):
312 raise permissions.BannedUserException(
313 'The user %s has been banned from using this site' %
314 requester_auth.email)
315
316 return (client_id, requester_auth)
317
318 def AssertBaseChecks(self, request, metadata):
319 """Reject requests that we refuse to serve."""
320 # TODO(jrobbins): Add read_only check as an exception raised in sql.py.
321 if (settings.read_only and
322 not request.__class__.__name__.startswith(('Get', 'List'))):
323 raise permissions.PermissionException(
324 'This request is not allowed in read-only mode')
325
326 if REASON_HEADER in metadata:
327 logging.info('Request reason: %r', metadata[REASON_HEADER])
328 if REQUEST_ID_HEADER in metadata:
329 # TODO(jrobbins): Ignore requests with duplicate request_ids.
330 logging.info('request_id: %r', metadata[REQUEST_ID_HEADER])
331
332 def ProcessException(self, e, prpc_context, mc):
333 """Return True if we convert an exception to a pRPC status code."""
334 logging.exception(e)
335 logging.info(e.message)
336 exc_type = type(e)
337 if exc_type == exceptions.NoSuchUserException:
338 prpc_context.set_code(codes.StatusCode.NOT_FOUND)
339 prpc_context.set_details('The user does not exist.')
340 elif exc_type == exceptions.NoSuchProjectException:
341 prpc_context.set_code(codes.StatusCode.NOT_FOUND)
342 prpc_context.set_details('The project does not exist.')
343 elif exc_type == exceptions.NoSuchTemplateException:
344 prpc_context.set_code(codes.StatusCode.NOT_FOUND)
345 prpc_context.set_details('The template does not exist.')
346 elif exc_type == exceptions.NoSuchIssueException:
347 prpc_context.set_code(codes.StatusCode.NOT_FOUND)
348 details = 'The issue does not exist.'
349 if e.message:
350 details = cgi.escape(e.message, quote=True)
351 prpc_context.set_details(details)
352 elif exc_type == exceptions.NoSuchIssueApprovalException:
353 prpc_context.set_code(codes.StatusCode.NOT_FOUND)
354 prpc_context.set_details('The issue approval does not exist.')
355 elif exc_type == exceptions.NoSuchCommentException:
356 prpc_context.set_code(codes.StatusCode.INVALID_ARGUMENT)
357 prpc_context.set_details('No such comment')
358 elif exc_type == exceptions.NoSuchComponentException:
359 prpc_context.set_code(codes.StatusCode.NOT_FOUND)
360 prpc_context.set_details('The component does not exist.')
361 elif exc_type == permissions.BannedUserException:
362 prpc_context.set_code(codes.StatusCode.PERMISSION_DENIED)
363 prpc_context.set_details('The requesting user has been banned.')
364 elif exc_type == permissions.PermissionException:
365 logging.info('perms is %r', mc.perms)
366 prpc_context.set_code(codes.StatusCode.PERMISSION_DENIED)
367 prpc_context.set_details('Permission denied.')
368 elif exc_type == exceptions.GroupExistsException:
369 prpc_context.set_code(codes.StatusCode.ALREADY_EXISTS)
370 prpc_context.set_details('The user group already exists.')
371 elif exc_type == features_svc.HotlistAlreadyExists:
372 prpc_context.set_code(codes.StatusCode.ALREADY_EXISTS)
373 prpc_context.set_details('A hotlist with that name already exists.')
374 elif exc_type == exceptions.ComponentDefAlreadyExists:
375 prpc_context.set_code(codes.StatusCode.ALREADY_EXISTS)
376 prpc_context.set_details('A component with that path already exists.')
377 elif exc_type == exceptions.ActionNotSupported:
378 prpc_context.set_code(codes.StatusCode.INVALID_ARGUMENT)
379 prpc_context.set_details('Requested action not supported.')
380 elif exc_type == exceptions.InvalidComponentNameException:
381 prpc_context.set_code(codes.StatusCode.INVALID_ARGUMENT)
382 prpc_context.set_details('That component name is invalid.')
383 elif exc_type == exceptions.FilterRuleException:
384 prpc_context.set_code(codes.StatusCode.INVALID_ARGUMENT)
385 prpc_context.set_details('Violates filter rule that should error.')
386 elif exc_type == exceptions.InputException:
387 prpc_context.set_code(codes.StatusCode.INVALID_ARGUMENT)
388 prpc_context.set_details(
389 'Invalid arguments: %s' % cgi.escape(e.message, quote=True))
390 elif exc_type == exceptions.OverAttachmentQuota:
391 prpc_context.set_code(codes.StatusCode.RESOURCE_EXHAUSTED)
392 prpc_context.set_details(
393 'The request would exceed the attachment quota limit.')
394 elif exc_type == ratelimiter.ApiRateLimitExceeded:
395 prpc_context.set_code(codes.StatusCode.PERMISSION_DENIED)
396 prpc_context.set_details('The requester has exceeded API quotas limit.')
397 elif exc_type == oauth.InvalidOAuthTokenError:
398 prpc_context.set_code(codes.StatusCode.UNAUTHENTICATED)
399 prpc_context.set_details(
400 'The oauth token was not valid or must be refreshed.')
401 elif exc_type == xsrf.TokenIncorrect:
402 logging.info('Bad XSRF token: %r', e.message)
403 prpc_context.set_code(codes.StatusCode.INVALID_ARGUMENT)
404 prpc_context.set_details('Bad XSRF token.')
405 elif exc_type == exceptions.PageTokenException:
406 prpc_context.set_code(codes.StatusCode.INVALID_ARGUMENT)
407 prpc_context.set_details(
408 'Page token invalid or incorrect for the accompanying request')
409 else:
410 prpc_context.set_code(codes.StatusCode.INTERNAL)
411 prpc_context.set_details('Potential programming error.')
412 return False # Re-raise any exception from programming errors.
413 return True # It if was one of the cases above, don't reraise.
414
415 def RecordMonitoringStats(
416 self, start_time, request, response, prpc_context, now=None):
417 """Record monitoring info about this request."""
418 now = now or time.time()
419 elapsed_ms = int((now - start_time) * 1000)
420 method_name = request.__class__.__name__
421 if method_name.endswith('Request'):
422 method_name = method_name[:-len('Request')]
423
424 fields = monitoring.GetCommonFields(
425 # pRPC uses its own statuses, but we report HTTP status codes.
426 ConvertPRPCStatusToHTTPStatus(prpc_context),
427 # Use the API name, not the request path, to prevent an explosion in
428 # possible field values.
429 'monorail.v3.' + method_name)
430 monitoring.AddServerDurations(elapsed_ms, fields)
431 monitoring.IncrementServerResponseStatusCount(fields)
432 monitoring.AddServerRequesteBytes(
433 len(json_format.MessageToJson(request)), fields)
434 response_length = 0
435 if response:
436 response_length = len(json_format.MessageToJson(response))
437 monitoring.AddServerResponseBytes(response_length, fields)