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