##// END OF EJS Templates
http: Allow http, pyro and custom implementations for scm app.
Martin Bornhold -
r962:a79a5a84 default
parent child Browse files
Show More
@@ -1,524 +1,527 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 from rhodecode.lib.middleware.utils import scm_app
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 custom_implementation = self.config.get('vcs.scm_app_implementation')
177 if custom_implementation and custom_implementation != 'pyro4':
178 log.info(
179 "Using custom implementation of scm_app: %s",
180 custom_implementation)
176 custom_implementation = self.config['vcs.scm_app_implementation']
177 if custom_implementation == 'http':
178 log.info('Using HTTP implementation of scm app.')
179 scm_app_impl = scm_app_http
180 elif custom_implementation == 'pyro4':
181 log.info('Using Pyro implementation of scm app.')
182 scm_app_impl = scm_app
183 else:
184 log.info('Using custom implementation of scm_app: "{}"'.format(
185 custom_implementation))
181 186 scm_app_impl = importlib.import_module(custom_implementation)
182 else:
183 scm_app_impl = scm_app
184 187 return scm_app_impl
185 188
186 189 def _get_by_id(self, repo_name):
187 190 """
188 191 Gets a special pattern _<ID> from clone url and tries to replace it
189 192 with a repository_name for support of _<ID> non changeable urls
190 193 """
191 194
192 195 data = repo_name.split('/')
193 196 if len(data) >= 2:
194 197 from rhodecode.model.repo import RepoModel
195 198 by_id_match = RepoModel().get_repo_by_id(repo_name)
196 199 if by_id_match:
197 200 data[1] = by_id_match.repo_name
198 201
199 202 return safe_str('/'.join(data))
200 203
201 204 def _invalidate_cache(self, repo_name):
202 205 """
203 206 Set's cache for this repository for invalidation on next access
204 207
205 208 :param repo_name: full repo name, also a cache key
206 209 """
207 210 ScmModel().mark_for_invalidation(repo_name)
208 211
209 212 def is_valid_and_existing_repo(self, repo_name, base_path, scm_type):
210 213 db_repo = Repository.get_by_repo_name(repo_name)
211 214 if not db_repo:
212 215 log.debug('Repository `%s` not found inside the database.',
213 216 repo_name)
214 217 return False
215 218
216 219 if db_repo.repo_type != scm_type:
217 220 log.warning(
218 221 'Repository `%s` have incorrect scm_type, expected %s got %s',
219 222 repo_name, db_repo.repo_type, scm_type)
220 223 return False
221 224
222 225 return is_valid_repo(repo_name, base_path, expect_scm=scm_type)
223 226
224 227 def valid_and_active_user(self, user):
225 228 """
226 229 Checks if that user is not empty, and if it's actually object it checks
227 230 if he's active.
228 231
229 232 :param user: user object or None
230 233 :return: boolean
231 234 """
232 235 if user is None:
233 236 return False
234 237
235 238 elif user.active:
236 239 return True
237 240
238 241 return False
239 242
240 243 def _check_permission(self, action, user, repo_name, ip_addr=None):
241 244 """
242 245 Checks permissions using action (push/pull) user and repository
243 246 name
244 247
245 248 :param action: push or pull action
246 249 :param user: user instance
247 250 :param repo_name: repository name
248 251 """
249 252 # check IP
250 253 inherit = user.inherit_default_permissions
251 254 ip_allowed = AuthUser.check_ip_allowed(user.user_id, ip_addr,
252 255 inherit_from_default=inherit)
253 256 if ip_allowed:
254 257 log.info('Access for IP:%s allowed', ip_addr)
255 258 else:
256 259 return False
257 260
258 261 if action == 'push':
259 262 if not HasPermissionAnyMiddleware('repository.write',
260 263 'repository.admin')(user,
261 264 repo_name):
262 265 return False
263 266
264 267 else:
265 268 # any other action need at least read permission
266 269 if not HasPermissionAnyMiddleware('repository.read',
267 270 'repository.write',
268 271 'repository.admin')(user,
269 272 repo_name):
270 273 return False
271 274
272 275 return True
273 276
274 277 def _check_ssl(self, environ, start_response):
275 278 """
276 279 Checks the SSL check flag and returns False if SSL is not present
277 280 and required True otherwise
278 281 """
279 282 org_proto = environ['wsgi._org_proto']
280 283 # check if we have SSL required ! if not it's a bad request !
281 284 require_ssl = str2bool(self.repo_vcs_config.get('web', 'push_ssl'))
282 285 if require_ssl and org_proto == 'http':
283 286 log.debug('proto is %s and SSL is required BAD REQUEST !',
284 287 org_proto)
285 288 return False
286 289 return True
287 290
288 291 def __call__(self, environ, start_response):
289 292 try:
290 293 return self._handle_request(environ, start_response)
291 294 except Exception:
292 295 log.exception("Exception while handling request")
293 296 appenlight.track_exception(environ)
294 297 return HTTPInternalServerError()(environ, start_response)
295 298 finally:
296 299 meta.Session.remove()
297 300
298 301 def _handle_request(self, environ, start_response):
299 302
300 303 if not self._check_ssl(environ, start_response):
301 304 reason = ('SSL required, while RhodeCode was unable '
302 305 'to detect this as SSL request')
303 306 log.debug('User not allowed to proceed, %s', reason)
304 307 return HTTPNotAcceptable(reason)(environ, start_response)
305 308
306 309 if not self.url_repo_name:
307 310 log.warning('Repository name is empty: %s', self.url_repo_name)
308 311 # failed to get repo name, we fail now
309 312 return HTTPNotFound()(environ, start_response)
310 313 log.debug('Extracted repo name is %s', self.url_repo_name)
311 314
312 315 ip_addr = get_ip_addr(environ)
313 316 username = None
314 317
315 318 # skip passing error to error controller
316 319 environ['pylons.status_code_redirect'] = True
317 320
318 321 # ======================================================================
319 322 # GET ACTION PULL or PUSH
320 323 # ======================================================================
321 324 action = self._get_action(environ)
322 325
323 326 # ======================================================================
324 327 # Check if this is a request to a shadow repository of a pull request.
325 328 # In this case only pull action is allowed.
326 329 # ======================================================================
327 330 if self.is_shadow_repo and action != 'pull':
328 331 reason = 'Only pull action is allowed for shadow repositories.'
329 332 log.debug('User not allowed to proceed, %s', reason)
330 333 return HTTPNotAcceptable(reason)(environ, start_response)
331 334
332 335 # ======================================================================
333 336 # CHECK ANONYMOUS PERMISSION
334 337 # ======================================================================
335 338 if action in ['pull', 'push']:
336 339 anonymous_user = User.get_default_user()
337 340 username = anonymous_user.username
338 341 if anonymous_user.active:
339 342 # ONLY check permissions if the user is activated
340 343 anonymous_perm = self._check_permission(
341 344 action, anonymous_user, self.acl_repo_name, ip_addr)
342 345 else:
343 346 anonymous_perm = False
344 347
345 348 if not anonymous_user.active or not anonymous_perm:
346 349 if not anonymous_user.active:
347 350 log.debug('Anonymous access is disabled, running '
348 351 'authentication')
349 352
350 353 if not anonymous_perm:
351 354 log.debug('Not enough credentials to access this '
352 355 'repository as anonymous user')
353 356
354 357 username = None
355 358 # ==============================================================
356 359 # DEFAULT PERM FAILED OR ANONYMOUS ACCESS IS DISABLED SO WE
357 360 # NEED TO AUTHENTICATE AND ASK FOR AUTH USER PERMISSIONS
358 361 # ==============================================================
359 362
360 363 # try to auth based on environ, container auth methods
361 364 log.debug('Running PRE-AUTH for container based authentication')
362 365 pre_auth = authenticate(
363 366 '', '', environ, VCS_TYPE, registry=self.registry)
364 367 if pre_auth and pre_auth.get('username'):
365 368 username = pre_auth['username']
366 369 log.debug('PRE-AUTH got %s as username', username)
367 370
368 371 # If not authenticated by the container, running basic auth
369 372 if not username:
370 373 self.authenticate.realm = get_rhodecode_realm()
371 374
372 375 try:
373 376 result = self.authenticate(environ)
374 377 except (UserCreationError, NotAllowedToCreateUserError) as e:
375 378 log.error(e)
376 379 reason = safe_str(e)
377 380 return HTTPNotAcceptable(reason)(environ, start_response)
378 381
379 382 if isinstance(result, str):
380 383 AUTH_TYPE.update(environ, 'basic')
381 384 REMOTE_USER.update(environ, result)
382 385 username = result
383 386 else:
384 387 return result.wsgi_application(environ, start_response)
385 388
386 389 # ==============================================================
387 390 # CHECK PERMISSIONS FOR THIS REQUEST USING GIVEN USERNAME
388 391 # ==============================================================
389 392 user = User.get_by_username(username)
390 393 if not self.valid_and_active_user(user):
391 394 return HTTPForbidden()(environ, start_response)
392 395 username = user.username
393 396 user.update_lastactivity()
394 397 meta.Session().commit()
395 398
396 399 # check user attributes for password change flag
397 400 user_obj = user
398 401 if user_obj and user_obj.username != User.DEFAULT_USER and \
399 402 user_obj.user_data.get('force_password_change'):
400 403 reason = 'password change required'
401 404 log.debug('User not allowed to authenticate, %s', reason)
402 405 return HTTPNotAcceptable(reason)(environ, start_response)
403 406
404 407 # check permissions for this repository
405 408 perm = self._check_permission(
406 409 action, user, self.acl_repo_name, ip_addr)
407 410 if not perm:
408 411 return HTTPForbidden()(environ, start_response)
409 412
410 413 # extras are injected into UI object and later available
411 414 # in hooks executed by rhodecode
412 415 check_locking = _should_check_locking(environ.get('QUERY_STRING'))
413 416 extras = vcs_operation_context(
414 417 environ, repo_name=self.acl_repo_name, username=username,
415 418 action=action, scm=self.SCM, check_locking=check_locking,
416 419 is_shadow_repo=self.is_shadow_repo
417 420 )
418 421
419 422 # ======================================================================
420 423 # REQUEST HANDLING
421 424 # ======================================================================
422 425 repo_path = os.path.join(
423 426 safe_str(self.basepath), safe_str(self.vcs_repo_name))
424 427 log.debug('Repository path is %s', repo_path)
425 428
426 429 fix_PATH()
427 430
428 431 log.info(
429 432 '%s action on %s repo "%s" by "%s" from %s',
430 433 action, self.SCM, safe_str(self.url_repo_name),
431 434 safe_str(username), ip_addr)
432 435
433 436 return self._generate_vcs_response(
434 437 environ, start_response, repo_path, extras, action)
435 438
436 439 @initialize_generator
437 440 def _generate_vcs_response(
438 441 self, environ, start_response, repo_path, extras, action):
439 442 """
440 443 Returns a generator for the response content.
441 444
442 445 This method is implemented as a generator, so that it can trigger
443 446 the cache validation after all content sent back to the client. It
444 447 also handles the locking exceptions which will be triggered when
445 448 the first chunk is produced by the underlying WSGI application.
446 449 """
447 450 callback_daemon, extras = self._prepare_callback_daemon(extras)
448 451 config = self._create_config(extras, self.acl_repo_name)
449 452 log.debug('HOOKS extras is %s', extras)
450 453 app = self._create_wsgi_app(repo_path, self.url_repo_name, config)
451 454
452 455 try:
453 456 with callback_daemon:
454 457 try:
455 458 response = app(environ, start_response)
456 459 finally:
457 460 # This statement works together with the decorator
458 461 # "initialize_generator" above. The decorator ensures that
459 462 # we hit the first yield statement before the generator is
460 463 # returned back to the WSGI server. This is needed to
461 464 # ensure that the call to "app" above triggers the
462 465 # needed callback to "start_response" before the
463 466 # generator is actually used.
464 467 yield "__init__"
465 468
466 469 for chunk in response:
467 470 yield chunk
468 471 except Exception as exc:
469 472 # TODO: johbo: Improve "translating" back the exception.
470 473 if getattr(exc, '_vcs_kind', None) == 'repo_locked':
471 474 exc = HTTPLockedRC(*exc.args)
472 475 _code = rhodecode.CONFIG.get('lock_ret_code')
473 476 log.debug('Repository LOCKED ret code %s!', (_code,))
474 477 elif getattr(exc, '_vcs_kind', None) == 'requirement':
475 478 log.debug(
476 479 'Repository requires features unknown to this Mercurial')
477 480 exc = HTTPRequirementError(*exc.args)
478 481 else:
479 482 raise
480 483
481 484 for chunk in exc(environ, start_response):
482 485 yield chunk
483 486 finally:
484 487 # invalidate cache on push
485 488 try:
486 489 if action == 'push':
487 490 self._invalidate_cache(self.url_repo_name)
488 491 finally:
489 492 meta.Session.remove()
490 493
491 494 def _get_repository_name(self, environ):
492 495 """Get repository name out of the environmnent
493 496
494 497 :param environ: WSGI environment
495 498 """
496 499 raise NotImplementedError()
497 500
498 501 def _get_action(self, environ):
499 502 """Map request commands into a pull or push command.
500 503
501 504 :param environ: WSGI environment
502 505 """
503 506 raise NotImplementedError()
504 507
505 508 def _create_wsgi_app(self, repo_path, repo_name, config):
506 509 """Return the WSGI app that will finally handle the request."""
507 510 raise NotImplementedError()
508 511
509 512 def _create_config(self, extras, repo_name):
510 513 """Create a Pyro safe config representation."""
511 514 raise NotImplementedError()
512 515
513 516 def _prepare_callback_daemon(self, extras):
514 517 return prepare_callback_daemon(
515 518 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
516 519 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
517 520
518 521
519 522 def _should_check_locking(query_string):
520 523 # this is kind of hacky, but due to how mercurial handles client-server
521 524 # server see all operation on commit; bookmarks, phases and
522 525 # obsolescence marker in different transaction, we don't want to check
523 526 # locking on those
524 527 return query_string not in ['cmd=listkeys']
General Comments 0
You need to be logged in to leave comments. Login now