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