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