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