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