##// END OF EJS Templates
auth, performance: use cache_ttl for anonymous access taken from the rhodecode plugin....
marcink -
r2425:46757b44 default
parent child Browse files
Show More
@@ -1,629 +1,641 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2014-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 SimpleVCS middleware for handling protocol request (push/clone etc.)
23 23 It's implemented with basic auth function
24 24 """
25 25
26 26 import os
27 27 import re
28 28 import logging
29 29 import importlib
30 30 from functools import wraps
31 31
32 32 import time
33 33 from paste.httpheaders import REMOTE_USER, AUTH_TYPE
34 34 # TODO(marcink): check if we should use webob.exc here ?
35 35 from pyramid.httpexceptions import (
36 36 HTTPNotFound, HTTPForbidden, HTTPNotAcceptable, HTTPInternalServerError)
37 37 from zope.cachedescriptors.property import Lazy as LazyProperty
38 38
39 39 import rhodecode
40 40 from rhodecode.authentication.base import (
41 authenticate, get_perms_cache_manager, VCS_TYPE)
41 authenticate, get_perms_cache_manager, VCS_TYPE, loadplugin)
42 42 from rhodecode.lib import caches
43 43 from rhodecode.lib.auth import AuthUser, HasPermissionAnyMiddleware
44 44 from rhodecode.lib.base import (
45 45 BasicAuth, get_ip_addr, get_user_agent, vcs_operation_context)
46 46 from rhodecode.lib.exceptions import (
47 47 HTTPLockedRC, HTTPRequirementError, UserCreationError,
48 48 NotAllowedToCreateUserError)
49 49 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
50 50 from rhodecode.lib.middleware import appenlight
51 51 from rhodecode.lib.middleware.utils import scm_app_http
52 52 from rhodecode.lib.utils import is_valid_repo, SLUG_RE
53 53 from rhodecode.lib.utils2 import safe_str, fix_PATH, str2bool, safe_unicode
54 54 from rhodecode.lib.vcs.conf import settings as vcs_settings
55 55 from rhodecode.lib.vcs.backends import base
56 56 from rhodecode.model import meta
57 57 from rhodecode.model.db import User, Repository, PullRequest
58 58 from rhodecode.model.scm import ScmModel
59 59 from rhodecode.model.pull_request import PullRequestModel
60 60 from rhodecode.model.settings import SettingsModel, VcsSettingsModel
61 61
62 62 log = logging.getLogger(__name__)
63 63
64 64
65 65 def initialize_generator(factory):
66 66 """
67 67 Initializes the returned generator by draining its first element.
68 68
69 69 This can be used to give a generator an initializer, which is the code
70 70 up to the first yield statement. This decorator enforces that the first
71 71 produced element has the value ``"__init__"`` to make its special
72 72 purpose very explicit in the using code.
73 73 """
74 74
75 75 @wraps(factory)
76 76 def wrapper(*args, **kwargs):
77 77 gen = factory(*args, **kwargs)
78 78 try:
79 79 init = gen.next()
80 80 except StopIteration:
81 81 raise ValueError('Generator must yield at least one element.')
82 82 if init != "__init__":
83 83 raise ValueError('First yielded element must be "__init__".')
84 84 return gen
85 85 return wrapper
86 86
87 87
88 88 class SimpleVCS(object):
89 89 """Common functionality for SCM HTTP handlers."""
90 90
91 91 SCM = 'unknown'
92 92
93 93 acl_repo_name = None
94 94 url_repo_name = None
95 95 vcs_repo_name = None
96 96 rc_extras = {}
97 97
98 98 # We have to handle requests to shadow repositories different than requests
99 99 # to normal repositories. Therefore we have to distinguish them. To do this
100 100 # we use this regex which will match only on URLs pointing to shadow
101 101 # repositories.
102 102 shadow_repo_re = re.compile(
103 103 '(?P<groups>(?:{slug_pat}/)*)' # repo groups
104 104 '(?P<target>{slug_pat})/' # target repo
105 105 'pull-request/(?P<pr_id>\d+)/' # pull request
106 106 'repository$' # shadow repo
107 107 .format(slug_pat=SLUG_RE.pattern))
108 108
109 109 def __init__(self, config, registry):
110 110 self.registry = registry
111 111 self.config = config
112 112 # re-populated by specialized middleware
113 113 self.repo_vcs_config = base.Config()
114 114 self.rhodecode_settings = SettingsModel().get_all_settings(cache=True)
115 115
116 116 registry.rhodecode_settings = self.rhodecode_settings
117 117 # authenticate this VCS request using authfunc
118 118 auth_ret_code_detection = \
119 119 str2bool(self.config.get('auth_ret_code_detection', False))
120 120 self.authenticate = BasicAuth(
121 121 '', authenticate, registry, config.get('auth_ret_code'),
122 122 auth_ret_code_detection)
123 123 self.ip_addr = '0.0.0.0'
124 124
125 125 @LazyProperty
126 126 def global_vcs_config(self):
127 127 try:
128 128 return VcsSettingsModel().get_ui_settings_as_config_obj()
129 129 except Exception:
130 130 return base.Config()
131 131
132 132 @property
133 133 def base_path(self):
134 134 settings_path = self.repo_vcs_config.get(
135 135 *VcsSettingsModel.PATH_SETTING)
136 136
137 137 if not settings_path:
138 138 settings_path = self.global_vcs_config.get(
139 139 *VcsSettingsModel.PATH_SETTING)
140 140
141 141 if not settings_path:
142 142 # try, maybe we passed in explicitly as config option
143 143 settings_path = self.config.get('base_path')
144 144
145 145 if not settings_path:
146 146 raise ValueError('FATAL: base_path is empty')
147 147 return settings_path
148 148
149 149 def set_repo_names(self, environ):
150 150 """
151 151 This will populate the attributes acl_repo_name, url_repo_name,
152 152 vcs_repo_name and is_shadow_repo. In case of requests to normal (non
153 153 shadow) repositories all names are equal. In case of requests to a
154 154 shadow repository the acl-name points to the target repo of the pull
155 155 request and the vcs-name points to the shadow repo file system path.
156 156 The url-name is always the URL used by the vcs client program.
157 157
158 158 Example in case of a shadow repo:
159 159 acl_repo_name = RepoGroup/MyRepo
160 160 url_repo_name = RepoGroup/MyRepo/pull-request/3/repository
161 161 vcs_repo_name = /repo/base/path/RepoGroup/.__shadow_MyRepo_pr-3'
162 162 """
163 163 # First we set the repo name from URL for all attributes. This is the
164 164 # default if handling normal (non shadow) repo requests.
165 165 self.url_repo_name = self._get_repository_name(environ)
166 166 self.acl_repo_name = self.vcs_repo_name = self.url_repo_name
167 167 self.is_shadow_repo = False
168 168
169 169 # Check if this is a request to a shadow repository.
170 170 match = self.shadow_repo_re.match(self.url_repo_name)
171 171 if match:
172 172 match_dict = match.groupdict()
173 173
174 174 # Build acl repo name from regex match.
175 175 acl_repo_name = safe_unicode('{groups}{target}'.format(
176 176 groups=match_dict['groups'] or '',
177 177 target=match_dict['target']))
178 178
179 179 # Retrieve pull request instance by ID from regex match.
180 180 pull_request = PullRequest.get(match_dict['pr_id'])
181 181
182 182 # Only proceed if we got a pull request and if acl repo name from
183 183 # URL equals the target repo name of the pull request.
184 184 if pull_request and (acl_repo_name ==
185 185 pull_request.target_repo.repo_name):
186 186 # Get file system path to shadow repository.
187 187 workspace_id = PullRequestModel()._workspace_id(pull_request)
188 188 target_vcs = pull_request.target_repo.scm_instance()
189 189 vcs_repo_name = target_vcs._get_shadow_repository_path(
190 190 workspace_id)
191 191
192 192 # Store names for later usage.
193 193 self.vcs_repo_name = vcs_repo_name
194 194 self.acl_repo_name = acl_repo_name
195 195 self.is_shadow_repo = True
196 196
197 197 log.debug('Setting all VCS repository names: %s', {
198 198 'acl_repo_name': self.acl_repo_name,
199 199 'url_repo_name': self.url_repo_name,
200 200 'vcs_repo_name': self.vcs_repo_name,
201 201 })
202 202
203 203 @property
204 204 def scm_app(self):
205 205 custom_implementation = self.config['vcs.scm_app_implementation']
206 206 if custom_implementation == 'http':
207 207 log.info('Using HTTP implementation of scm app.')
208 208 scm_app_impl = scm_app_http
209 209 else:
210 210 log.info('Using custom implementation of scm_app: "{}"'.format(
211 211 custom_implementation))
212 212 scm_app_impl = importlib.import_module(custom_implementation)
213 213 return scm_app_impl
214 214
215 215 def _get_by_id(self, repo_name):
216 216 """
217 217 Gets a special pattern _<ID> from clone url and tries to replace it
218 218 with a repository_name for support of _<ID> non changeable urls
219 219 """
220 220
221 221 data = repo_name.split('/')
222 222 if len(data) >= 2:
223 223 from rhodecode.model.repo import RepoModel
224 224 by_id_match = RepoModel().get_repo_by_id(repo_name)
225 225 if by_id_match:
226 226 data[1] = by_id_match.repo_name
227 227
228 228 return safe_str('/'.join(data))
229 229
230 230 def _invalidate_cache(self, repo_name):
231 231 """
232 232 Set's cache for this repository for invalidation on next access
233 233
234 234 :param repo_name: full repo name, also a cache key
235 235 """
236 236 ScmModel().mark_for_invalidation(repo_name)
237 237
238 238 def is_valid_and_existing_repo(self, repo_name, base_path, scm_type):
239 239 db_repo = Repository.get_by_repo_name(repo_name)
240 240 if not db_repo:
241 241 log.debug('Repository `%s` not found inside the database.',
242 242 repo_name)
243 243 return False
244 244
245 245 if db_repo.repo_type != scm_type:
246 246 log.warning(
247 247 'Repository `%s` have incorrect scm_type, expected %s got %s',
248 248 repo_name, db_repo.repo_type, scm_type)
249 249 return False
250 250
251 251 return is_valid_repo(repo_name, base_path,
252 252 explicit_scm=scm_type, expect_scm=scm_type)
253 253
254 254 def valid_and_active_user(self, user):
255 255 """
256 256 Checks if that user is not empty, and if it's actually object it checks
257 257 if he's active.
258 258
259 259 :param user: user object or None
260 260 :return: boolean
261 261 """
262 262 if user is None:
263 263 return False
264 264
265 265 elif user.active:
266 266 return True
267 267
268 268 return False
269 269
270 270 @property
271 271 def is_shadow_repo_dir(self):
272 272 return os.path.isdir(self.vcs_repo_name)
273 273
274 274 def _check_permission(self, action, user, repo_name, ip_addr=None,
275 275 plugin_id='', plugin_cache_active=False, cache_ttl=0):
276 276 """
277 277 Checks permissions using action (push/pull) user and repository
278 278 name. If plugin_cache and ttl is set it will use the plugin which
279 279 authenticated the user to store the cached permissions result for N
280 280 amount of seconds as in cache_ttl
281 281
282 282 :param action: push or pull action
283 283 :param user: user instance
284 284 :param repo_name: repository name
285 285 """
286 286
287 287 # get instance of cache manager configured for a namespace
288 288 cache_manager = get_perms_cache_manager(custom_ttl=cache_ttl)
289 289 log.debug('AUTH_CACHE_TTL for permissions `%s` active: %s (TTL: %s)',
290 290 plugin_id, plugin_cache_active, cache_ttl)
291 291
292 292 # for environ based password can be empty, but then the validation is
293 293 # on the server that fills in the env data needed for authentication
294 294 _perm_calc_hash = caches.compute_key_from_params(
295 295 plugin_id, action, user.user_id, repo_name, ip_addr)
296 296
297 297 # _authenticate is a wrapper for .auth() method of plugin.
298 298 # it checks if .auth() sends proper data.
299 299 # For RhodeCodeExternalAuthPlugin it also maps users to
300 300 # Database and maps the attributes returned from .auth()
301 301 # to RhodeCode database. If this function returns data
302 302 # then auth is correct.
303 303 start = time.time()
304 304 log.debug('Running plugin `%s` permissions check', plugin_id)
305 305
306 306 def perm_func():
307 307 """
308 308 This function is used internally in Cache of Beaker to calculate
309 309 Results
310 310 """
311 311 log.debug('auth: calculating permission access now...')
312 312 # check IP
313 313 inherit = user.inherit_default_permissions
314 314 ip_allowed = AuthUser.check_ip_allowed(
315 315 user.user_id, ip_addr, inherit_from_default=inherit)
316 316 if ip_allowed:
317 317 log.info('Access for IP:%s allowed', ip_addr)
318 318 else:
319 319 return False
320 320
321 321 if action == 'push':
322 322 perms = ('repository.write', 'repository.admin')
323 323 if not HasPermissionAnyMiddleware(*perms)(user, repo_name):
324 324 return False
325 325
326 326 else:
327 327 # any other action need at least read permission
328 328 perms = (
329 329 'repository.read', 'repository.write', 'repository.admin')
330 330 if not HasPermissionAnyMiddleware(*perms)(user, repo_name):
331 331 return False
332 332
333 333 return True
334 334
335 335 if plugin_cache_active:
336 336 log.debug('Trying to fetch cached perms by %s', _perm_calc_hash[:6])
337 337 perm_result = cache_manager.get(
338 338 _perm_calc_hash, createfunc=perm_func)
339 339 else:
340 340 perm_result = perm_func()
341 341
342 342 auth_time = time.time() - start
343 343 log.debug('Permissions for plugin `%s` completed in %.3fs, '
344 344 'expiration time of fetched cache %.1fs.',
345 345 plugin_id, auth_time, cache_ttl)
346 346
347 347 return perm_result
348 348
349 349 def _check_ssl(self, environ, start_response):
350 350 """
351 351 Checks the SSL check flag and returns False if SSL is not present
352 352 and required True otherwise
353 353 """
354 354 org_proto = environ['wsgi._org_proto']
355 355 # check if we have SSL required ! if not it's a bad request !
356 356 require_ssl = str2bool(self.repo_vcs_config.get('web', 'push_ssl'))
357 357 if require_ssl and org_proto == 'http':
358 358 log.debug('proto is %s and SSL is required BAD REQUEST !',
359 359 org_proto)
360 360 return False
361 361 return True
362 362
363 def _get_default_cache_ttl(self):
364 # take AUTH_CACHE_TTL from the `rhodecode` auth plugin
365 plugin = loadplugin('egg:rhodecode-enterprise-ce#rhodecode')
366 plugin_settings = plugin.get_settings()
367 plugin_cache_active, cache_ttl = plugin.get_ttl_cache(
368 plugin_settings) or (False, 0)
369 return plugin_cache_active, cache_ttl
370
363 371 def __call__(self, environ, start_response):
364 372 try:
365 373 return self._handle_request(environ, start_response)
366 374 except Exception:
367 375 log.exception("Exception while handling request")
368 376 appenlight.track_exception(environ)
369 377 return HTTPInternalServerError()(environ, start_response)
370 378 finally:
371 379 meta.Session.remove()
372 380
373 381 def _handle_request(self, environ, start_response):
374 382
375 383 if not self._check_ssl(environ, start_response):
376 384 reason = ('SSL required, while RhodeCode was unable '
377 385 'to detect this as SSL request')
378 386 log.debug('User not allowed to proceed, %s', reason)
379 387 return HTTPNotAcceptable(reason)(environ, start_response)
380 388
381 389 if not self.url_repo_name:
382 390 log.warning('Repository name is empty: %s', self.url_repo_name)
383 391 # failed to get repo name, we fail now
384 392 return HTTPNotFound()(environ, start_response)
385 393 log.debug('Extracted repo name is %s', self.url_repo_name)
386 394
387 395 ip_addr = get_ip_addr(environ)
388 396 user_agent = get_user_agent(environ)
389 397 username = None
390 398
391 399 # skip passing error to error controller
392 400 environ['pylons.status_code_redirect'] = True
393 401
394 402 # ======================================================================
395 403 # GET ACTION PULL or PUSH
396 404 # ======================================================================
397 405 action = self._get_action(environ)
398 406
399 407 # ======================================================================
400 408 # Check if this is a request to a shadow repository of a pull request.
401 409 # In this case only pull action is allowed.
402 410 # ======================================================================
403 411 if self.is_shadow_repo and action != 'pull':
404 412 reason = 'Only pull action is allowed for shadow repositories.'
405 413 log.debug('User not allowed to proceed, %s', reason)
406 414 return HTTPNotAcceptable(reason)(environ, start_response)
407 415
408 416 # Check if the shadow repo actually exists, in case someone refers
409 417 # to it, and it has been deleted because of successful merge.
410 418 if self.is_shadow_repo and not self.is_shadow_repo_dir:
411 419 log.debug('Shadow repo detected, and shadow repo dir `%s` is missing',
412 420 self.is_shadow_repo_dir)
413 421 return HTTPNotFound()(environ, start_response)
414 422
415 423 # ======================================================================
416 424 # CHECK ANONYMOUS PERMISSION
417 425 # ======================================================================
418 426 if action in ['pull', 'push']:
419 427 anonymous_user = User.get_default_user()
420 428 username = anonymous_user.username
421 429 if anonymous_user.active:
430 plugin_cache_active, cache_ttl = self._get_default_cache_ttl()
422 431 # ONLY check permissions if the user is activated
423 432 anonymous_perm = self._check_permission(
424 action, anonymous_user, self.acl_repo_name, ip_addr)
433 action, anonymous_user, self.acl_repo_name, ip_addr,
434 plugin_id='anonymous_access',
435 plugin_cache_active=plugin_cache_active, cache_ttl=cache_ttl,
436 )
425 437 else:
426 438 anonymous_perm = False
427 439
428 440 if not anonymous_user.active or not anonymous_perm:
429 441 if not anonymous_user.active:
430 442 log.debug('Anonymous access is disabled, running '
431 443 'authentication')
432 444
433 445 if not anonymous_perm:
434 446 log.debug('Not enough credentials to access this '
435 447 'repository as anonymous user')
436 448
437 449 username = None
438 450 # ==============================================================
439 451 # DEFAULT PERM FAILED OR ANONYMOUS ACCESS IS DISABLED SO WE
440 452 # NEED TO AUTHENTICATE AND ASK FOR AUTH USER PERMISSIONS
441 453 # ==============================================================
442 454
443 455 # try to auth based on environ, container auth methods
444 456 log.debug('Running PRE-AUTH for container based authentication')
445 457 pre_auth = authenticate(
446 458 '', '', environ, VCS_TYPE, registry=self.registry,
447 459 acl_repo_name=self.acl_repo_name)
448 460 if pre_auth and pre_auth.get('username'):
449 461 username = pre_auth['username']
450 462 log.debug('PRE-AUTH got %s as username', username)
451 463 if pre_auth:
452 464 log.debug('PRE-AUTH successful from %s',
453 465 pre_auth.get('auth_data', {}).get('_plugin'))
454 466
455 467 # If not authenticated by the container, running basic auth
456 468 # before inject the calling repo_name for special scope checks
457 469 self.authenticate.acl_repo_name = self.acl_repo_name
458 470
459 471 plugin_cache_active, cache_ttl = False, 0
460 472 plugin = None
461 473 if not username:
462 474 self.authenticate.realm = self.authenticate.get_rc_realm()
463 475
464 476 try:
465 477 auth_result = self.authenticate(environ)
466 478 except (UserCreationError, NotAllowedToCreateUserError) as e:
467 479 log.error(e)
468 480 reason = safe_str(e)
469 481 return HTTPNotAcceptable(reason)(environ, start_response)
470 482
471 483 if isinstance(auth_result, dict):
472 484 AUTH_TYPE.update(environ, 'basic')
473 485 REMOTE_USER.update(environ, auth_result['username'])
474 486 username = auth_result['username']
475 487 plugin = auth_result.get('auth_data', {}).get('_plugin')
476 488 log.info(
477 489 'MAIN-AUTH successful for user `%s` from %s plugin',
478 490 username, plugin)
479 491
480 492 plugin_cache_active, cache_ttl = auth_result.get(
481 493 'auth_data', {}).get('_ttl_cache') or (False, 0)
482 494 else:
483 495 return auth_result.wsgi_application(
484 496 environ, start_response)
485 497
486 498
487 499 # ==============================================================
488 500 # CHECK PERMISSIONS FOR THIS REQUEST USING GIVEN USERNAME
489 501 # ==============================================================
490 502 user = User.get_by_username(username)
491 503 if not self.valid_and_active_user(user):
492 504 return HTTPForbidden()(environ, start_response)
493 505 username = user.username
494 506 user.update_lastactivity()
495 507 meta.Session().commit()
496 508
497 509 # check user attributes for password change flag
498 510 user_obj = user
499 511 if user_obj and user_obj.username != User.DEFAULT_USER and \
500 512 user_obj.user_data.get('force_password_change'):
501 513 reason = 'password change required'
502 514 log.debug('User not allowed to authenticate, %s', reason)
503 515 return HTTPNotAcceptable(reason)(environ, start_response)
504 516
505 517 # check permissions for this repository
506 518 perm = self._check_permission(
507 519 action, user, self.acl_repo_name, ip_addr,
508 520 plugin, plugin_cache_active, cache_ttl)
509 521 if not perm:
510 522 return HTTPForbidden()(environ, start_response)
511 523
512 524 # extras are injected into UI object and later available
513 525 # in hooks executed by RhodeCode
514 526 check_locking = _should_check_locking(environ.get('QUERY_STRING'))
515 527 extras = vcs_operation_context(
516 528 environ, repo_name=self.acl_repo_name, username=username,
517 529 action=action, scm=self.SCM, check_locking=check_locking,
518 530 is_shadow_repo=self.is_shadow_repo
519 531 )
520 532
521 533 # ======================================================================
522 534 # REQUEST HANDLING
523 535 # ======================================================================
524 536 repo_path = os.path.join(
525 537 safe_str(self.base_path), safe_str(self.vcs_repo_name))
526 538 log.debug('Repository path is %s', repo_path)
527 539
528 540 fix_PATH()
529 541
530 542 log.info(
531 543 '%s action on %s repo "%s" by "%s" from %s %s',
532 544 action, self.SCM, safe_str(self.url_repo_name),
533 545 safe_str(username), ip_addr, user_agent)
534 546
535 547 return self._generate_vcs_response(
536 548 environ, start_response, repo_path, extras, action)
537 549
538 550 @initialize_generator
539 551 def _generate_vcs_response(
540 552 self, environ, start_response, repo_path, extras, action):
541 553 """
542 554 Returns a generator for the response content.
543 555
544 556 This method is implemented as a generator, so that it can trigger
545 557 the cache validation after all content sent back to the client. It
546 558 also handles the locking exceptions which will be triggered when
547 559 the first chunk is produced by the underlying WSGI application.
548 560 """
549 561 callback_daemon, extras = self._prepare_callback_daemon(extras)
550 562 config = self._create_config(extras, self.acl_repo_name)
551 563 log.debug('HOOKS extras is %s', extras)
552 564 app = self._create_wsgi_app(repo_path, self.url_repo_name, config)
553 565 app.rc_extras = extras
554 566
555 567 try:
556 568 with callback_daemon:
557 569 try:
558 570 response = app(environ, start_response)
559 571 finally:
560 572 # This statement works together with the decorator
561 573 # "initialize_generator" above. The decorator ensures that
562 574 # we hit the first yield statement before the generator is
563 575 # returned back to the WSGI server. This is needed to
564 576 # ensure that the call to "app" above triggers the
565 577 # needed callback to "start_response" before the
566 578 # generator is actually used.
567 579 yield "__init__"
568 580
569 581 for chunk in response:
570 582 yield chunk
571 583 except Exception as exc:
572 584 # TODO: martinb: Exceptions are only raised in case of the Pyro4
573 585 # backend. Refactor this except block after dropping Pyro4 support.
574 586 # TODO: johbo: Improve "translating" back the exception.
575 587 if getattr(exc, '_vcs_kind', None) == 'repo_locked':
576 588 exc = HTTPLockedRC(*exc.args)
577 589 _code = rhodecode.CONFIG.get('lock_ret_code')
578 590 log.debug('Repository LOCKED ret code %s!', (_code,))
579 591 elif getattr(exc, '_vcs_kind', None) == 'requirement':
580 592 log.debug(
581 593 'Repository requires features unknown to this Mercurial')
582 594 exc = HTTPRequirementError(*exc.args)
583 595 else:
584 596 raise
585 597
586 598 for chunk in exc(environ, start_response):
587 599 yield chunk
588 600 finally:
589 601 # invalidate cache on push
590 602 try:
591 603 if action == 'push':
592 604 self._invalidate_cache(self.url_repo_name)
593 605 finally:
594 606 meta.Session.remove()
595 607
596 608 def _get_repository_name(self, environ):
597 609 """Get repository name out of the environmnent
598 610
599 611 :param environ: WSGI environment
600 612 """
601 613 raise NotImplementedError()
602 614
603 615 def _get_action(self, environ):
604 616 """Map request commands into a pull or push command.
605 617
606 618 :param environ: WSGI environment
607 619 """
608 620 raise NotImplementedError()
609 621
610 622 def _create_wsgi_app(self, repo_path, repo_name, config):
611 623 """Return the WSGI app that will finally handle the request."""
612 624 raise NotImplementedError()
613 625
614 626 def _create_config(self, extras, repo_name):
615 627 """Create a safe config representation."""
616 628 raise NotImplementedError()
617 629
618 630 def _prepare_callback_daemon(self, extras):
619 631 return prepare_callback_daemon(
620 632 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
621 633 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
622 634
623 635
624 636 def _should_check_locking(query_string):
625 637 # this is kind of hacky, but due to how mercurial handles client-server
626 638 # server see all operation on commit; bookmarks, phases and
627 639 # obsolescence marker in different transaction, we don't want to check
628 640 # locking on those
629 641 return query_string not in ['cmd=listkeys']
@@ -1,496 +1,499 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import base64
22 22
23 23 import mock
24 24 import pytest
25 25
26 26 from rhodecode.lib.utils2 import AttributeDict
27 27 from rhodecode.tests.utils import CustomTestApp
28 28
29 29 from rhodecode.lib.caching_query import FromCache
30 30 from rhodecode.lib.hooks_daemon import DummyHooksCallbackDaemon
31 31 from rhodecode.lib.middleware import simplevcs
32 32 from rhodecode.lib.middleware.https_fixup import HttpsFixup
33 33 from rhodecode.lib.middleware.utils import scm_app_http
34 34 from rhodecode.model.db import User, _hash_key
35 35 from rhodecode.model.meta import Session
36 36 from rhodecode.tests import (
37 37 HG_REPO, TEST_USER_ADMIN_LOGIN, TEST_USER_ADMIN_PASS)
38 38 from rhodecode.tests.lib.middleware import mock_scm_app
39 39
40 40
41 41 class StubVCSController(simplevcs.SimpleVCS):
42 42
43 43 SCM = 'hg'
44 44 stub_response_body = tuple()
45 45
46 46 def __init__(self, *args, **kwargs):
47 47 super(StubVCSController, self).__init__(*args, **kwargs)
48 48 self._action = 'pull'
49 49 self._is_shadow_repo_dir = True
50 50 self._name = HG_REPO
51 51 self.set_repo_names(None)
52 52
53 53 @property
54 54 def is_shadow_repo_dir(self):
55 55 return self._is_shadow_repo_dir
56 56
57 57 def _get_repository_name(self, environ):
58 58 return self._name
59 59
60 60 def _get_action(self, environ):
61 61 return self._action
62 62
63 63 def _create_wsgi_app(self, repo_path, repo_name, config):
64 64 def fake_app(environ, start_response):
65 65 headers = [
66 66 ('Http-Accept', 'application/mercurial')
67 67 ]
68 68 start_response('200 OK', headers)
69 69 return self.stub_response_body
70 70 return fake_app
71 71
72 72 def _create_config(self, extras, repo_name):
73 73 return None
74 74
75 75
76 76 @pytest.fixture
77 77 def vcscontroller(baseapp, config_stub, request_stub):
78 78 config_stub.testing_securitypolicy()
79 79 config_stub.include('rhodecode.authentication')
80 80
81 81 controller = StubVCSController(
82 82 baseapp.config.get_settings(), request_stub.registry)
83 83 app = HttpsFixup(controller, baseapp.config.get_settings())
84 84 app = CustomTestApp(app)
85 85
86 86 _remove_default_user_from_query_cache()
87 87
88 88 # Sanity checks that things are set up correctly
89 89 app.get('/' + HG_REPO, status=200)
90 90
91 91 app.controller = controller
92 92 return app
93 93
94 94
95 95 def _remove_default_user_from_query_cache():
96 96 user = User.get_default_user(cache=True)
97 97 query = Session().query(User).filter(User.username == user.username)
98 98 query = query.options(
99 99 FromCache("sql_cache_short", "get_user_%s" % _hash_key(user.username)))
100 100 query.invalidate()
101 101 Session().expire(user)
102 102
103 103
104 104 def test_handles_exceptions_during_permissions_checks(
105 105 vcscontroller, disable_anonymous_user):
106 106 user_and_pass = '%s:%s' % (TEST_USER_ADMIN_LOGIN, TEST_USER_ADMIN_PASS)
107 107 auth_password = base64.encodestring(user_and_pass).strip()
108 108 extra_environ = {
109 109 'AUTH_TYPE': 'Basic',
110 110 'HTTP_AUTHORIZATION': 'Basic %s' % auth_password,
111 111 'REMOTE_USER': TEST_USER_ADMIN_LOGIN,
112 112 }
113 113
114 114 # Verify that things are hooked up correctly
115 115 vcscontroller.get('/', status=200, extra_environ=extra_environ)
116 116
117 117 # Simulate trouble during permission checks
118 118 with mock.patch('rhodecode.model.db.User.get_by_username',
119 119 side_effect=Exception) as get_user:
120 120 # Verify that a correct 500 is returned and check that the expected
121 121 # code path was hit.
122 122 vcscontroller.get('/', status=500, extra_environ=extra_environ)
123 123 assert get_user.called
124 124
125 125
126 126 def test_returns_forbidden_if_no_anonymous_access(
127 127 vcscontroller, disable_anonymous_user):
128 128 vcscontroller.get('/', status=401)
129 129
130 130
131 131 class StubFailVCSController(simplevcs.SimpleVCS):
132 132 def _handle_request(self, environ, start_response):
133 133 raise Exception("BOOM")
134 134
135 135
136 136 @pytest.fixture(scope='module')
137 137 def fail_controller(baseapp):
138 138 controller = StubFailVCSController(
139 139 baseapp.config.get_settings(), baseapp.config)
140 140 controller = HttpsFixup(controller, baseapp.config.get_settings())
141 141 controller = CustomTestApp(controller)
142 142 return controller
143 143
144 144
145 145 def test_handles_exceptions_as_internal_server_error(fail_controller):
146 146 fail_controller.get('/', status=500)
147 147
148 148
149 149 def test_provides_traceback_for_appenlight(fail_controller):
150 150 response = fail_controller.get(
151 151 '/', status=500, extra_environ={'appenlight.client': 'fake'})
152 152 assert 'appenlight.__traceback' in response.request.environ
153 153
154 154
155 155 def test_provides_utils_scm_app_as_scm_app_by_default(baseapp, request_stub):
156 156 controller = StubVCSController(baseapp.config.get_settings(), request_stub.registry)
157 157 assert controller.scm_app is scm_app_http
158 158
159 159
160 160 def test_allows_to_override_scm_app_via_config(baseapp, request_stub):
161 161 config = baseapp.config.get_settings().copy()
162 162 config['vcs.scm_app_implementation'] = (
163 163 'rhodecode.tests.lib.middleware.mock_scm_app')
164 164 controller = StubVCSController(config, request_stub.registry)
165 165 assert controller.scm_app is mock_scm_app
166 166
167 167
168 168 @pytest.mark.parametrize('query_string, expected', [
169 169 ('cmd=stub_command', True),
170 170 ('cmd=listkeys', False),
171 171 ])
172 172 def test_should_check_locking(query_string, expected):
173 173 result = simplevcs._should_check_locking(query_string)
174 174 assert result == expected
175 175
176 176
177 177 class TestShadowRepoRegularExpression(object):
178 178 pr_segment = 'pull-request'
179 179 shadow_segment = 'repository'
180 180
181 181 @pytest.mark.parametrize('url, expected', [
182 182 # repo with/without groups
183 183 ('My-Repo/{pr_segment}/1/{shadow_segment}', True),
184 184 ('Group/My-Repo/{pr_segment}/2/{shadow_segment}', True),
185 185 ('Group/Sub-Group/My-Repo/{pr_segment}/3/{shadow_segment}', True),
186 186 ('Group/Sub-Group1/Sub-Group2/My-Repo/{pr_segment}/3/{shadow_segment}', True),
187 187
188 188 # pull request ID
189 189 ('MyRepo/{pr_segment}/1/{shadow_segment}', True),
190 190 ('MyRepo/{pr_segment}/1234567890/{shadow_segment}', True),
191 191 ('MyRepo/{pr_segment}/-1/{shadow_segment}', False),
192 192 ('MyRepo/{pr_segment}/invalid/{shadow_segment}', False),
193 193
194 194 # unicode
195 195 (u'Sp€çîál-Repö/{pr_segment}/1/{shadow_segment}', True),
196 196 (u'Sp€çîál-Gröüp/Sp€çîál-Repö/{pr_segment}/1/{shadow_segment}', True),
197 197
198 198 # trailing/leading slash
199 199 ('/My-Repo/{pr_segment}/1/{shadow_segment}', False),
200 200 ('My-Repo/{pr_segment}/1/{shadow_segment}/', False),
201 201 ('/My-Repo/{pr_segment}/1/{shadow_segment}/', False),
202 202
203 203 # misc
204 204 ('My-Repo/{pr_segment}/1/{shadow_segment}/extra', False),
205 205 ('My-Repo/{pr_segment}/1/{shadow_segment}extra', False),
206 206 ])
207 207 def test_shadow_repo_regular_expression(self, url, expected):
208 208 from rhodecode.lib.middleware.simplevcs import SimpleVCS
209 209 url = url.format(
210 210 pr_segment=self.pr_segment,
211 211 shadow_segment=self.shadow_segment)
212 212 match_obj = SimpleVCS.shadow_repo_re.match(url)
213 213 assert (match_obj is not None) == expected
214 214
215 215
216 216 @pytest.mark.backends('git', 'hg')
217 217 class TestShadowRepoExposure(object):
218 218
219 219 def test_pull_on_shadow_repo_propagates_to_wsgi_app(
220 220 self, baseapp, request_stub):
221 221 """
222 222 Check that a pull action to a shadow repo is propagated to the
223 223 underlying wsgi app.
224 224 """
225 225 controller = StubVCSController(
226 226 baseapp.config.get_settings(), request_stub.registry)
227 227 controller._check_ssl = mock.Mock()
228 228 controller.is_shadow_repo = True
229 229 controller._action = 'pull'
230 230 controller._is_shadow_repo_dir = True
231 231 controller.stub_response_body = 'dummy body value'
232 controller._get_default_cache_ttl = mock.Mock(
233 return_value=(False, 0))
234
232 235 environ_stub = {
233 236 'HTTP_HOST': 'test.example.com',
234 237 'HTTP_ACCEPT': 'application/mercurial',
235 238 'REQUEST_METHOD': 'GET',
236 239 'wsgi.url_scheme': 'http',
237 240 }
238 241
239 242 response = controller(environ_stub, mock.Mock())
240 243 response_body = ''.join(response)
241 244
242 245 # Assert that we got the response from the wsgi app.
243 246 assert response_body == controller.stub_response_body
244 247
245 248 def test_pull_on_shadow_repo_that_is_missing(self, baseapp, request_stub):
246 249 """
247 250 Check that a pull action to a shadow repo is propagated to the
248 251 underlying wsgi app.
249 252 """
250 253 controller = StubVCSController(
251 254 baseapp.config.get_settings(), request_stub.registry)
252 255 controller._check_ssl = mock.Mock()
253 256 controller.is_shadow_repo = True
254 257 controller._action = 'pull'
255 258 controller._is_shadow_repo_dir = False
256 259 controller.stub_response_body = 'dummy body value'
257 260 environ_stub = {
258 261 'HTTP_HOST': 'test.example.com',
259 262 'HTTP_ACCEPT': 'application/mercurial',
260 263 'REQUEST_METHOD': 'GET',
261 264 'wsgi.url_scheme': 'http',
262 265 }
263 266
264 267 response = controller(environ_stub, mock.Mock())
265 268 response_body = ''.join(response)
266 269
267 270 # Assert that we got the response from the wsgi app.
268 271 assert '404 Not Found' in response_body
269 272
270 273 def test_push_on_shadow_repo_raises(self, baseapp, request_stub):
271 274 """
272 275 Check that a push action to a shadow repo is aborted.
273 276 """
274 277 controller = StubVCSController(
275 278 baseapp.config.get_settings(), request_stub.registry)
276 279 controller._check_ssl = mock.Mock()
277 280 controller.is_shadow_repo = True
278 281 controller._action = 'push'
279 282 controller.stub_response_body = 'dummy body value'
280 283 environ_stub = {
281 284 'HTTP_HOST': 'test.example.com',
282 285 'HTTP_ACCEPT': 'application/mercurial',
283 286 'REQUEST_METHOD': 'GET',
284 287 'wsgi.url_scheme': 'http',
285 288 }
286 289
287 290 response = controller(environ_stub, mock.Mock())
288 291 response_body = ''.join(response)
289 292
290 293 assert response_body != controller.stub_response_body
291 294 # Assert that a 406 error is returned.
292 295 assert '406 Not Acceptable' in response_body
293 296
294 297 def test_set_repo_names_no_shadow(self, baseapp, request_stub):
295 298 """
296 299 Check that the set_repo_names method sets all names to the one returned
297 300 by the _get_repository_name method on a request to a non shadow repo.
298 301 """
299 302 environ_stub = {}
300 303 controller = StubVCSController(
301 304 baseapp.config.get_settings(), request_stub.registry)
302 305 controller._name = 'RepoGroup/MyRepo'
303 306 controller.set_repo_names(environ_stub)
304 307 assert not controller.is_shadow_repo
305 308 assert (controller.url_repo_name ==
306 309 controller.acl_repo_name ==
307 310 controller.vcs_repo_name ==
308 311 controller._get_repository_name(environ_stub))
309 312
310 313 def test_set_repo_names_with_shadow(
311 314 self, baseapp, pr_util, config_stub, request_stub):
312 315 """
313 316 Check that the set_repo_names method sets correct names on a request
314 317 to a shadow repo.
315 318 """
316 319 from rhodecode.model.pull_request import PullRequestModel
317 320
318 321 pull_request = pr_util.create_pull_request()
319 322 shadow_url = '{target}/{pr_segment}/{pr_id}/{shadow_segment}'.format(
320 323 target=pull_request.target_repo.repo_name,
321 324 pr_id=pull_request.pull_request_id,
322 325 pr_segment=TestShadowRepoRegularExpression.pr_segment,
323 326 shadow_segment=TestShadowRepoRegularExpression.shadow_segment)
324 327 controller = StubVCSController(
325 328 baseapp.config.get_settings(), request_stub.registry)
326 329 controller._name = shadow_url
327 330 controller.set_repo_names({})
328 331
329 332 # Get file system path to shadow repo for assertions.
330 333 workspace_id = PullRequestModel()._workspace_id(pull_request)
331 334 target_vcs = pull_request.target_repo.scm_instance()
332 335 vcs_repo_name = target_vcs._get_shadow_repository_path(
333 336 workspace_id)
334 337
335 338 assert controller.vcs_repo_name == vcs_repo_name
336 339 assert controller.url_repo_name == shadow_url
337 340 assert controller.acl_repo_name == pull_request.target_repo.repo_name
338 341 assert controller.is_shadow_repo
339 342
340 343 def test_set_repo_names_with_shadow_but_missing_pr(
341 344 self, baseapp, pr_util, config_stub, request_stub):
342 345 """
343 346 Checks that the set_repo_names method enforces matching target repos
344 347 and pull request IDs.
345 348 """
346 349 pull_request = pr_util.create_pull_request()
347 350 shadow_url = '{target}/{pr_segment}/{pr_id}/{shadow_segment}'.format(
348 351 target=pull_request.target_repo.repo_name,
349 352 pr_id=999999999,
350 353 pr_segment=TestShadowRepoRegularExpression.pr_segment,
351 354 shadow_segment=TestShadowRepoRegularExpression.shadow_segment)
352 355 controller = StubVCSController(
353 356 baseapp.config.get_settings(), request_stub.registry)
354 357 controller._name = shadow_url
355 358 controller.set_repo_names({})
356 359
357 360 assert not controller.is_shadow_repo
358 361 assert (controller.url_repo_name ==
359 362 controller.acl_repo_name ==
360 363 controller.vcs_repo_name)
361 364
362 365
363 366 @pytest.mark.usefixtures('baseapp')
364 367 class TestGenerateVcsResponse(object):
365 368
366 369 def test_ensures_that_start_response_is_called_early_enough(self):
367 370 self.call_controller_with_response_body(iter(['a', 'b']))
368 371 assert self.start_response.called
369 372
370 373 def test_invalidates_cache_after_body_is_consumed(self):
371 374 result = self.call_controller_with_response_body(iter(['a', 'b']))
372 375 assert not self.was_cache_invalidated()
373 376 # Consume the result
374 377 list(result)
375 378 assert self.was_cache_invalidated()
376 379
377 380 @mock.patch('rhodecode.lib.middleware.simplevcs.HTTPLockedRC')
378 381 def test_handles_locking_exception(self, http_locked_rc):
379 382 result = self.call_controller_with_response_body(
380 383 self.raise_result_iter(vcs_kind='repo_locked'))
381 384 assert not http_locked_rc.called
382 385 # Consume the result
383 386 list(result)
384 387 assert http_locked_rc.called
385 388
386 389 @mock.patch('rhodecode.lib.middleware.simplevcs.HTTPRequirementError')
387 390 def test_handles_requirement_exception(self, http_requirement):
388 391 result = self.call_controller_with_response_body(
389 392 self.raise_result_iter(vcs_kind='requirement'))
390 393 assert not http_requirement.called
391 394 # Consume the result
392 395 list(result)
393 396 assert http_requirement.called
394 397
395 398 @mock.patch('rhodecode.lib.middleware.simplevcs.HTTPLockedRC')
396 399 def test_handles_locking_exception_in_app_call(self, http_locked_rc):
397 400 app_factory_patcher = mock.patch.object(
398 401 StubVCSController, '_create_wsgi_app')
399 402 with app_factory_patcher as app_factory:
400 403 app_factory().side_effect = self.vcs_exception()
401 404 result = self.call_controller_with_response_body(['a'])
402 405 list(result)
403 406 assert http_locked_rc.called
404 407
405 408 def test_raises_unknown_exceptions(self):
406 409 result = self.call_controller_with_response_body(
407 410 self.raise_result_iter(vcs_kind='unknown'))
408 411 with pytest.raises(Exception):
409 412 list(result)
410 413
411 414 def test_prepare_callback_daemon_is_called(self):
412 415 def side_effect(extras):
413 416 return DummyHooksCallbackDaemon(), extras
414 417
415 418 prepare_patcher = mock.patch.object(
416 419 StubVCSController, '_prepare_callback_daemon')
417 420 with prepare_patcher as prepare_mock:
418 421 prepare_mock.side_effect = side_effect
419 422 self.call_controller_with_response_body(iter(['a', 'b']))
420 423 assert prepare_mock.called
421 424 assert prepare_mock.call_count == 1
422 425
423 426 def call_controller_with_response_body(self, response_body):
424 427 settings = {
425 428 'base_path': 'fake_base_path',
426 429 'vcs.hooks.protocol': 'http',
427 430 'vcs.hooks.direct_calls': False,
428 431 }
429 432 registry = AttributeDict()
430 433 controller = StubVCSController(settings, registry)
431 434 controller._invalidate_cache = mock.Mock()
432 435 controller.stub_response_body = response_body
433 436 self.start_response = mock.Mock()
434 437 result = controller._generate_vcs_response(
435 438 environ={}, start_response=self.start_response,
436 439 repo_path='fake_repo_path',
437 440 extras={}, action='push')
438 441 self.controller = controller
439 442 return result
440 443
441 444 def raise_result_iter(self, vcs_kind='repo_locked'):
442 445 """
443 446 Simulates an exception due to a vcs raised exception if kind vcs_kind
444 447 """
445 448 raise self.vcs_exception(vcs_kind=vcs_kind)
446 449 yield "never_reached"
447 450
448 451 def vcs_exception(self, vcs_kind='repo_locked'):
449 452 locked_exception = Exception('TEST_MESSAGE')
450 453 locked_exception._vcs_kind = vcs_kind
451 454 return locked_exception
452 455
453 456 def was_cache_invalidated(self):
454 457 return self.controller._invalidate_cache.called
455 458
456 459
457 460 class TestInitializeGenerator(object):
458 461
459 462 def test_drains_first_element(self):
460 463 gen = self.factory(['__init__', 1, 2])
461 464 result = list(gen)
462 465 assert result == [1, 2]
463 466
464 467 @pytest.mark.parametrize('values', [
465 468 [],
466 469 [1, 2],
467 470 ])
468 471 def test_raises_value_error(self, values):
469 472 with pytest.raises(ValueError):
470 473 self.factory(values)
471 474
472 475 @simplevcs.initialize_generator
473 476 def factory(self, iterable):
474 477 for elem in iterable:
475 478 yield elem
476 479
477 480
478 481 class TestPrepareHooksDaemon(object):
479 482 def test_calls_imported_prepare_callback_daemon(self, app_settings, request_stub):
480 483 expected_extras = {'extra1': 'value1'}
481 484 daemon = DummyHooksCallbackDaemon()
482 485
483 486 controller = StubVCSController(app_settings, request_stub.registry)
484 487 prepare_patcher = mock.patch.object(
485 488 simplevcs, 'prepare_callback_daemon',
486 489 return_value=(daemon, expected_extras))
487 490 with prepare_patcher as prepare_mock:
488 491 callback_daemon, extras = controller._prepare_callback_daemon(
489 492 expected_extras.copy())
490 493 prepare_mock.assert_called_once_with(
491 494 expected_extras,
492 495 protocol=app_settings['vcs.hooks.protocol'],
493 496 use_direct_calls=app_settings['vcs.hooks.direct_calls'])
494 497
495 498 assert callback_daemon == daemon
496 499 assert extras == extras
General Comments 0
You need to be logged in to leave comments. Login now