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