blob: 7dfdf0c4a5ad4eab48361c4a73dae10715836d44 [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 sys
15import time
16from google.appengine.api import oauth
17
18from google.appengine.api import users
19from google.protobuf import json_format
20from components.prpc import codes
Copybara854996b2021-09-07 19:36:02 +000021
22import settings
23from framework import authdata
24from framework import exceptions
Copybara854996b2021-09-07 19:36:02 +000025from framework import framework_constants
26from framework import monitoring
27from framework import monorailcontext
28from framework import ratelimiter
29from framework import permissions
30from framework import sql
31from framework import xsrf
32from services import client_config_svc
33from services import features_svc
34
35
36# Header for XSRF token to protect cookie-based auth users.
37XSRF_TOKEN_HEADER = 'x-xsrf-token'
38# Header for test account email. Only accepted for local dev server.
39TEST_ACCOUNT_HEADER = 'x-test-account'
40# Optional header to help us understand why certain calls were made.
41REASON_HEADER = 'x-reason'
42# Optional header to help prevent double updates.
43REQUEST_ID_HEADER = 'x-request-id'
44
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +020045# TODO(https://crbug.com/1346473)
46_PRPC_TO_HTTP_STATUS = {
47 codes.StatusCode.OK: httplib.OK,
48 codes.StatusCode.CANCELLED: httplib.NO_CONTENT,
49 codes.StatusCode.INVALID_ARGUMENT: httplib.BAD_REQUEST,
50 codes.StatusCode.DEADLINE_EXCEEDED: httplib.SERVICE_UNAVAILABLE,
51 codes.StatusCode.NOT_FOUND: httplib.NOT_FOUND,
52 codes.StatusCode.ALREADY_EXISTS: httplib.CONFLICT,
53 codes.StatusCode.PERMISSION_DENIED: httplib.FORBIDDEN,
54 codes.StatusCode.RESOURCE_EXHAUSTED: httplib.SERVICE_UNAVAILABLE,
55 codes.StatusCode.FAILED_PRECONDITION: httplib.PRECONDITION_FAILED,
56 codes.StatusCode.OUT_OF_RANGE: httplib.BAD_REQUEST,
57 codes.StatusCode.UNIMPLEMENTED: httplib.NOT_IMPLEMENTED,
58 codes.StatusCode.INTERNAL: httplib.INTERNAL_SERVER_ERROR,
59 codes.StatusCode.UNAVAILABLE: httplib.SERVICE_UNAVAILABLE,
60 codes.StatusCode.UNAUTHENTICATED: httplib.UNAUTHORIZED,
61}
Copybara854996b2021-09-07 19:36:02 +000062
63def ConvertPRPCStatusToHTTPStatus(context):
64 """pRPC uses internal codes 0..16, but we want to report HTTP codes."""
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +020065 return _PRPC_TO_HTTP_STATUS.get(context._code, 500)
Copybara854996b2021-09-07 19:36:02 +000066
67
68def PRPCMethod(func):
69 @functools.wraps(func)
70 def wrapper(self, request, prpc_context, cnxn=None):
71 return self.Run(
72 func, request, prpc_context, cnxn=cnxn)
73
74 wrapper.wrapped = func
75 return wrapper
76
77
78class MonorailServicer(object):
79 """Abstract base class for API servicers.
80 """
81
82 def __init__(self, services, make_rate_limiter=True, xsrf_timeout=None):
83 self.services = services
84 if make_rate_limiter:
85 self.rate_limiter = ratelimiter.ApiRateLimiter()
86 else:
87 self.rate_limiter = None
88 # We allow subclasses to specify a different timeout. This allows the
89 # RefreshToken method to check the token with a longer expiration and
90 # generate a new one.
91 self.xsrf_timeout = xsrf_timeout or xsrf.TOKEN_TIMEOUT_SEC
92
93 def Run(
94 self, handler, request, prpc_context,
95 cnxn=None, perms=None, start_time=None, end_time=None):
96 """Run a Do* method in an API context.
97
98 Args:
99 handler: API handler method to call with MonorailContext and request.
100 request: API Request proto object.
101 prpc_context: pRPC context object with status code.
102 cnxn: Optional connection to SQL database.
103 perms: PermissionSet passed in during testing.
104 start_time: Int timestamp passed in during testing.
105 end_time: Int timestamp passed in during testing.
106
107 Returns:
108 The response proto returned from the handler or None if that
109 method raised an exception that we handle.
110
111 Raises:
112 Only programming errors should be raised as exceptions. All
113 execptions for permission checks and input validation that are
114 raised in the Do* method are converted into pRPC status codes.
115 """
116 start_time = start_time or time.time()
117 cnxn = cnxn or sql.MonorailConnection()
118 if self.services.cache_manager:
119 self.services.cache_manager.DoDistributedInvalidation(cnxn)
120
121 response = None
122 client_id = None # TODO(jrobbins): consider using client ID.
123 requester_auth = None
124 metadata = dict(prpc_context.invocation_metadata())
125 mc = monorailcontext.MonorailContext(self.services, cnxn=cnxn, perms=perms)
126 try:
127 self.AssertBaseChecks(request, metadata)
128 requester_auth = self.GetAndAssertRequesterAuth(
129 cnxn, metadata, self.services)
130 logging.info('request proto is:\n%r\n', request)
131 logging.info('requester is %r', requester_auth.email)
132
133 if self.rate_limiter:
134 self.rate_limiter.CheckStart(
135 client_id, requester_auth.email, start_time)
136 mc.auth = requester_auth
137 if not perms:
138 mc.LookupLoggedInUserPerms(self.GetRequestProject(mc.cnxn, request))
139 response = handler(self, mc, request)
140
141 except Exception as e:
142 if not self.ProcessException(e, prpc_context, mc):
143 raise e.__class__, e, sys.exc_info()[2]
144 finally:
145 if mc:
146 mc.CleanUp()
147 if self.rate_limiter and requester_auth and requester_auth.email:
148 end_time = end_time or time.time()
149 self.rate_limiter.CheckEnd(
150 client_id, requester_auth.email, end_time, start_time)
151 self.RecordMonitoringStats(start_time, request, response, prpc_context)
152
153 return response
154
155 def _GetAllowedEmailDomainAuth(self, cnxn, services):
156 """Checks if the requester's email is found in api_allowed_email_domains
157 and is authorized by the custom monorail scope.
158
159 Args:
160 cnxn: connection to the SQL database.
161 services: connections to backend services.
162
163 Returns:
164 A new AuthData object if the method determines the requester is allowed
165 to access the API, otherwise, None.
166 """
167 try:
168 # Note: get_current_user(scopes) returns the User with the User's email.
169 # So, in addition to requesting any scope listed in 'scopes', it also
170 # always requests the email scope.
171 monorail_scope_user = oauth.get_current_user(
172 framework_constants.MONORAIL_SCOPE)
173 logging.info('monorail scope user %r', monorail_scope_user)
174 # TODO(b/144508063): remove this workaround.
175 authorized_scopes = oauth.get_authorized_scopes(
176 framework_constants.MONORAIL_SCOPE)
177 if framework_constants.MONORAIL_SCOPE not in authorized_scopes:
178 raise oauth.Error('Work around for b/144508063')
179 logging.info(authorized_scopes)
180 if (monorail_scope_user and monorail_scope_user.email().endswith(
181 settings.api_allowed_email_domains)):
182 logging.info('User %r authenticated with Oauth and monorail',
183 monorail_scope_user.email())
184 return authdata.AuthData.FromEmail(
185 cnxn, monorail_scope_user.email(), services)
186 except oauth.Error as ex:
187 logging.info('oauth.Error for monorail scope: %s' % ex)
188 return None
189
190 def GetAndAssertRequesterAuth(self, cnxn, metadata, services):
191 """Gets the requester identity and checks if the user has permission
192 to make the request.
193 Any users successfully authenticated with oauth must be allowlisted or
194 have accounts with the domains in api_allowed_email_domains.
195 Users identified using cookie-based auth must have valid XSRF tokens.
196 Test accounts ending with @example.com are only allowed in the
197 local_mode.
198
199 Args:
200 cnxn: connection to the SQL database.
201 metadata: metadata sent by the client.
202 services: connections to backend services.
203
204 Returns:
205 A new AuthData object representing a signed in or anonymous user.
206
207 Raises:
208 exceptions.NoSuchUserException: If the requester does not exist
209 permissions.BannedUserException: If the user has been banned from the site
210 permissions.PermissionException: If the user is not authorized with the
211 Monorail scope, is not allowlisted, and has an invalid token.
212 """
213 # TODO(monorail:6538): Move different authentication methods into separate
214 # functions.
215 requester_auth = None
216 # When running on localhost, allow request to specify test account.
217 if TEST_ACCOUNT_HEADER in metadata:
218 if not settings.local_mode:
219 raise exceptions.InputException(
220 'x-test-account only accepted in local_mode')
221 # For local development, we accept any request.
222 # TODO(jrobbins): make this more realistic by requiring a fake XSRF token.
223 test_account = metadata[TEST_ACCOUNT_HEADER]
224 if not test_account.endswith('@example.com'):
225 raise exceptions.InputException(
226 'test_account must end with @example.com')
227 logging.info('Using test_account: %r' % test_account)
228 requester_auth = authdata.AuthData.FromEmail(cnxn, test_account, services)
229
230 # Oauth for users with email domains in api_allowed_email_domains.
231 if not requester_auth:
232 requester_auth = self._GetAllowedEmailDomainAuth(cnxn, services)
233
234 # Oauth for allowlisted users
235 if not requester_auth:
236 try:
237 client_id = oauth.get_client_id(framework_constants.OAUTH_SCOPE)
238 user = oauth.get_current_user(framework_constants.OAUTH_SCOPE)
239 if user:
240 auth_client_ids, auth_emails = (
241 client_config_svc.GetClientConfigSvc().GetClientIDEmails())
242 logging.info('Oauth requester %s', user.email())
243 # Check if email or client_id is allowlisted
244 if (user.email() in auth_emails) or (client_id in auth_client_ids):
245 logging.info('Client %r is allowlisted', user.email())
246 requester_auth = authdata.AuthData.FromEmail(
247 cnxn, user.email(), services)
248 except oauth.Error as ex:
249 logging.info('Got oauth error: %r', ex)
250
251 # Cookie-based auth for signed in and anonymous users.
252 if not requester_auth:
253 # Check for signed in user
254 user = users.get_current_user()
255 if user:
256 logging.info('Using cookie user: %r', user.email())
257 requester_auth = authdata.AuthData.FromEmail(
258 cnxn, user.email(), services)
259 else:
260 # Create AuthData for anonymous user.
261 requester_auth = authdata.AuthData.FromEmail(cnxn, None, services)
262
263 # Cookie-based auth signed-in and anon users need to have the XSRF
264 # token validate.
265 try:
266 token = metadata.get(XSRF_TOKEN_HEADER)
267 xsrf.ValidateToken(
268 token, requester_auth.user_id, xsrf.XHR_SERVLET_PATH,
269 timeout=self.xsrf_timeout)
270 except xsrf.TokenIncorrect:
271 raise permissions.PermissionException(
272 'Requester %s does not have permission to make this request.'
273 % requester_auth.email)
274
275 if permissions.IsBanned(requester_auth.user_pb, requester_auth.user_view):
276 raise permissions.BannedUserException(
277 'The user %s has been banned from using this site' %
278 requester_auth.email)
279
280 return requester_auth
281
282 def AssertBaseChecks(self, request, metadata):
283 """Reject requests that we refuse to serve."""
284 # TODO(jrobbins): Add read_only check as an exception raised in sql.py.
285 if (settings.read_only and
286 not request.__class__.__name__.startswith(('Get', 'List'))):
287 raise permissions.PermissionException(
288 'This request is not allowed in read-only mode')
289
290 if REASON_HEADER in metadata:
291 logging.info('Request reason: %r', metadata[REASON_HEADER])
292 if REQUEST_ID_HEADER in metadata:
293 # TODO(jrobbins): Ignore requests with duplicate request_ids.
294 logging.info('request_id: %r', metadata[REQUEST_ID_HEADER])
295
296 def GetRequestProject(self, cnxn, request):
297 """Return the Project business object that the user is viewing or None."""
298 if hasattr(request, 'project_name'):
299 project = self.services.project.GetProjectByName(
300 cnxn, request.project_name)
301 if not project:
302 logging.info(
303 'Request has project_name: %r but it does not exist.',
304 request.project_name)
305 return None
306 return project
307 else:
308 return None
309
310 def ProcessException(self, e, prpc_context, mc):
311 """Return True if we convert an exception to a pRPC status code."""
312 logging.exception(e)
313 logging.info(e.message)
314 exc_type = type(e)
315 if exc_type == exceptions.NoSuchUserException:
316 prpc_context.set_code(codes.StatusCode.NOT_FOUND)
317 prpc_context.set_details('The user does not exist.')
318 elif exc_type == exceptions.NoSuchProjectException:
319 prpc_context.set_code(codes.StatusCode.NOT_FOUND)
320 prpc_context.set_details('The project does not exist.')
321 elif exc_type == exceptions.NoSuchTemplateException:
322 prpc_context.set_code(codes.StatusCode.NOT_FOUND)
323 prpc_context.set_details('The template does not exist.')
324 elif exc_type == exceptions.NoSuchIssueException:
325 prpc_context.set_code(codes.StatusCode.NOT_FOUND)
326 prpc_context.set_details('The issue does not exist.')
327 elif exc_type == exceptions.NoSuchCommentException:
328 prpc_context.set_code(codes.StatusCode.INVALID_ARGUMENT)
329 prpc_context.set_details('No such comment')
330 elif exc_type == exceptions.NoSuchComponentException:
331 prpc_context.set_code(codes.StatusCode.NOT_FOUND)
332 prpc_context.set_details('The component does not exist.')
333 elif exc_type == permissions.BannedUserException:
334 prpc_context.set_code(codes.StatusCode.PERMISSION_DENIED)
335 prpc_context.set_details('The requesting user has been banned.')
336 elif exc_type == permissions.PermissionException:
337 logging.info('perms is %r', mc.perms)
338 prpc_context.set_code(codes.StatusCode.PERMISSION_DENIED)
339 prpc_context.set_details('Permission denied.')
340 elif exc_type == exceptions.GroupExistsException:
341 prpc_context.set_code(codes.StatusCode.ALREADY_EXISTS)
342 prpc_context.set_details('The user group already exists.')
343 elif exc_type == features_svc.HotlistAlreadyExists:
344 prpc_context.set_code(codes.StatusCode.ALREADY_EXISTS)
345 prpc_context.set_details('A hotlist with that name already exists.')
346 elif exc_type == exceptions.FieldDefAlreadyExists:
347 prpc_context.set_code(codes.StatusCode.ALREADY_EXISTS)
348 prpc_context.set_details('A field def with that name already exists.')
349 elif exc_type == exceptions.InvalidComponentNameException:
350 prpc_context.set_code(codes.StatusCode.INVALID_ARGUMENT)
351 prpc_context.set_details('That component name is invalid.')
352 elif exc_type == exceptions.FilterRuleException:
353 prpc_context.set_code(codes.StatusCode.INVALID_ARGUMENT)
354 prpc_context.set_details('Violates filter rule that should error.')
355 elif exc_type == exceptions.InputException:
356 prpc_context.set_code(codes.StatusCode.INVALID_ARGUMENT)
357 prpc_context.set_details(
358 'Invalid arguments: %s' % cgi.escape(e.message, quote=True))
359 elif exc_type == ratelimiter.ApiRateLimitExceeded:
360 prpc_context.set_code(codes.StatusCode.PERMISSION_DENIED)
361 prpc_context.set_details('The requester has exceeded API quotas limit.')
362 elif exc_type == oauth.InvalidOAuthTokenError:
363 prpc_context.set_code(codes.StatusCode.UNAUTHENTICATED)
364 prpc_context.set_details(
365 'The oauth token was not valid or must be refreshed.')
366 elif exc_type == xsrf.TokenIncorrect:
367 logging.info('Bad XSRF token: %r', e.message)
368 prpc_context.set_code(codes.StatusCode.INVALID_ARGUMENT)
369 prpc_context.set_details('Bad XSRF token.')
370 else:
371 prpc_context.set_code(codes.StatusCode.INTERNAL)
372 prpc_context.set_details('Potential programming error.')
373 return False # Re-raise any exception from programming errors.
374 return True # It if was one of the cases above, don't reraise.
375
376 def RecordMonitoringStats(
377 self, start_time, request, response, prpc_context, now=None):
378 """Record monitoring info about this request."""
379 now = now or time.time()
380 elapsed_ms = int((now - start_time) * 1000)
381 method_name = request.__class__.__name__
382 if method_name.endswith('Request'):
383 method_name = method_name[:-len('Request')]
384
385 fields = monitoring.GetCommonFields(
386 # pRPC uses its own statuses, but we report HTTP status codes.
387 ConvertPRPCStatusToHTTPStatus(prpc_context),
388 # Use the API name, not the request path, to prevent an explosion in
389 # possible field values.
390 'monorail.v0.' + method_name)
391
392 monitoring.AddServerDurations(elapsed_ms, fields)
393 monitoring.IncrementServerResponseStatusCount(fields)
394 monitoring.AddServerRequesteBytes(
395 len(json_format.MessageToJson(request)), fields)
396 response_length = 0
397 if response:
398 response_length = len(json_format.MessageToJson(response))
399 monitoring.AddServerResponseBytes(response_length, fields)