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