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