##// END OF EJS Templates
simplevcs: fix a typo when running detection of vcs backend....
marcink -
r1125:ee691b6f default
parent child Browse files
Show More
@@ -1,529 +1,529 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2014-2016 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 logging
28 28 import importlib
29 29 import re
30 30 from functools import wraps
31 31
32 32 from paste.httpheaders import REMOTE_USER, AUTH_TYPE
33 33 from webob.exc import (
34 34 HTTPNotFound, HTTPForbidden, HTTPNotAcceptable, HTTPInternalServerError)
35 35
36 36 import rhodecode
37 37 from rhodecode.authentication.base import authenticate, VCS_TYPE
38 38 from rhodecode.lib.auth import AuthUser, HasPermissionAnyMiddleware
39 39 from rhodecode.lib.base import BasicAuth, get_ip_addr, vcs_operation_context
40 40 from rhodecode.lib.exceptions import (
41 41 HTTPLockedRC, HTTPRequirementError, UserCreationError,
42 42 NotAllowedToCreateUserError)
43 43 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
44 44 from rhodecode.lib.middleware import appenlight
45 45 from rhodecode.lib.middleware.utils import scm_app, scm_app_http
46 46 from rhodecode.lib.utils import (
47 47 is_valid_repo, get_rhodecode_realm, get_rhodecode_base_path, SLUG_RE)
48 48 from rhodecode.lib.utils2 import safe_str, fix_PATH, str2bool, safe_unicode
49 49 from rhodecode.lib.vcs.conf import settings as vcs_settings
50 50 from rhodecode.lib.vcs.backends import base
51 51 from rhodecode.model import meta
52 52 from rhodecode.model.db import User, Repository, PullRequest
53 53 from rhodecode.model.scm import ScmModel
54 54 from rhodecode.model.pull_request import PullRequestModel
55 55
56 56
57 57 log = logging.getLogger(__name__)
58 58
59 59
60 60 def initialize_generator(factory):
61 61 """
62 62 Initializes the returned generator by draining its first element.
63 63
64 64 This can be used to give a generator an initializer, which is the code
65 65 up to the first yield statement. This decorator enforces that the first
66 66 produced element has the value ``"__init__"`` to make its special
67 67 purpose very explicit in the using code.
68 68 """
69 69
70 70 @wraps(factory)
71 71 def wrapper(*args, **kwargs):
72 72 gen = factory(*args, **kwargs)
73 73 try:
74 74 init = gen.next()
75 75 except StopIteration:
76 76 raise ValueError('Generator must yield at least one element.')
77 77 if init != "__init__":
78 78 raise ValueError('First yielded element must be "__init__".')
79 79 return gen
80 80 return wrapper
81 81
82 82
83 83 class SimpleVCS(object):
84 84 """Common functionality for SCM HTTP handlers."""
85 85
86 86 SCM = 'unknown'
87 87
88 88 acl_repo_name = None
89 89 url_repo_name = None
90 90 vcs_repo_name = None
91 91
92 92 # We have to handle requests to shadow repositories different than requests
93 93 # to normal repositories. Therefore we have to distinguish them. To do this
94 94 # we use this regex which will match only on URLs pointing to shadow
95 95 # repositories.
96 96 shadow_repo_re = re.compile(
97 97 '(?P<groups>(?:{slug_pat}/)*)' # repo groups
98 98 '(?P<target>{slug_pat})/' # target repo
99 99 'pull-request/(?P<pr_id>\d+)/' # pull request
100 100 'repository$' # shadow repo
101 101 .format(slug_pat=SLUG_RE.pattern))
102 102
103 103 def __init__(self, application, config, registry):
104 104 self.registry = registry
105 105 self.application = application
106 106 self.config = config
107 107 # re-populated by specialized middleware
108 108 self.repo_vcs_config = base.Config()
109 109
110 110 # base path of repo locations
111 111 self.basepath = get_rhodecode_base_path()
112 112 # authenticate this VCS request using authfunc
113 113 auth_ret_code_detection = \
114 114 str2bool(self.config.get('auth_ret_code_detection', False))
115 115 self.authenticate = BasicAuth(
116 116 '', authenticate, registry, config.get('auth_ret_code'),
117 117 auth_ret_code_detection)
118 118 self.ip_addr = '0.0.0.0'
119 119
120 120 def set_repo_names(self, environ):
121 121 """
122 122 This will populate the attributes acl_repo_name, url_repo_name,
123 123 vcs_repo_name and is_shadow_repo. In case of requests to normal (non
124 124 shadow) repositories all names are equal. In case of requests to a
125 125 shadow repository the acl-name points to the target repo of the pull
126 126 request and the vcs-name points to the shadow repo file system path.
127 127 The url-name is always the URL used by the vcs client program.
128 128
129 129 Example in case of a shadow repo:
130 130 acl_repo_name = RepoGroup/MyRepo
131 131 url_repo_name = RepoGroup/MyRepo/pull-request/3/repository
132 132 vcs_repo_name = /repo/base/path/RepoGroup/.__shadow_MyRepo_pr-3'
133 133 """
134 134 # First we set the repo name from URL for all attributes. This is the
135 135 # default if handling normal (non shadow) repo requests.
136 136 self.url_repo_name = self._get_repository_name(environ)
137 137 self.acl_repo_name = self.vcs_repo_name = self.url_repo_name
138 138 self.is_shadow_repo = False
139 139
140 140 # Check if this is a request to a shadow repository.
141 141 match = self.shadow_repo_re.match(self.url_repo_name)
142 142 if match:
143 143 match_dict = match.groupdict()
144 144
145 145 # Build acl repo name from regex match.
146 146 acl_repo_name = safe_unicode('{groups}{target}'.format(
147 147 groups=match_dict['groups'] or '',
148 148 target=match_dict['target']))
149 149
150 150 # Retrieve pull request instance by ID from regex match.
151 151 pull_request = PullRequest.get(match_dict['pr_id'])
152 152
153 153 # Only proceed if we got a pull request and if acl repo name from
154 154 # URL equals the target repo name of the pull request.
155 155 if pull_request and (acl_repo_name ==
156 156 pull_request.target_repo.repo_name):
157 157 # Get file system path to shadow repository.
158 158 workspace_id = PullRequestModel()._workspace_id(pull_request)
159 159 target_vcs = pull_request.target_repo.scm_instance()
160 160 vcs_repo_name = target_vcs._get_shadow_repository_path(
161 161 workspace_id)
162 162
163 163 # Store names for later usage.
164 164 self.vcs_repo_name = vcs_repo_name
165 165 self.acl_repo_name = acl_repo_name
166 166 self.is_shadow_repo = True
167 167
168 168 log.debug('Repository names: %s', {
169 169 'acl_repo_name': self.acl_repo_name,
170 170 'url_repo_name': self.url_repo_name,
171 171 'vcs_repo_name': self.vcs_repo_name,
172 172 })
173 173
174 174 @property
175 175 def scm_app(self):
176 176 custom_implementation = self.config['vcs.scm_app_implementation']
177 177 if custom_implementation == 'http':
178 178 log.info('Using HTTP implementation of scm app.')
179 179 scm_app_impl = scm_app_http
180 180 elif custom_implementation == 'pyro4':
181 181 log.info('Using Pyro implementation of scm app.')
182 182 scm_app_impl = scm_app
183 183 else:
184 184 log.info('Using custom implementation of scm_app: "{}"'.format(
185 185 custom_implementation))
186 186 scm_app_impl = importlib.import_module(custom_implementation)
187 187 return scm_app_impl
188 188
189 189 def _get_by_id(self, repo_name):
190 190 """
191 191 Gets a special pattern _<ID> from clone url and tries to replace it
192 192 with a repository_name for support of _<ID> non changeable urls
193 193 """
194 194
195 195 data = repo_name.split('/')
196 196 if len(data) >= 2:
197 197 from rhodecode.model.repo import RepoModel
198 198 by_id_match = RepoModel().get_repo_by_id(repo_name)
199 199 if by_id_match:
200 200 data[1] = by_id_match.repo_name
201 201
202 202 return safe_str('/'.join(data))
203 203
204 204 def _invalidate_cache(self, repo_name):
205 205 """
206 206 Set's cache for this repository for invalidation on next access
207 207
208 208 :param repo_name: full repo name, also a cache key
209 209 """
210 210 ScmModel().mark_for_invalidation(repo_name)
211 211
212 212 def is_valid_and_existing_repo(self, repo_name, base_path, scm_type):
213 213 db_repo = Repository.get_by_repo_name(repo_name)
214 214 if not db_repo:
215 215 log.debug('Repository `%s` not found inside the database.',
216 216 repo_name)
217 217 return False
218 218
219 219 if db_repo.repo_type != scm_type:
220 220 log.warning(
221 221 'Repository `%s` have incorrect scm_type, expected %s got %s',
222 222 repo_name, db_repo.repo_type, scm_type)
223 223 return False
224 224
225 return is_valid_repo(repo_name, base_path, expect_scm=scm_type)
225 return is_valid_repo(repo_name, base_path, explicit_scm=scm_type)
226 226
227 227 def valid_and_active_user(self, user):
228 228 """
229 229 Checks if that user is not empty, and if it's actually object it checks
230 230 if he's active.
231 231
232 232 :param user: user object or None
233 233 :return: boolean
234 234 """
235 235 if user is None:
236 236 return False
237 237
238 238 elif user.active:
239 239 return True
240 240
241 241 return False
242 242
243 243 def _check_permission(self, action, user, repo_name, ip_addr=None):
244 244 """
245 245 Checks permissions using action (push/pull) user and repository
246 246 name
247 247
248 248 :param action: push or pull action
249 249 :param user: user instance
250 250 :param repo_name: repository name
251 251 """
252 252 # check IP
253 253 inherit = user.inherit_default_permissions
254 254 ip_allowed = AuthUser.check_ip_allowed(user.user_id, ip_addr,
255 255 inherit_from_default=inherit)
256 256 if ip_allowed:
257 257 log.info('Access for IP:%s allowed', ip_addr)
258 258 else:
259 259 return False
260 260
261 261 if action == 'push':
262 262 if not HasPermissionAnyMiddleware('repository.write',
263 263 'repository.admin')(user,
264 264 repo_name):
265 265 return False
266 266
267 267 else:
268 268 # any other action need at least read permission
269 269 if not HasPermissionAnyMiddleware('repository.read',
270 270 'repository.write',
271 271 'repository.admin')(user,
272 272 repo_name):
273 273 return False
274 274
275 275 return True
276 276
277 277 def _check_ssl(self, environ, start_response):
278 278 """
279 279 Checks the SSL check flag and returns False if SSL is not present
280 280 and required True otherwise
281 281 """
282 282 org_proto = environ['wsgi._org_proto']
283 283 # check if we have SSL required ! if not it's a bad request !
284 284 require_ssl = str2bool(self.repo_vcs_config.get('web', 'push_ssl'))
285 285 if require_ssl and org_proto == 'http':
286 286 log.debug('proto is %s and SSL is required BAD REQUEST !',
287 287 org_proto)
288 288 return False
289 289 return True
290 290
291 291 def __call__(self, environ, start_response):
292 292 try:
293 293 return self._handle_request(environ, start_response)
294 294 except Exception:
295 295 log.exception("Exception while handling request")
296 296 appenlight.track_exception(environ)
297 297 return HTTPInternalServerError()(environ, start_response)
298 298 finally:
299 299 meta.Session.remove()
300 300
301 301 def _handle_request(self, environ, start_response):
302 302
303 303 if not self._check_ssl(environ, start_response):
304 304 reason = ('SSL required, while RhodeCode was unable '
305 305 'to detect this as SSL request')
306 306 log.debug('User not allowed to proceed, %s', reason)
307 307 return HTTPNotAcceptable(reason)(environ, start_response)
308 308
309 309 if not self.url_repo_name:
310 310 log.warning('Repository name is empty: %s', self.url_repo_name)
311 311 # failed to get repo name, we fail now
312 312 return HTTPNotFound()(environ, start_response)
313 313 log.debug('Extracted repo name is %s', self.url_repo_name)
314 314
315 315 ip_addr = get_ip_addr(environ)
316 316 username = None
317 317
318 318 # skip passing error to error controller
319 319 environ['pylons.status_code_redirect'] = True
320 320
321 321 # ======================================================================
322 322 # GET ACTION PULL or PUSH
323 323 # ======================================================================
324 324 action = self._get_action(environ)
325 325
326 326 # ======================================================================
327 327 # Check if this is a request to a shadow repository of a pull request.
328 328 # In this case only pull action is allowed.
329 329 # ======================================================================
330 330 if self.is_shadow_repo and action != 'pull':
331 331 reason = 'Only pull action is allowed for shadow repositories.'
332 332 log.debug('User not allowed to proceed, %s', reason)
333 333 return HTTPNotAcceptable(reason)(environ, start_response)
334 334
335 335 # ======================================================================
336 336 # CHECK ANONYMOUS PERMISSION
337 337 # ======================================================================
338 338 if action in ['pull', 'push']:
339 339 anonymous_user = User.get_default_user()
340 340 username = anonymous_user.username
341 341 if anonymous_user.active:
342 342 # ONLY check permissions if the user is activated
343 343 anonymous_perm = self._check_permission(
344 344 action, anonymous_user, self.acl_repo_name, ip_addr)
345 345 else:
346 346 anonymous_perm = False
347 347
348 348 if not anonymous_user.active or not anonymous_perm:
349 349 if not anonymous_user.active:
350 350 log.debug('Anonymous access is disabled, running '
351 351 'authentication')
352 352
353 353 if not anonymous_perm:
354 354 log.debug('Not enough credentials to access this '
355 355 'repository as anonymous user')
356 356
357 357 username = None
358 358 # ==============================================================
359 359 # DEFAULT PERM FAILED OR ANONYMOUS ACCESS IS DISABLED SO WE
360 360 # NEED TO AUTHENTICATE AND ASK FOR AUTH USER PERMISSIONS
361 361 # ==============================================================
362 362
363 363 # try to auth based on environ, container auth methods
364 364 log.debug('Running PRE-AUTH for container based authentication')
365 365 pre_auth = authenticate(
366 366 '', '', environ, VCS_TYPE, registry=self.registry)
367 367 if pre_auth and pre_auth.get('username'):
368 368 username = pre_auth['username']
369 369 log.debug('PRE-AUTH got %s as username', username)
370 370
371 371 # If not authenticated by the container, running basic auth
372 372 if not username:
373 373 self.authenticate.realm = get_rhodecode_realm()
374 374
375 375 try:
376 376 result = self.authenticate(environ)
377 377 except (UserCreationError, NotAllowedToCreateUserError) as e:
378 378 log.error(e)
379 379 reason = safe_str(e)
380 380 return HTTPNotAcceptable(reason)(environ, start_response)
381 381
382 382 if isinstance(result, str):
383 383 AUTH_TYPE.update(environ, 'basic')
384 384 REMOTE_USER.update(environ, result)
385 385 username = result
386 386 else:
387 387 return result.wsgi_application(environ, start_response)
388 388
389 389 # ==============================================================
390 390 # CHECK PERMISSIONS FOR THIS REQUEST USING GIVEN USERNAME
391 391 # ==============================================================
392 392 user = User.get_by_username(username)
393 393 if not self.valid_and_active_user(user):
394 394 return HTTPForbidden()(environ, start_response)
395 395 username = user.username
396 396 user.update_lastactivity()
397 397 meta.Session().commit()
398 398
399 399 # check user attributes for password change flag
400 400 user_obj = user
401 401 if user_obj and user_obj.username != User.DEFAULT_USER and \
402 402 user_obj.user_data.get('force_password_change'):
403 403 reason = 'password change required'
404 404 log.debug('User not allowed to authenticate, %s', reason)
405 405 return HTTPNotAcceptable(reason)(environ, start_response)
406 406
407 407 # check permissions for this repository
408 408 perm = self._check_permission(
409 409 action, user, self.acl_repo_name, ip_addr)
410 410 if not perm:
411 411 return HTTPForbidden()(environ, start_response)
412 412
413 413 # extras are injected into UI object and later available
414 414 # in hooks executed by rhodecode
415 415 check_locking = _should_check_locking(environ.get('QUERY_STRING'))
416 416 extras = vcs_operation_context(
417 417 environ, repo_name=self.acl_repo_name, username=username,
418 418 action=action, scm=self.SCM, check_locking=check_locking,
419 419 is_shadow_repo=self.is_shadow_repo
420 420 )
421 421
422 422 # ======================================================================
423 423 # REQUEST HANDLING
424 424 # ======================================================================
425 425 repo_path = os.path.join(
426 426 safe_str(self.basepath), safe_str(self.vcs_repo_name))
427 427 log.debug('Repository path is %s', repo_path)
428 428
429 429 fix_PATH()
430 430
431 431 log.info(
432 432 '%s action on %s repo "%s" by "%s" from %s',
433 433 action, self.SCM, safe_str(self.url_repo_name),
434 434 safe_str(username), ip_addr)
435 435
436 436 return self._generate_vcs_response(
437 437 environ, start_response, repo_path, extras, action)
438 438
439 439 @initialize_generator
440 440 def _generate_vcs_response(
441 441 self, environ, start_response, repo_path, extras, action):
442 442 """
443 443 Returns a generator for the response content.
444 444
445 445 This method is implemented as a generator, so that it can trigger
446 446 the cache validation after all content sent back to the client. It
447 447 also handles the locking exceptions which will be triggered when
448 448 the first chunk is produced by the underlying WSGI application.
449 449 """
450 450 callback_daemon, extras = self._prepare_callback_daemon(extras)
451 451 config = self._create_config(extras, self.acl_repo_name)
452 452 log.debug('HOOKS extras is %s', extras)
453 453 app = self._create_wsgi_app(repo_path, self.url_repo_name, config)
454 454
455 455 try:
456 456 with callback_daemon:
457 457 try:
458 458 response = app(environ, start_response)
459 459 finally:
460 460 # This statement works together with the decorator
461 461 # "initialize_generator" above. The decorator ensures that
462 462 # we hit the first yield statement before the generator is
463 463 # returned back to the WSGI server. This is needed to
464 464 # ensure that the call to "app" above triggers the
465 465 # needed callback to "start_response" before the
466 466 # generator is actually used.
467 467 yield "__init__"
468 468
469 469 for chunk in response:
470 470 yield chunk
471 471 except Exception as exc:
472 472 # TODO: martinb: Exceptions are only raised in case of the Pyro4
473 473 # backend. Refactor this except block after dropping Pyro4 support.
474 474 # TODO: johbo: Improve "translating" back the exception.
475 475 if getattr(exc, '_vcs_kind', None) == 'repo_locked':
476 476 exc = HTTPLockedRC(*exc.args)
477 477 _code = rhodecode.CONFIG.get('lock_ret_code')
478 478 log.debug('Repository LOCKED ret code %s!', (_code,))
479 479 elif getattr(exc, '_vcs_kind', None) == 'requirement':
480 480 log.debug(
481 481 'Repository requires features unknown to this Mercurial')
482 482 exc = HTTPRequirementError(*exc.args)
483 483 else:
484 484 raise
485 485
486 486 for chunk in exc(environ, start_response):
487 487 yield chunk
488 488 finally:
489 489 # invalidate cache on push
490 490 try:
491 491 if action == 'push':
492 492 self._invalidate_cache(self.url_repo_name)
493 493 finally:
494 494 meta.Session.remove()
495 495
496 496 def _get_repository_name(self, environ):
497 497 """Get repository name out of the environmnent
498 498
499 499 :param environ: WSGI environment
500 500 """
501 501 raise NotImplementedError()
502 502
503 503 def _get_action(self, environ):
504 504 """Map request commands into a pull or push command.
505 505
506 506 :param environ: WSGI environment
507 507 """
508 508 raise NotImplementedError()
509 509
510 510 def _create_wsgi_app(self, repo_path, repo_name, config):
511 511 """Return the WSGI app that will finally handle the request."""
512 512 raise NotImplementedError()
513 513
514 514 def _create_config(self, extras, repo_name):
515 515 """Create a Pyro safe config representation."""
516 516 raise NotImplementedError()
517 517
518 518 def _prepare_callback_daemon(self, extras):
519 519 return prepare_callback_daemon(
520 520 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
521 521 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
522 522
523 523
524 524 def _should_check_locking(query_string):
525 525 # this is kind of hacky, but due to how mercurial handles client-server
526 526 # server see all operation on commit; bookmarks, phases and
527 527 # obsolescence marker in different transaction, we don't want to check
528 528 # locking on those
529 529 return query_string not in ['cmd=listkeys']
General Comments 0
You need to be logged in to leave comments. Login now