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