##// END OF EJS Templates
security: update lastactivity when on audit logs....
marcink -
r2930:a5198975 default
parent child Browse files
Show More

The requested changes are too big and content was truncated. Show full diff

@@ -1,542 +1,543 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2018 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 import inspect
22 22 import itertools
23 23 import logging
24 24 import types
25 25 import fnmatch
26 26
27 27 import decorator
28 28 import venusian
29 29 from collections import OrderedDict
30 30
31 31 from pyramid.exceptions import ConfigurationError
32 32 from pyramid.renderers import render
33 33 from pyramid.response import Response
34 34 from pyramid.httpexceptions import HTTPNotFound
35 35
36 36 from rhodecode.api.exc import (
37 37 JSONRPCBaseError, JSONRPCError, JSONRPCForbidden, JSONRPCValidationError)
38 38 from rhodecode.apps._base import TemplateArgs
39 39 from rhodecode.lib.auth import AuthUser
40 40 from rhodecode.lib.base import get_ip_addr, attach_context_attributes
41 41 from rhodecode.lib.ext_json import json
42 42 from rhodecode.lib.utils2 import safe_str
43 43 from rhodecode.lib.plugins.utils import get_plugin_settings
44 44 from rhodecode.model.db import User, UserApiKeys
45 45
46 46 log = logging.getLogger(__name__)
47 47
48 48 DEFAULT_RENDERER = 'jsonrpc_renderer'
49 49 DEFAULT_URL = '/_admin/apiv2'
50 50
51 51
52 52 def find_methods(jsonrpc_methods, pattern):
53 53 matches = OrderedDict()
54 54 if not isinstance(pattern, (list, tuple)):
55 55 pattern = [pattern]
56 56
57 57 for single_pattern in pattern:
58 58 for method_name, method in jsonrpc_methods.items():
59 59 if fnmatch.fnmatch(method_name, single_pattern):
60 60 matches[method_name] = method
61 61 return matches
62 62
63 63
64 64 class ExtJsonRenderer(object):
65 65 """
66 66 Custom renderer that mkaes use of our ext_json lib
67 67
68 68 """
69 69
70 70 def __init__(self, serializer=json.dumps, **kw):
71 71 """ Any keyword arguments will be passed to the ``serializer``
72 72 function."""
73 73 self.serializer = serializer
74 74 self.kw = kw
75 75
76 76 def __call__(self, info):
77 77 """ Returns a plain JSON-encoded string with content-type
78 78 ``application/json``. The content-type may be overridden by
79 79 setting ``request.response.content_type``."""
80 80
81 81 def _render(value, system):
82 82 request = system.get('request')
83 83 if request is not None:
84 84 response = request.response
85 85 ct = response.content_type
86 86 if ct == response.default_content_type:
87 87 response.content_type = 'application/json'
88 88
89 89 return self.serializer(value, **self.kw)
90 90
91 91 return _render
92 92
93 93
94 94 def jsonrpc_response(request, result):
95 95 rpc_id = getattr(request, 'rpc_id', None)
96 96 response = request.response
97 97
98 98 # store content_type before render is called
99 99 ct = response.content_type
100 100
101 101 ret_value = ''
102 102 if rpc_id:
103 103 ret_value = {
104 104 'id': rpc_id,
105 105 'result': result,
106 106 'error': None,
107 107 }
108 108
109 109 # fetch deprecation warnings, and store it inside results
110 110 deprecation = getattr(request, 'rpc_deprecation', None)
111 111 if deprecation:
112 112 ret_value['DEPRECATION_WARNING'] = deprecation
113 113
114 114 raw_body = render(DEFAULT_RENDERER, ret_value, request=request)
115 115 response.body = safe_str(raw_body, response.charset)
116 116
117 117 if ct == response.default_content_type:
118 118 response.content_type = 'application/json'
119 119
120 120 return response
121 121
122 122
123 123 def jsonrpc_error(request, message, retid=None, code=None):
124 124 """
125 125 Generate a Response object with a JSON-RPC error body
126 126
127 127 :param code:
128 128 :param retid:
129 129 :param message:
130 130 """
131 131 err_dict = {'id': retid, 'result': None, 'error': message}
132 132 body = render(DEFAULT_RENDERER, err_dict, request=request).encode('utf-8')
133 133 return Response(
134 134 body=body,
135 135 status=code,
136 136 content_type='application/json'
137 137 )
138 138
139 139
140 140 def exception_view(exc, request):
141 141 rpc_id = getattr(request, 'rpc_id', None)
142 142
143 143 fault_message = 'undefined error'
144 144 if isinstance(exc, JSONRPCError):
145 145 fault_message = exc.message
146 146 log.debug('json-rpc error rpc_id:%s "%s"', rpc_id, fault_message)
147 147 elif isinstance(exc, JSONRPCValidationError):
148 148 colander_exc = exc.colander_exception
149 149 # TODO(marcink): think maybe of nicer way to serialize errors ?
150 150 fault_message = colander_exc.asdict()
151 151 log.debug('json-rpc error rpc_id:%s "%s"', rpc_id, fault_message)
152 152 elif isinstance(exc, JSONRPCForbidden):
153 153 fault_message = 'Access was denied to this resource.'
154 154 log.warning('json-rpc forbidden call rpc_id:%s "%s"', rpc_id, fault_message)
155 155 elif isinstance(exc, HTTPNotFound):
156 156 method = request.rpc_method
157 157 log.debug('json-rpc method `%s` not found in list of '
158 158 'api calls: %s, rpc_id:%s',
159 159 method, request.registry.jsonrpc_methods.keys(), rpc_id)
160 160
161 161 similar = 'none'
162 162 try:
163 163 similar_paterns = ['*{}*'.format(x) for x in method.split('_')]
164 164 similar_found = find_methods(
165 165 request.registry.jsonrpc_methods, similar_paterns)
166 166 similar = ', '.join(similar_found.keys()) or similar
167 167 except Exception:
168 168 # make the whole above block safe
169 169 pass
170 170
171 171 fault_message = "No such method: {}. Similar methods: {}".format(
172 172 method, similar)
173 173
174 174 return jsonrpc_error(request, fault_message, rpc_id)
175 175
176 176
177 177 def request_view(request):
178 178 """
179 179 Main request handling method. It handles all logic to call a specific
180 180 exposed method
181 181 """
182 182
183 183 # check if we can find this session using api_key, get_by_auth_token
184 184 # search not expired tokens only
185 185
186 186 try:
187 187 api_user = User.get_by_auth_token(request.rpc_api_key)
188 188
189 189 if api_user is None:
190 190 return jsonrpc_error(
191 191 request, retid=request.rpc_id, message='Invalid API KEY')
192 192
193 193 if not api_user.active:
194 194 return jsonrpc_error(
195 195 request, retid=request.rpc_id,
196 196 message='Request from this user not allowed')
197 197
198 198 # check if we are allowed to use this IP
199 199 auth_u = AuthUser(
200 200 api_user.user_id, request.rpc_api_key, ip_addr=request.rpc_ip_addr)
201 201 if not auth_u.ip_allowed:
202 202 return jsonrpc_error(
203 203 request, retid=request.rpc_id,
204 204 message='Request from IP:%s not allowed' % (
205 205 request.rpc_ip_addr,))
206 206 else:
207 207 log.info('Access for IP:%s allowed' % (request.rpc_ip_addr,))
208 208
209 209 # register our auth-user
210 210 request.rpc_user = auth_u
211 request.environ['rc_auth_user_id'] = auth_u.user_id
211 212
212 213 # now check if token is valid for API
213 214 auth_token = request.rpc_api_key
214 215 token_match = api_user.authenticate_by_token(
215 216 auth_token, roles=[UserApiKeys.ROLE_API])
216 217 invalid_token = not token_match
217 218
218 219 log.debug('Checking if API KEY is valid with proper role')
219 220 if invalid_token:
220 221 return jsonrpc_error(
221 222 request, retid=request.rpc_id,
222 223 message='API KEY invalid or, has bad role for an API call')
223 224
224 225 except Exception:
225 226 log.exception('Error on API AUTH')
226 227 return jsonrpc_error(
227 228 request, retid=request.rpc_id, message='Invalid API KEY')
228 229
229 230 method = request.rpc_method
230 231 func = request.registry.jsonrpc_methods[method]
231 232
232 233 # now that we have a method, add request._req_params to
233 234 # self.kargs and dispatch control to WGIController
234 235 argspec = inspect.getargspec(func)
235 236 arglist = argspec[0]
236 237 defaults = map(type, argspec[3] or [])
237 238 default_empty = types.NotImplementedType
238 239
239 240 # kw arguments required by this method
240 241 func_kwargs = dict(itertools.izip_longest(
241 242 reversed(arglist), reversed(defaults), fillvalue=default_empty))
242 243
243 244 # This attribute will need to be first param of a method that uses
244 245 # api_key, which is translated to instance of user at that name
245 246 user_var = 'apiuser'
246 247 request_var = 'request'
247 248
248 249 for arg in [user_var, request_var]:
249 250 if arg not in arglist:
250 251 return jsonrpc_error(
251 252 request,
252 253 retid=request.rpc_id,
253 254 message='This method [%s] does not support '
254 255 'required parameter `%s`' % (func.__name__, arg))
255 256
256 257 # get our arglist and check if we provided them as args
257 258 for arg, default in func_kwargs.items():
258 259 if arg in [user_var, request_var]:
259 260 # user_var and request_var are pre-hardcoded parameters and we
260 261 # don't need to do any translation
261 262 continue
262 263
263 264 # skip the required param check if it's default value is
264 265 # NotImplementedType (default_empty)
265 266 if default == default_empty and arg not in request.rpc_params:
266 267 return jsonrpc_error(
267 268 request,
268 269 retid=request.rpc_id,
269 270 message=('Missing non optional `%s` arg in JSON DATA' % arg)
270 271 )
271 272
272 273 # sanitize extra passed arguments
273 274 for k in request.rpc_params.keys()[:]:
274 275 if k not in func_kwargs:
275 276 del request.rpc_params[k]
276 277
277 278 call_params = request.rpc_params
278 279 call_params.update({
279 280 'request': request,
280 281 'apiuser': auth_u
281 282 })
282 283
283 284 # register some common functions for usage
284 285 attach_context_attributes(
285 286 TemplateArgs(), request, request.rpc_user.user_id)
286 287
287 288 try:
288 289 ret_value = func(**call_params)
289 290 return jsonrpc_response(request, ret_value)
290 291 except JSONRPCBaseError:
291 292 raise
292 293 except Exception:
293 294 log.exception('Unhandled exception occurred on api call: %s', func)
294 295 return jsonrpc_error(request, retid=request.rpc_id,
295 296 message='Internal server error')
296 297
297 298
298 299 def setup_request(request):
299 300 """
300 301 Parse a JSON-RPC request body. It's used inside the predicates method
301 302 to validate and bootstrap requests for usage in rpc calls.
302 303
303 304 We need to raise JSONRPCError here if we want to return some errors back to
304 305 user.
305 306 """
306 307
307 308 log.debug('Executing setup request: %r', request)
308 309 request.rpc_ip_addr = get_ip_addr(request.environ)
309 310 # TODO(marcink): deprecate GET at some point
310 311 if request.method not in ['POST', 'GET']:
311 312 log.debug('unsupported request method "%s"', request.method)
312 313 raise JSONRPCError(
313 314 'unsupported request method "%s". Please use POST' % request.method)
314 315
315 316 if 'CONTENT_LENGTH' not in request.environ:
316 317 log.debug("No Content-Length")
317 318 raise JSONRPCError("Empty body, No Content-Length in request")
318 319
319 320 else:
320 321 length = request.environ['CONTENT_LENGTH']
321 322 log.debug('Content-Length: %s', length)
322 323
323 324 if length == 0:
324 325 log.debug("Content-Length is 0")
325 326 raise JSONRPCError("Content-Length is 0")
326 327
327 328 raw_body = request.body
328 329 try:
329 330 json_body = json.loads(raw_body)
330 331 except ValueError as e:
331 332 # catch JSON errors Here
332 333 raise JSONRPCError("JSON parse error ERR:%s RAW:%r" % (e, raw_body))
333 334
334 335 request.rpc_id = json_body.get('id')
335 336 request.rpc_method = json_body.get('method')
336 337
337 338 # check required base parameters
338 339 try:
339 340 api_key = json_body.get('api_key')
340 341 if not api_key:
341 342 api_key = json_body.get('auth_token')
342 343
343 344 if not api_key:
344 345 raise KeyError('api_key or auth_token')
345 346
346 347 # TODO(marcink): support passing in token in request header
347 348
348 349 request.rpc_api_key = api_key
349 350 request.rpc_id = json_body['id']
350 351 request.rpc_method = json_body['method']
351 352 request.rpc_params = json_body['args'] \
352 353 if isinstance(json_body['args'], dict) else {}
353 354
354 355 log.debug(
355 356 'method: %s, params: %s' % (request.rpc_method, request.rpc_params))
356 357 except KeyError as e:
357 358 raise JSONRPCError('Incorrect JSON data. Missing %s' % e)
358 359
359 360 log.debug('setup complete, now handling method:%s rpcid:%s',
360 361 request.rpc_method, request.rpc_id, )
361 362
362 363
363 364 class RoutePredicate(object):
364 365 def __init__(self, val, config):
365 366 self.val = val
366 367
367 368 def text(self):
368 369 return 'jsonrpc route = %s' % self.val
369 370
370 371 phash = text
371 372
372 373 def __call__(self, info, request):
373 374 if self.val:
374 375 # potentially setup and bootstrap our call
375 376 setup_request(request)
376 377
377 378 # Always return True so that even if it isn't a valid RPC it
378 379 # will fall through to the underlaying handlers like notfound_view
379 380 return True
380 381
381 382
382 383 class NotFoundPredicate(object):
383 384 def __init__(self, val, config):
384 385 self.val = val
385 386 self.methods = config.registry.jsonrpc_methods
386 387
387 388 def text(self):
388 389 return 'jsonrpc method not found = {}.'.format(self.val)
389 390
390 391 phash = text
391 392
392 393 def __call__(self, info, request):
393 394 return hasattr(request, 'rpc_method')
394 395
395 396
396 397 class MethodPredicate(object):
397 398 def __init__(self, val, config):
398 399 self.method = val
399 400
400 401 def text(self):
401 402 return 'jsonrpc method = %s' % self.method
402 403
403 404 phash = text
404 405
405 406 def __call__(self, context, request):
406 407 # we need to explicitly return False here, so pyramid doesn't try to
407 408 # execute our view directly. We need our main handler to execute things
408 409 return getattr(request, 'rpc_method') == self.method
409 410
410 411
411 412 def add_jsonrpc_method(config, view, **kwargs):
412 413 # pop the method name
413 414 method = kwargs.pop('method', None)
414 415
415 416 if method is None:
416 417 raise ConfigurationError(
417 418 'Cannot register a JSON-RPC method without specifying the '
418 419 '"method"')
419 420
420 421 # we define custom predicate, to enable to detect conflicting methods,
421 422 # those predicates are kind of "translation" from the decorator variables
422 423 # to internal predicates names
423 424
424 425 kwargs['jsonrpc_method'] = method
425 426
426 427 # register our view into global view store for validation
427 428 config.registry.jsonrpc_methods[method] = view
428 429
429 430 # we're using our main request_view handler, here, so each method
430 431 # has a unified handler for itself
431 432 config.add_view(request_view, route_name='apiv2', **kwargs)
432 433
433 434
434 435 class jsonrpc_method(object):
435 436 """
436 437 decorator that works similar to @add_view_config decorator,
437 438 but tailored for our JSON RPC
438 439 """
439 440
440 441 venusian = venusian # for testing injection
441 442
442 443 def __init__(self, method=None, **kwargs):
443 444 self.method = method
444 445 self.kwargs = kwargs
445 446
446 447 def __call__(self, wrapped):
447 448 kwargs = self.kwargs.copy()
448 449 kwargs['method'] = self.method or wrapped.__name__
449 450 depth = kwargs.pop('_depth', 0)
450 451
451 452 def callback(context, name, ob):
452 453 config = context.config.with_package(info.module)
453 454 config.add_jsonrpc_method(view=ob, **kwargs)
454 455
455 456 info = venusian.attach(wrapped, callback, category='pyramid',
456 457 depth=depth + 1)
457 458 if info.scope == 'class':
458 459 # ensure that attr is set if decorating a class method
459 460 kwargs.setdefault('attr', wrapped.__name__)
460 461
461 462 kwargs['_info'] = info.codeinfo # fbo action_method
462 463 return wrapped
463 464
464 465
465 466 class jsonrpc_deprecated_method(object):
466 467 """
467 468 Marks method as deprecated, adds log.warning, and inject special key to
468 469 the request variable to mark method as deprecated.
469 470 Also injects special docstring that extract_docs will catch to mark
470 471 method as deprecated.
471 472
472 473 :param use_method: specify which method should be used instead of
473 474 the decorated one
474 475
475 476 Use like::
476 477
477 478 @jsonrpc_method()
478 479 @jsonrpc_deprecated_method(use_method='new_func', deprecated_at_version='3.0.0')
479 480 def old_func(request, apiuser, arg1, arg2):
480 481 ...
481 482 """
482 483
483 484 def __init__(self, use_method, deprecated_at_version):
484 485 self.use_method = use_method
485 486 self.deprecated_at_version = deprecated_at_version
486 487 self.deprecated_msg = ''
487 488
488 489 def __call__(self, func):
489 490 self.deprecated_msg = 'Please use method `{method}` instead.'.format(
490 491 method=self.use_method)
491 492
492 493 docstring = """\n
493 494 .. deprecated:: {version}
494 495
495 496 {deprecation_message}
496 497
497 498 {original_docstring}
498 499 """
499 500 func.__doc__ = docstring.format(
500 501 version=self.deprecated_at_version,
501 502 deprecation_message=self.deprecated_msg,
502 503 original_docstring=func.__doc__)
503 504 return decorator.decorator(self.__wrapper, func)
504 505
505 506 def __wrapper(self, func, *fargs, **fkwargs):
506 507 log.warning('DEPRECATED API CALL on function %s, please '
507 508 'use `%s` instead', func, self.use_method)
508 509 # alter function docstring to mark as deprecated, this is picked up
509 510 # via fabric file that generates API DOC.
510 511 result = func(*fargs, **fkwargs)
511 512
512 513 request = fargs[0]
513 514 request.rpc_deprecation = 'DEPRECATED METHOD ' + self.deprecated_msg
514 515 return result
515 516
516 517
517 518 def includeme(config):
518 519 plugin_module = 'rhodecode.api'
519 520 plugin_settings = get_plugin_settings(
520 521 plugin_module, config.registry.settings)
521 522
522 523 if not hasattr(config.registry, 'jsonrpc_methods'):
523 524 config.registry.jsonrpc_methods = OrderedDict()
524 525
525 526 # match filter by given method only
526 527 config.add_view_predicate('jsonrpc_method', MethodPredicate)
527 528
528 529 config.add_renderer(DEFAULT_RENDERER, ExtJsonRenderer(
529 530 serializer=json.dumps, indent=4))
530 531 config.add_directive('add_jsonrpc_method', add_jsonrpc_method)
531 532
532 533 config.add_route_predicate(
533 534 'jsonrpc_call', RoutePredicate)
534 535
535 536 config.add_route(
536 537 'apiv2', plugin_settings.get('url', DEFAULT_URL), jsonrpc_call=True)
537 538
538 539 config.scan(plugin_module, ignore='rhodecode.api.tests')
539 540 # register some exception handling view
540 541 config.add_view(exception_view, context=JSONRPCBaseError)
541 542 config.add_view_predicate('jsonrpc_method_not_found', NotFoundPredicate)
542 543 config.add_notfound_view(exception_view, jsonrpc_method_not_found=True)
@@ -1,116 +1,120 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2018 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 import mock
22 22 import pytest
23 23
24 24 from rhodecode.model.db import User
25 25 from rhodecode.model.user import UserModel
26 26 from rhodecode.tests import TEST_USER_ADMIN_LOGIN
27 27 from rhodecode.api.tests.utils import (
28 28 build_data, api_call, assert_ok, assert_error, crash, jsonify)
29 29
30 30
31 31 @pytest.mark.usefixtures("testuser_api", "app")
32 32 class TestUpdateUser(object):
33 33 @pytest.mark.parametrize("name, expected", [
34 34 ('firstname', 'new_username'),
35 35 ('lastname', 'new_username'),
36 36 ('email', 'new_username'),
37 37 ('admin', True),
38 38 ('admin', False),
39 39 ('extern_type', 'ldap'),
40 40 ('extern_type', None),
41 41 ('extern_name', 'test'),
42 42 ('extern_name', None),
43 43 ('active', False),
44 44 ('active', True),
45 45 ('password', 'newpass')
46 46 ])
47 47 def test_api_update_user(self, name, expected, user_util):
48 48 usr = user_util.create_user()
49 49
50 50 kw = {name: expected, 'userid': usr.user_id}
51 51 id_, params = build_data(self.apikey, 'update_user', **kw)
52 52 response = api_call(self.app, params)
53 53
54 54 ret = {
55 55 'msg': 'updated user ID:%s %s' % (usr.user_id, usr.username),
56 56 'user': jsonify(
57 57 UserModel()
58 58 .get_by_username(usr.username)
59 59 .get_api_data(include_secrets=True)
60 60 )
61 61 }
62 62
63 63 expected = ret
64 64 assert_ok(id_, expected, given=response.body)
65 65
66 66 def test_api_update_user_no_changed_params(self):
67 67 usr = UserModel().get_by_username(TEST_USER_ADMIN_LOGIN)
68 68 ret = jsonify(usr.get_api_data(include_secrets=True))
69 69 id_, params = build_data(
70 70 self.apikey, 'update_user', userid=TEST_USER_ADMIN_LOGIN)
71 71
72 72 response = api_call(self.app, params)
73 73 ret = {
74 74 'msg': 'updated user ID:%s %s' % (
75 75 usr.user_id, TEST_USER_ADMIN_LOGIN),
76 76 'user': ret
77 77 }
78 78 expected = ret
79 expected['user']['last_activity'] = response.json['result']['user'][
80 'last_activity']
79 81 assert_ok(id_, expected, given=response.body)
80 82
81 83 def test_api_update_user_by_user_id(self):
82 84 usr = UserModel().get_by_username(TEST_USER_ADMIN_LOGIN)
83 85 ret = jsonify(usr.get_api_data(include_secrets=True))
84 86 id_, params = build_data(
85 87 self.apikey, 'update_user', userid=usr.user_id)
86 88
87 89 response = api_call(self.app, params)
88 90 ret = {
89 91 'msg': 'updated user ID:%s %s' % (
90 92 usr.user_id, TEST_USER_ADMIN_LOGIN),
91 93 'user': ret
92 94 }
93 95 expected = ret
96 expected['user']['last_activity'] = response.json['result']['user'][
97 'last_activity']
94 98 assert_ok(id_, expected, given=response.body)
95 99
96 100 def test_api_update_user_default_user(self):
97 101 usr = User.get_default_user()
98 102 id_, params = build_data(
99 103 self.apikey, 'update_user', userid=usr.user_id)
100 104
101 105 response = api_call(self.app, params)
102 106 expected = 'editing default user is forbidden'
103 107 assert_error(id_, expected, given=response.body)
104 108
105 109 @mock.patch.object(UserModel, 'update_user', crash)
106 110 def test_api_update_user_when_exception_happens(self):
107 111 usr = UserModel().get_by_username(TEST_USER_ADMIN_LOGIN)
108 112 ret = jsonify(usr.get_api_data(include_secrets=True))
109 113 id_, params = build_data(
110 114 self.apikey, 'update_user', userid=usr.user_id)
111 115
112 116 response = api_call(self.app, params)
113 117 ret = 'failed to update user `%s`' % (usr.user_id,)
114 118
115 119 expected = ret
116 120 assert_error(id_, expected, given=response.body)
@@ -1,518 +1,522 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2018 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 import os
22 22 import sys
23 23 import logging
24 24 import collections
25 25 import tempfile
26 26
27 27 from paste.gzipper import make_gzip_middleware
28 import pyramid.events
28 29 from pyramid.wsgi import wsgiapp
29 30 from pyramid.authorization import ACLAuthorizationPolicy
30 31 from pyramid.config import Configurator
31 32 from pyramid.settings import asbool, aslist
32 33 from pyramid.httpexceptions import (
33 34 HTTPException, HTTPError, HTTPInternalServerError, HTTPFound, HTTPNotFound)
34 from pyramid.events import ApplicationCreated
35 35 from pyramid.renderers import render_to_response
36 36
37 37 from rhodecode.model import meta
38 38 from rhodecode.config import patches
39 39 from rhodecode.config import utils as config_utils
40 40 from rhodecode.config.environment import load_pyramid_environment
41 41
42 import rhodecode.events
42 43 from rhodecode.lib.middleware.vcs import VCSMiddleware
43 44 from rhodecode.lib.request import Request
44 45 from rhodecode.lib.vcs import VCSCommunicationError
45 46 from rhodecode.lib.exceptions import VCSServerUnavailable
46 47 from rhodecode.lib.middleware.appenlight import wrap_in_appenlight_if_enabled
47 48 from rhodecode.lib.middleware.https_fixup import HttpsFixup
48 49 from rhodecode.lib.celerylib.loader import configure_celery
49 50 from rhodecode.lib.plugins.utils import register_rhodecode_plugin
50 51 from rhodecode.lib.utils2 import aslist as rhodecode_aslist, AttributeDict
51 52 from rhodecode.lib.exc_tracking import store_exception
52 53 from rhodecode.subscribers import (
53 54 scan_repositories_if_enabled, write_js_routes_if_enabled,
54 55 write_metadata_if_needed, inject_app_settings)
55 56
56 57
57 58 log = logging.getLogger(__name__)
58 59
59 60
60 61 def is_http_error(response):
61 62 # error which should have traceback
62 63 return response.status_code > 499
63 64
64 65
65 66 def make_pyramid_app(global_config, **settings):
66 67 """
67 68 Constructs the WSGI application based on Pyramid.
68 69
69 70 Specials:
70 71
71 72 * The application can also be integrated like a plugin via the call to
72 73 `includeme`. This is accompanied with the other utility functions which
73 74 are called. Changing this should be done with great care to not break
74 75 cases when these fragments are assembled from another place.
75 76
76 77 """
77 78
78 79 # Allows to use format style "{ENV_NAME}" placeholders in the configuration. It
79 80 # will be replaced by the value of the environment variable "NAME" in this case.
80 81 environ = {
81 82 'ENV_{}'.format(key): value for key, value in os.environ.items()}
82 83
83 84 global_config = _substitute_values(global_config, environ)
84 85 settings = _substitute_values(settings, environ)
85 86
86 87 sanitize_settings_and_apply_defaults(settings)
87 88
88 89 config = Configurator(settings=settings)
89 90
90 91 # Apply compatibility patches
91 92 patches.inspect_getargspec()
92 93
93 94 load_pyramid_environment(global_config, settings)
94 95
95 96 # Static file view comes first
96 97 includeme_first(config)
97 98
98 99 includeme(config)
99 100
100 101 pyramid_app = config.make_wsgi_app()
101 102 pyramid_app = wrap_app_in_wsgi_middlewares(pyramid_app, config)
102 103 pyramid_app.config = config
103 104
104 105 config.configure_celery(global_config['__file__'])
105 106 # creating the app uses a connection - return it after we are done
106 107 meta.Session.remove()
107 108
108 109 log.info('Pyramid app %s created and configured.', pyramid_app)
109 110 return pyramid_app
110 111
111 112
112 113 def not_found_view(request):
113 114 """
114 115 This creates the view which should be registered as not-found-view to
115 116 pyramid.
116 117 """
117 118
118 119 if not getattr(request, 'vcs_call', None):
119 120 # handle like regular case with our error_handler
120 121 return error_handler(HTTPNotFound(), request)
121 122
122 123 # handle not found view as a vcs call
123 124 settings = request.registry.settings
124 125 ae_client = getattr(request, 'ae_client', None)
125 126 vcs_app = VCSMiddleware(
126 127 HTTPNotFound(), request.registry, settings,
127 128 appenlight_client=ae_client)
128 129
129 130 return wsgiapp(vcs_app)(None, request)
130 131
131 132
132 133 def error_handler(exception, request):
133 134 import rhodecode
134 135 from rhodecode.lib import helpers
135 136
136 137 rhodecode_title = rhodecode.CONFIG.get('rhodecode_title') or 'RhodeCode'
137 138
138 139 base_response = HTTPInternalServerError()
139 140 # prefer original exception for the response since it may have headers set
140 141 if isinstance(exception, HTTPException):
141 142 base_response = exception
142 143 elif isinstance(exception, VCSCommunicationError):
143 144 base_response = VCSServerUnavailable()
144 145
145 146 if is_http_error(base_response):
146 147 log.exception(
147 148 'error occurred handling this request for path: %s', request.path)
148 149
149 150 error_explanation = base_response.explanation or str(base_response)
150 151 if base_response.status_code == 404:
151 152 error_explanation += " Or you don't have permission to access it."
152 153 c = AttributeDict()
153 154 c.error_message = base_response.status
154 155 c.error_explanation = error_explanation
155 156 c.visual = AttributeDict()
156 157
157 158 c.visual.rhodecode_support_url = (
158 159 request.registry.settings.get('rhodecode_support_url') or
159 160 request.route_url('rhodecode_support')
160 161 )
161 162 c.redirect_time = 0
162 163 c.rhodecode_name = rhodecode_title
163 164 if not c.rhodecode_name:
164 165 c.rhodecode_name = 'Rhodecode'
165 166
166 167 c.causes = []
167 168 if is_http_error(base_response):
168 169 c.causes.append('Server is overloaded.')
169 170 c.causes.append('Server database connection is lost.')
170 171 c.causes.append('Server expected unhandled error.')
171 172
172 173 if hasattr(base_response, 'causes'):
173 174 c.causes = base_response.causes
174 175
175 176 c.messages = helpers.flash.pop_messages(request=request)
176 177
177 178 exc_info = sys.exc_info()
178 179 c.exception_id = id(exc_info)
179 180 c.show_exception_id = isinstance(base_response, VCSServerUnavailable) \
180 181 or base_response.status_code > 499
181 182 c.exception_id_url = request.route_url(
182 183 'admin_settings_exception_tracker_show', exception_id=c.exception_id)
183 184
184 185 if c.show_exception_id:
185 186 store_exception(c.exception_id, exc_info)
186 187
187 188 response = render_to_response(
188 189 '/errors/error_document.mako', {'c': c, 'h': helpers}, request=request,
189 190 response=base_response)
190 191
191 192 return response
192 193
193 194
194 195 def includeme_first(config):
195 196 # redirect automatic browser favicon.ico requests to correct place
196 197 def favicon_redirect(context, request):
197 198 return HTTPFound(
198 199 request.static_path('rhodecode:public/images/favicon.ico'))
199 200
200 201 config.add_view(favicon_redirect, route_name='favicon')
201 202 config.add_route('favicon', '/favicon.ico')
202 203
203 204 def robots_redirect(context, request):
204 205 return HTTPFound(
205 206 request.static_path('rhodecode:public/robots.txt'))
206 207
207 208 config.add_view(robots_redirect, route_name='robots')
208 209 config.add_route('robots', '/robots.txt')
209 210
210 211 config.add_static_view(
211 212 '_static/deform', 'deform:static')
212 213 config.add_static_view(
213 214 '_static/rhodecode', path='rhodecode:public', cache_max_age=3600 * 24)
214 215
215 216
216 217 def includeme(config):
217 218 settings = config.registry.settings
218 219 config.set_request_factory(Request)
219 220
220 221 # plugin information
221 222 config.registry.rhodecode_plugins = collections.OrderedDict()
222 223
223 224 config.add_directive(
224 225 'register_rhodecode_plugin', register_rhodecode_plugin)
225 226
226 227 config.add_directive('configure_celery', configure_celery)
227 228
228 229 if asbool(settings.get('appenlight', 'false')):
229 230 config.include('appenlight_client.ext.pyramid_tween')
230 231
231 232 # Includes which are required. The application would fail without them.
232 233 config.include('pyramid_mako')
233 234 config.include('pyramid_beaker')
234 235 config.include('rhodecode.lib.caches')
235 236 config.include('rhodecode.lib.rc_cache')
236 237
237 238 config.include('rhodecode.authentication')
238 239 config.include('rhodecode.integrations')
239 240
240 241 # apps
241 242 config.include('rhodecode.apps._base')
242 243 config.include('rhodecode.apps.ops')
243 244
244 245 config.include('rhodecode.apps.admin')
245 246 config.include('rhodecode.apps.channelstream')
246 247 config.include('rhodecode.apps.login')
247 248 config.include('rhodecode.apps.home')
248 249 config.include('rhodecode.apps.journal')
249 250 config.include('rhodecode.apps.repository')
250 251 config.include('rhodecode.apps.repo_group')
251 252 config.include('rhodecode.apps.user_group')
252 253 config.include('rhodecode.apps.search')
253 254 config.include('rhodecode.apps.user_profile')
254 255 config.include('rhodecode.apps.user_group_profile')
255 256 config.include('rhodecode.apps.my_account')
256 257 config.include('rhodecode.apps.svn_support')
257 258 config.include('rhodecode.apps.ssh_support')
258 259 config.include('rhodecode.apps.gist')
259 260
260 261 config.include('rhodecode.apps.debug_style')
261 262 config.include('rhodecode.tweens')
262 263 config.include('rhodecode.api')
263 264
264 265 config.add_route(
265 266 'rhodecode_support', 'https://rhodecode.com/help/', static=True)
266 267
267 268 config.add_translation_dirs('rhodecode:i18n/')
268 269 settings['default_locale_name'] = settings.get('lang', 'en')
269 270
270 271 # Add subscribers.
271 config.add_subscriber(inject_app_settings, ApplicationCreated)
272 config.add_subscriber(scan_repositories_if_enabled, ApplicationCreated)
273 config.add_subscriber(write_metadata_if_needed, ApplicationCreated)
274 config.add_subscriber(write_js_routes_if_enabled, ApplicationCreated)
275
272 config.add_subscriber(inject_app_settings,
273 pyramid.events.ApplicationCreated)
274 config.add_subscriber(scan_repositories_if_enabled,
275 pyramid.events.ApplicationCreated)
276 config.add_subscriber(write_metadata_if_needed,
277 pyramid.events.ApplicationCreated)
278 config.add_subscriber(write_js_routes_if_enabled,
279 pyramid.events.ApplicationCreated)
276 280
277 281 # request custom methods
278 282 config.add_request_method(
279 283 'rhodecode.lib.partial_renderer.get_partial_renderer',
280 284 'get_partial_renderer')
281 285
282 286 # Set the authorization policy.
283 287 authz_policy = ACLAuthorizationPolicy()
284 288 config.set_authorization_policy(authz_policy)
285 289
286 290 # Set the default renderer for HTML templates to mako.
287 291 config.add_mako_renderer('.html')
288 292
289 293 config.add_renderer(
290 294 name='json_ext',
291 295 factory='rhodecode.lib.ext_json_renderer.pyramid_ext_json')
292 296
293 297 # include RhodeCode plugins
294 298 includes = aslist(settings.get('rhodecode.includes', []))
295 299 for inc in includes:
296 300 config.include(inc)
297 301
298 302 # custom not found view, if our pyramid app doesn't know how to handle
299 303 # the request pass it to potential VCS handling ap
300 304 config.add_notfound_view(not_found_view)
301 305 if not settings.get('debugtoolbar.enabled', False):
302 306 # disabled debugtoolbar handle all exceptions via the error_handlers
303 307 config.add_view(error_handler, context=Exception)
304 308
305 309 # all errors including 403/404/50X
306 310 config.add_view(error_handler, context=HTTPError)
307 311
308 312
309 313 def wrap_app_in_wsgi_middlewares(pyramid_app, config):
310 314 """
311 315 Apply outer WSGI middlewares around the application.
312 316 """
313 317 registry = config.registry
314 318 settings = registry.settings
315 319
316 320 # enable https redirects based on HTTP_X_URL_SCHEME set by proxy
317 321 pyramid_app = HttpsFixup(pyramid_app, settings)
318 322
319 323 pyramid_app, _ae_client = wrap_in_appenlight_if_enabled(
320 324 pyramid_app, settings)
321 325 registry.ae_client = _ae_client
322 326
323 327 if settings['gzip_responses']:
324 328 pyramid_app = make_gzip_middleware(
325 329 pyramid_app, settings, compress_level=1)
326 330
327 331 # this should be the outer most middleware in the wsgi stack since
328 332 # middleware like Routes make database calls
329 333 def pyramid_app_with_cleanup(environ, start_response):
330 334 try:
331 335 return pyramid_app(environ, start_response)
332 336 finally:
333 337 # Dispose current database session and rollback uncommitted
334 338 # transactions.
335 339 meta.Session.remove()
336 340
337 341 # In a single threaded mode server, on non sqlite db we should have
338 342 # '0 Current Checked out connections' at the end of a request,
339 343 # if not, then something, somewhere is leaving a connection open
340 344 pool = meta.Base.metadata.bind.engine.pool
341 345 log.debug('sa pool status: %s', pool.status())
342 346 log.debug('Request processing finalized')
343 347
344 348 return pyramid_app_with_cleanup
345 349
346 350
347 351 def sanitize_settings_and_apply_defaults(settings):
348 352 """
349 353 Applies settings defaults and does all type conversion.
350 354
351 355 We would move all settings parsing and preparation into this place, so that
352 356 we have only one place left which deals with this part. The remaining parts
353 357 of the application would start to rely fully on well prepared settings.
354 358
355 359 This piece would later be split up per topic to avoid a big fat monster
356 360 function.
357 361 """
358 362
359 363 settings.setdefault('rhodecode.edition', 'Community Edition')
360 364
361 365 if 'mako.default_filters' not in settings:
362 366 # set custom default filters if we don't have it defined
363 367 settings['mako.imports'] = 'from rhodecode.lib.base import h_filter'
364 368 settings['mako.default_filters'] = 'h_filter'
365 369
366 370 if 'mako.directories' not in settings:
367 371 mako_directories = settings.setdefault('mako.directories', [
368 372 # Base templates of the original application
369 373 'rhodecode:templates',
370 374 ])
371 375 log.debug(
372 376 "Using the following Mako template directories: %s",
373 377 mako_directories)
374 378
375 379 # Default includes, possible to change as a user
376 380 pyramid_includes = settings.setdefault('pyramid.includes', [
377 381 'rhodecode.lib.middleware.request_wrapper',
378 382 ])
379 383 log.debug(
380 384 "Using the following pyramid.includes: %s",
381 385 pyramid_includes)
382 386
383 387 # TODO: johbo: Re-think this, usually the call to config.include
384 388 # should allow to pass in a prefix.
385 389 settings.setdefault('rhodecode.api.url', '/_admin/api')
386 390
387 391 # Sanitize generic settings.
388 392 _list_setting(settings, 'default_encoding', 'UTF-8')
389 393 _bool_setting(settings, 'is_test', 'false')
390 394 _bool_setting(settings, 'gzip_responses', 'false')
391 395
392 396 # Call split out functions that sanitize settings for each topic.
393 397 _sanitize_appenlight_settings(settings)
394 398 _sanitize_vcs_settings(settings)
395 399 _sanitize_cache_settings(settings)
396 400
397 401 # configure instance id
398 402 config_utils.set_instance_id(settings)
399 403
400 404 return settings
401 405
402 406
403 407 def _sanitize_appenlight_settings(settings):
404 408 _bool_setting(settings, 'appenlight', 'false')
405 409
406 410
407 411 def _sanitize_vcs_settings(settings):
408 412 """
409 413 Applies settings defaults and does type conversion for all VCS related
410 414 settings.
411 415 """
412 416 _string_setting(settings, 'vcs.svn.compatible_version', '')
413 417 _string_setting(settings, 'git_rev_filter', '--all')
414 418 _string_setting(settings, 'vcs.hooks.protocol', 'http')
415 419 _string_setting(settings, 'vcs.hooks.host', '127.0.0.1')
416 420 _string_setting(settings, 'vcs.scm_app_implementation', 'http')
417 421 _string_setting(settings, 'vcs.server', '')
418 422 _string_setting(settings, 'vcs.server.log_level', 'debug')
419 423 _string_setting(settings, 'vcs.server.protocol', 'http')
420 424 _bool_setting(settings, 'startup.import_repos', 'false')
421 425 _bool_setting(settings, 'vcs.hooks.direct_calls', 'false')
422 426 _bool_setting(settings, 'vcs.server.enable', 'true')
423 427 _bool_setting(settings, 'vcs.start_server', 'false')
424 428 _list_setting(settings, 'vcs.backends', 'hg, git, svn')
425 429 _int_setting(settings, 'vcs.connection_timeout', 3600)
426 430
427 431 # Support legacy values of vcs.scm_app_implementation. Legacy
428 432 # configurations may use 'rhodecode.lib.middleware.utils.scm_app_http'
429 433 # which is now mapped to 'http'.
430 434 scm_app_impl = settings['vcs.scm_app_implementation']
431 435 if scm_app_impl == 'rhodecode.lib.middleware.utils.scm_app_http':
432 436 settings['vcs.scm_app_implementation'] = 'http'
433 437
434 438
435 439 def _sanitize_cache_settings(settings):
436 440 _string_setting(settings, 'cache_dir',
437 441 os.path.join(tempfile.gettempdir(), 'rc_cache'))
438 442 # cache_perms
439 443 _string_setting(
440 444 settings,
441 445 'rc_cache.cache_perms.backend',
442 446 'dogpile.cache.rc.file_namespace')
443 447 _int_setting(
444 448 settings,
445 449 'rc_cache.cache_perms.expiration_time',
446 450 60)
447 451 _string_setting(
448 452 settings,
449 453 'rc_cache.cache_perms.arguments.filename',
450 454 os.path.join(tempfile.gettempdir(), 'rc_cache_1'))
451 455
452 456 # cache_repo
453 457 _string_setting(
454 458 settings,
455 459 'rc_cache.cache_repo.backend',
456 460 'dogpile.cache.rc.file_namespace')
457 461 _int_setting(
458 462 settings,
459 463 'rc_cache.cache_repo.expiration_time',
460 464 60)
461 465 _string_setting(
462 466 settings,
463 467 'rc_cache.cache_repo.arguments.filename',
464 468 os.path.join(tempfile.gettempdir(), 'rc_cache_2'))
465 469
466 470 # sql_cache_short
467 471 _string_setting(
468 472 settings,
469 473 'rc_cache.sql_cache_short.backend',
470 474 'dogpile.cache.rc.memory_lru')
471 475 _int_setting(
472 476 settings,
473 477 'rc_cache.sql_cache_short.expiration_time',
474 478 30)
475 479 _int_setting(
476 480 settings,
477 481 'rc_cache.sql_cache_short.max_size',
478 482 10000)
479 483
480 484
481 485 def _int_setting(settings, name, default):
482 486 settings[name] = int(settings.get(name, default))
483 487
484 488
485 489 def _bool_setting(settings, name, default):
486 490 input_val = settings.get(name, default)
487 491 if isinstance(input_val, unicode):
488 492 input_val = input_val.encode('utf8')
489 493 settings[name] = asbool(input_val)
490 494
491 495
492 496 def _list_setting(settings, name, default):
493 497 raw_value = settings.get(name, default)
494 498
495 499 old_separator = ','
496 500 if old_separator in raw_value:
497 501 # If we get a comma separated list, pass it to our own function.
498 502 settings[name] = rhodecode_aslist(raw_value, sep=old_separator)
499 503 else:
500 504 # Otherwise we assume it uses pyramids space/newline separation.
501 505 settings[name] = aslist(raw_value)
502 506
503 507
504 508 def _string_setting(settings, name, default, lower=True):
505 509 value = settings.get(name, default)
506 510 if lower:
507 511 value = value.lower()
508 512 settings[name] = value
509 513
510 514
511 515 def _substitute_values(mapping, substitutions):
512 516 result = {
513 517 # Note: Cannot use regular replacements, since they would clash
514 518 # with the implementation of ConfigParser. Using "format" instead.
515 519 key: value.format(**substitutions)
516 520 for key, value in mapping.items()
517 521 }
518 522 return result
@@ -1,76 +1,78 b''
1 1 # Copyright (C) 2016-2018 RhodeCode GmbH
2 2 #
3 3 # This program is free software: you can redistribute it and/or modify
4 4 # it under the terms of the GNU Affero General Public License, version 3
5 5 # (only), as published by the Free Software Foundation.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU Affero General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 #
15 15 # This program is dual-licensed. If you wish to learn more about the
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 19 import logging
20 20 from pyramid.threadlocal import get_current_registry
21 21 from rhodecode.events.base import RhodeCodeIntegrationEvent
22 22
23 23
24 24 log = logging.getLogger(__name__)
25 25
26 26
27 27 def trigger(event, registry=None):
28 28 """
29 29 Helper method to send an event. This wraps the pyramid logic to send an
30 30 event.
31 31 """
32 32 # For the first step we are using pyramids thread locals here. If the
33 33 # event mechanism works out as a good solution we should think about
34 34 # passing the registry as an argument to get rid of it.
35 event_name = event.__class__
36 log.debug('event %s sent for execution', event_name)
35 37 registry = registry or get_current_registry()
36 38 registry.notify(event)
37 log.debug('event %s triggered using registry %s', event.__class__, registry)
39 log.debug('event %s triggered using registry %s', event_name, registry)
38 40
39 41 # Send the events to integrations directly
40 42 from rhodecode.integrations import integrations_event_handler
41 43 if isinstance(event, RhodeCodeIntegrationEvent):
42 44 integrations_event_handler(event)
43 45
44 46
45 47 from rhodecode.events.user import ( # noqa
46 48 UserPreCreate,
47 49 UserPostCreate,
48 50 UserPreUpdate,
49 51 UserRegistered,
50 52 UserPermissionsChange,
51 53 )
52 54
53 55 from rhodecode.events.repo import ( # noqa
54 56 RepoEvent,
55 57 RepoPreCreateEvent, RepoCreateEvent,
56 58 RepoPreDeleteEvent, RepoDeleteEvent,
57 59 RepoPrePushEvent, RepoPushEvent,
58 60 RepoPrePullEvent, RepoPullEvent,
59 61 )
60 62
61 63 from rhodecode.events.repo_group import ( # noqa
62 64 RepoGroupEvent,
63 65 RepoGroupCreateEvent,
64 66 RepoGroupUpdateEvent,
65 67 RepoGroupDeleteEvent,
66 68 )
67 69
68 70 from rhodecode.events.pullrequest import ( # noqa
69 71 PullRequestEvent,
70 72 PullRequestCreateEvent,
71 73 PullRequestUpdateEvent,
72 74 PullRequestCommentEvent,
73 75 PullRequestReviewEvent,
74 76 PullRequestMergeEvent,
75 77 PullRequestCloseEvent,
76 78 )
@@ -1,264 +1,279 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2017-2018 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 import logging
22 22 import datetime
23 23
24 24 from rhodecode.lib.jsonalchemy import JsonRaw
25 25 from rhodecode.model import meta
26 26 from rhodecode.model.db import User, UserLog, Repository
27 27
28 28
29 29 log = logging.getLogger(__name__)
30 30
31 31 # action as key, and expected action_data as value
32 32 ACTIONS_V1 = {
33 33 'user.login.success': {'user_agent': ''},
34 34 'user.login.failure': {'user_agent': ''},
35 35 'user.logout': {'user_agent': ''},
36 36 'user.register': {},
37 37 'user.password.reset_request': {},
38 38 'user.push': {'user_agent': '', 'commit_ids': []},
39 39 'user.pull': {'user_agent': ''},
40 40
41 41 'user.create': {'data': {}},
42 42 'user.delete': {'old_data': {}},
43 43 'user.edit': {'old_data': {}},
44 44 'user.edit.permissions': {},
45 45 'user.edit.ip.add': {'ip': {}, 'user': {}},
46 46 'user.edit.ip.delete': {'ip': {}, 'user': {}},
47 47 'user.edit.token.add': {'token': {}, 'user': {}},
48 48 'user.edit.token.delete': {'token': {}, 'user': {}},
49 49 'user.edit.email.add': {'email': ''},
50 50 'user.edit.email.delete': {'email': ''},
51 51 'user.edit.ssh_key.add': {'token': {}, 'user': {}},
52 52 'user.edit.ssh_key.delete': {'token': {}, 'user': {}},
53 53 'user.edit.password_reset.enabled': {},
54 54 'user.edit.password_reset.disabled': {},
55 55
56 56 'user_group.create': {'data': {}},
57 57 'user_group.delete': {'old_data': {}},
58 58 'user_group.edit': {'old_data': {}},
59 59 'user_group.edit.permissions': {},
60 60 'user_group.edit.member.add': {'user': {}},
61 61 'user_group.edit.member.delete': {'user': {}},
62 62
63 63 'repo.create': {'data': {}},
64 64 'repo.fork': {'data': {}},
65 65 'repo.edit': {'old_data': {}},
66 66 'repo.edit.permissions': {},
67 67 'repo.delete': {'old_data': {}},
68 68 'repo.commit.strip': {'commit_id': ''},
69 69 'repo.archive.download': {'user_agent': '', 'archive_name': '',
70 70 'archive_spec': '', 'archive_cached': ''},
71 71 'repo.pull_request.create': '',
72 72 'repo.pull_request.edit': '',
73 73 'repo.pull_request.delete': '',
74 74 'repo.pull_request.close': '',
75 75 'repo.pull_request.merge': '',
76 76 'repo.pull_request.vote': '',
77 77 'repo.pull_request.comment.create': '',
78 78 'repo.pull_request.comment.delete': '',
79 79
80 80 'repo.pull_request.reviewer.add': '',
81 81 'repo.pull_request.reviewer.delete': '',
82 82
83 83 'repo.commit.comment.create': {'data': {}},
84 84 'repo.commit.comment.delete': {'data': {}},
85 85 'repo.commit.vote': '',
86 86
87 87 'repo_group.create': {'data': {}},
88 88 'repo_group.edit': {'old_data': {}},
89 89 'repo_group.edit.permissions': {},
90 90 'repo_group.delete': {'old_data': {}},
91 91 }
92 92 ACTIONS = ACTIONS_V1
93 93
94 94 SOURCE_WEB = 'source_web'
95 95 SOURCE_API = 'source_api'
96 96
97 97
98 98 class UserWrap(object):
99 99 """
100 100 Fake object used to imitate AuthUser
101 101 """
102 102
103 103 def __init__(self, user_id=None, username=None, ip_addr=None):
104 104 self.user_id = user_id
105 105 self.username = username
106 106 self.ip_addr = ip_addr
107 107
108 108
109 109 class RepoWrap(object):
110 110 """
111 111 Fake object used to imitate RepoObject that audit logger requires
112 112 """
113 113
114 114 def __init__(self, repo_id=None, repo_name=None):
115 115 self.repo_id = repo_id
116 116 self.repo_name = repo_name
117 117
118 118
119 119 def _store_log(action_name, action_data, user_id, username, user_data,
120 120 ip_address, repository_id, repository_name):
121 121 user_log = UserLog()
122 122 user_log.version = UserLog.VERSION_2
123 123
124 124 user_log.action = action_name
125 125 user_log.action_data = action_data or JsonRaw(u'{}')
126 126
127 127 user_log.user_ip = ip_address
128 128
129 129 user_log.user_id = user_id
130 130 user_log.username = username
131 131 user_log.user_data = user_data or JsonRaw(u'{}')
132 132
133 133 user_log.repository_id = repository_id
134 134 user_log.repository_name = repository_name
135 135
136 136 user_log.action_date = datetime.datetime.now()
137 137
138 138 return user_log
139 139
140 140
141 141 def store_web(*args, **kwargs):
142 142 if 'action_data' not in kwargs:
143 143 kwargs['action_data'] = {}
144 144 kwargs['action_data'].update({
145 145 'source': SOURCE_WEB
146 146 })
147 147 return store(*args, **kwargs)
148 148
149 149
150 150 def store_api(*args, **kwargs):
151 151 if 'action_data' not in kwargs:
152 152 kwargs['action_data'] = {}
153 153 kwargs['action_data'].update({
154 154 'source': SOURCE_API
155 155 })
156 156 return store(*args, **kwargs)
157 157
158 158
159 159 def store(action, user, action_data=None, user_data=None, ip_addr=None,
160 160 repo=None, sa_session=None, commit=False):
161 161 """
162 162 Audit logger for various actions made by users, typically this
163 163 results in a call such::
164 164
165 165 from rhodecode.lib import audit_logger
166 166
167 167 audit_logger.store(
168 168 'repo.edit', user=self._rhodecode_user)
169 169 audit_logger.store(
170 170 'repo.delete', action_data={'data': repo_data},
171 171 user=audit_logger.UserWrap(username='itried-login', ip_addr='8.8.8.8'))
172 172
173 173 # repo action
174 174 audit_logger.store(
175 175 'repo.delete',
176 176 user=audit_logger.UserWrap(username='itried-login', ip_addr='8.8.8.8'),
177 177 repo=audit_logger.RepoWrap(repo_name='some-repo'))
178 178
179 179 # repo action, when we know and have the repository object already
180 180 audit_logger.store(
181 181 'repo.delete', action_data={'source': audit_logger.SOURCE_WEB, },
182 182 user=self._rhodecode_user,
183 183 repo=repo_object)
184 184
185 185 # alternative wrapper to the above
186 186 audit_logger.store_web(
187 187 'repo.delete', action_data={},
188 188 user=self._rhodecode_user,
189 189 repo=repo_object)
190 190
191 191 # without an user ?
192 192 audit_logger.store(
193 193 'user.login.failure',
194 194 user=audit_logger.UserWrap(
195 195 username=self.request.params.get('username'),
196 196 ip_addr=self.request.remote_addr))
197 197
198 198 """
199 199 from rhodecode.lib.utils2 import safe_unicode
200 200 from rhodecode.lib.auth import AuthUser
201 201
202 202 action_spec = ACTIONS.get(action, None)
203 203 if action_spec is None:
204 204 raise ValueError('Action `{}` is not supported'.format(action))
205 205
206 206 if not sa_session:
207 207 sa_session = meta.Session()
208 208
209 209 try:
210 210 username = getattr(user, 'username', None)
211 211 if not username:
212 212 pass
213 213
214 214 user_id = getattr(user, 'user_id', None)
215 215 if not user_id:
216 216 # maybe we have username ? Try to figure user_id from username
217 217 if username:
218 218 user_id = getattr(
219 219 User.get_by_username(username), 'user_id', None)
220 220
221 221 ip_addr = ip_addr or getattr(user, 'ip_addr', None)
222 222 if not ip_addr:
223 223 pass
224 224
225 225 if not user_data:
226 226 # try to get this from the auth user
227 227 if isinstance(user, AuthUser):
228 228 user_data = {
229 229 'username': user.username,
230 230 'email': user.email,
231 231 }
232 232
233 233 repository_name = getattr(repo, 'repo_name', None)
234 234 repository_id = getattr(repo, 'repo_id', None)
235 235 if not repository_id:
236 236 # maybe we have repo_name ? Try to figure repo_id from repo_name
237 237 if repository_name:
238 238 repository_id = getattr(
239 239 Repository.get_by_repo_name(repository_name), 'repo_id', None)
240 240
241 241 action_name = safe_unicode(action)
242 242 ip_address = safe_unicode(ip_addr)
243 243
244 user_log = _store_log(
245 action_name=action_name,
246 action_data=action_data or {},
247 user_id=user_id,
248 username=username,
249 user_data=user_data or {},
250 ip_address=ip_address,
251 repository_id=repository_id,
252 repository_name=repository_name
253 )
244 with sa_session.no_autoflush:
245 update_user_last_activity(sa_session, user_id)
254 246
255 sa_session.add(user_log)
256 if commit:
257 sa_session.commit()
247 user_log = _store_log(
248 action_name=action_name,
249 action_data=action_data or {},
250 user_id=user_id,
251 username=username,
252 user_data=user_data or {},
253 ip_address=ip_address,
254 repository_id=repository_id,
255 repository_name=repository_name
256 )
257
258 sa_session.add(user_log)
259
260 if commit:
261 sa_session.commit()
258 262
259 263 entry_id = user_log.entry_id or ''
260 264 log.info('AUDIT[%s]: Logging action: `%s` by user:id:%s[%s] ip:%s',
261 265 entry_id, action_name, user_id, username, ip_address)
262 266
263 267 except Exception:
264 268 log.exception('AUDIT: failed to store audit log')
269
270
271 def update_user_last_activity(sa_session, user_id):
272 _last_activity = datetime.datetime.now()
273 try:
274 sa_session.query(User).filter(User.user_id == user_id).update(
275 {"last_activity": _last_activity})
276 log.debug(
277 'updated user `%s` last activity to:%s', user_id, _last_activity)
278 except Exception:
279 log.exception("Failed last activity update")
@@ -1,659 +1,661 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2014-2018 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 re
28 28 import logging
29 29 import importlib
30 30 from functools import wraps
31 31 from StringIO import StringIO
32 32 from lxml import etree
33 33
34 34 import time
35 35 from paste.httpheaders import REMOTE_USER, AUTH_TYPE
36 36
37 37 from pyramid.httpexceptions import (
38 38 HTTPNotFound, HTTPForbidden, HTTPNotAcceptable, HTTPInternalServerError)
39 39 from zope.cachedescriptors.property import Lazy as LazyProperty
40 40
41 41 import rhodecode
42 42 from rhodecode.authentication.base import authenticate, VCS_TYPE, loadplugin
43 43 from rhodecode.lib import caches, rc_cache
44 44 from rhodecode.lib.auth import AuthUser, HasPermissionAnyMiddleware
45 45 from rhodecode.lib.base import (
46 46 BasicAuth, get_ip_addr, get_user_agent, vcs_operation_context)
47 47 from rhodecode.lib.exceptions import (UserCreationError, NotAllowedToCreateUserError)
48 48 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
49 49 from rhodecode.lib.middleware import appenlight
50 50 from rhodecode.lib.middleware.utils import scm_app_http
51 51 from rhodecode.lib.utils import is_valid_repo, SLUG_RE
52 52 from rhodecode.lib.utils2 import safe_str, fix_PATH, str2bool, safe_unicode
53 53 from rhodecode.lib.vcs.conf import settings as vcs_settings
54 54 from rhodecode.lib.vcs.backends import base
55 55
56 56 from rhodecode.model import meta
57 57 from rhodecode.model.db import User, Repository, PullRequest
58 58 from rhodecode.model.scm import ScmModel
59 59 from rhodecode.model.pull_request import PullRequestModel
60 60 from rhodecode.model.settings import SettingsModel, VcsSettingsModel
61 61
62 62 log = logging.getLogger(__name__)
63 63
64 64
65 65 def extract_svn_txn_id(acl_repo_name, data):
66 66 """
67 67 Helper method for extraction of svn txn_id from submited XML data during
68 68 POST operations
69 69 """
70 70 try:
71 71 root = etree.fromstring(data)
72 72 pat = re.compile(r'/txn/(?P<txn_id>.*)')
73 73 for el in root:
74 74 if el.tag == '{DAV:}source':
75 75 for sub_el in el:
76 76 if sub_el.tag == '{DAV:}href':
77 77 match = pat.search(sub_el.text)
78 78 if match:
79 79 svn_tx_id = match.groupdict()['txn_id']
80 80 txn_id = caches.compute_key_from_params(
81 81 acl_repo_name, svn_tx_id)
82 82 return txn_id
83 83 except Exception:
84 84 log.exception('Failed to extract txn_id')
85 85
86 86
87 87 def initialize_generator(factory):
88 88 """
89 89 Initializes the returned generator by draining its first element.
90 90
91 91 This can be used to give a generator an initializer, which is the code
92 92 up to the first yield statement. This decorator enforces that the first
93 93 produced element has the value ``"__init__"`` to make its special
94 94 purpose very explicit in the using code.
95 95 """
96 96
97 97 @wraps(factory)
98 98 def wrapper(*args, **kwargs):
99 99 gen = factory(*args, **kwargs)
100 100 try:
101 101 init = gen.next()
102 102 except StopIteration:
103 103 raise ValueError('Generator must yield at least one element.')
104 104 if init != "__init__":
105 105 raise ValueError('First yielded element must be "__init__".')
106 106 return gen
107 107 return wrapper
108 108
109 109
110 110 class SimpleVCS(object):
111 111 """Common functionality for SCM HTTP handlers."""
112 112
113 113 SCM = 'unknown'
114 114
115 115 acl_repo_name = None
116 116 url_repo_name = None
117 117 vcs_repo_name = None
118 118 rc_extras = {}
119 119
120 120 # We have to handle requests to shadow repositories different than requests
121 121 # to normal repositories. Therefore we have to distinguish them. To do this
122 122 # we use this regex which will match only on URLs pointing to shadow
123 123 # repositories.
124 124 shadow_repo_re = re.compile(
125 125 '(?P<groups>(?:{slug_pat}/)*)' # repo groups
126 126 '(?P<target>{slug_pat})/' # target repo
127 127 'pull-request/(?P<pr_id>\d+)/' # pull request
128 128 'repository$' # shadow repo
129 129 .format(slug_pat=SLUG_RE.pattern))
130 130
131 131 def __init__(self, config, registry):
132 132 self.registry = registry
133 133 self.config = config
134 134 # re-populated by specialized middleware
135 135 self.repo_vcs_config = base.Config()
136 136 self.rhodecode_settings = SettingsModel().get_all_settings(cache=True)
137 137
138 138 registry.rhodecode_settings = self.rhodecode_settings
139 139 # authenticate this VCS request using authfunc
140 140 auth_ret_code_detection = \
141 141 str2bool(self.config.get('auth_ret_code_detection', False))
142 142 self.authenticate = BasicAuth(
143 143 '', authenticate, registry, config.get('auth_ret_code'),
144 144 auth_ret_code_detection)
145 145 self.ip_addr = '0.0.0.0'
146 146
147 147 @LazyProperty
148 148 def global_vcs_config(self):
149 149 try:
150 150 return VcsSettingsModel().get_ui_settings_as_config_obj()
151 151 except Exception:
152 152 return base.Config()
153 153
154 154 @property
155 155 def base_path(self):
156 156 settings_path = self.repo_vcs_config.get(
157 157 *VcsSettingsModel.PATH_SETTING)
158 158
159 159 if not settings_path:
160 160 settings_path = self.global_vcs_config.get(
161 161 *VcsSettingsModel.PATH_SETTING)
162 162
163 163 if not settings_path:
164 164 # try, maybe we passed in explicitly as config option
165 165 settings_path = self.config.get('base_path')
166 166
167 167 if not settings_path:
168 168 raise ValueError('FATAL: base_path is empty')
169 169 return settings_path
170 170
171 171 def set_repo_names(self, environ):
172 172 """
173 173 This will populate the attributes acl_repo_name, url_repo_name,
174 174 vcs_repo_name and is_shadow_repo. In case of requests to normal (non
175 175 shadow) repositories all names are equal. In case of requests to a
176 176 shadow repository the acl-name points to the target repo of the pull
177 177 request and the vcs-name points to the shadow repo file system path.
178 178 The url-name is always the URL used by the vcs client program.
179 179
180 180 Example in case of a shadow repo:
181 181 acl_repo_name = RepoGroup/MyRepo
182 182 url_repo_name = RepoGroup/MyRepo/pull-request/3/repository
183 183 vcs_repo_name = /repo/base/path/RepoGroup/.__shadow_MyRepo_pr-3'
184 184 """
185 185 # First we set the repo name from URL for all attributes. This is the
186 186 # default if handling normal (non shadow) repo requests.
187 187 self.url_repo_name = self._get_repository_name(environ)
188 188 self.acl_repo_name = self.vcs_repo_name = self.url_repo_name
189 189 self.is_shadow_repo = False
190 190
191 191 # Check if this is a request to a shadow repository.
192 192 match = self.shadow_repo_re.match(self.url_repo_name)
193 193 if match:
194 194 match_dict = match.groupdict()
195 195
196 196 # Build acl repo name from regex match.
197 197 acl_repo_name = safe_unicode('{groups}{target}'.format(
198 198 groups=match_dict['groups'] or '',
199 199 target=match_dict['target']))
200 200
201 201 # Retrieve pull request instance by ID from regex match.
202 202 pull_request = PullRequest.get(match_dict['pr_id'])
203 203
204 204 # Only proceed if we got a pull request and if acl repo name from
205 205 # URL equals the target repo name of the pull request.
206 206 if pull_request and \
207 207 (acl_repo_name == pull_request.target_repo.repo_name):
208 208 repo_id = pull_request.target_repo.repo_id
209 209 # Get file system path to shadow repository.
210 210 workspace_id = PullRequestModel()._workspace_id(pull_request)
211 211 target_vcs = pull_request.target_repo.scm_instance()
212 212 vcs_repo_name = target_vcs._get_shadow_repository_path(
213 213 repo_id, workspace_id)
214 214
215 215 # Store names for later usage.
216 216 self.vcs_repo_name = vcs_repo_name
217 217 self.acl_repo_name = acl_repo_name
218 218 self.is_shadow_repo = True
219 219
220 220 log.debug('Setting all VCS repository names: %s', {
221 221 'acl_repo_name': self.acl_repo_name,
222 222 'url_repo_name': self.url_repo_name,
223 223 'vcs_repo_name': self.vcs_repo_name,
224 224 })
225 225
226 226 @property
227 227 def scm_app(self):
228 228 custom_implementation = self.config['vcs.scm_app_implementation']
229 229 if custom_implementation == 'http':
230 230 log.info('Using HTTP implementation of scm app.')
231 231 scm_app_impl = scm_app_http
232 232 else:
233 233 log.info('Using custom implementation of scm_app: "{}"'.format(
234 234 custom_implementation))
235 235 scm_app_impl = importlib.import_module(custom_implementation)
236 236 return scm_app_impl
237 237
238 238 def _get_by_id(self, repo_name):
239 239 """
240 240 Gets a special pattern _<ID> from clone url and tries to replace it
241 241 with a repository_name for support of _<ID> non changeable urls
242 242 """
243 243
244 244 data = repo_name.split('/')
245 245 if len(data) >= 2:
246 246 from rhodecode.model.repo import RepoModel
247 247 by_id_match = RepoModel().get_repo_by_id(repo_name)
248 248 if by_id_match:
249 249 data[1] = by_id_match.repo_name
250 250
251 251 return safe_str('/'.join(data))
252 252
253 253 def _invalidate_cache(self, repo_name):
254 254 """
255 255 Set's cache for this repository for invalidation on next access
256 256
257 257 :param repo_name: full repo name, also a cache key
258 258 """
259 259 ScmModel().mark_for_invalidation(repo_name)
260 260
261 261 def is_valid_and_existing_repo(self, repo_name, base_path, scm_type):
262 262 db_repo = Repository.get_by_repo_name(repo_name)
263 263 if not db_repo:
264 264 log.debug('Repository `%s` not found inside the database.',
265 265 repo_name)
266 266 return False
267 267
268 268 if db_repo.repo_type != scm_type:
269 269 log.warning(
270 270 'Repository `%s` have incorrect scm_type, expected %s got %s',
271 271 repo_name, db_repo.repo_type, scm_type)
272 272 return False
273 273
274 274 config = db_repo._config
275 275 config.set('extensions', 'largefiles', '')
276 276 return is_valid_repo(
277 277 repo_name, base_path,
278 278 explicit_scm=scm_type, expect_scm=scm_type, config=config)
279 279
280 280 def valid_and_active_user(self, user):
281 281 """
282 282 Checks if that user is not empty, and if it's actually object it checks
283 283 if he's active.
284 284
285 285 :param user: user object or None
286 286 :return: boolean
287 287 """
288 288 if user is None:
289 289 return False
290 290
291 291 elif user.active:
292 292 return True
293 293
294 294 return False
295 295
296 296 @property
297 297 def is_shadow_repo_dir(self):
298 298 return os.path.isdir(self.vcs_repo_name)
299 299
300 300 def _check_permission(self, action, user, repo_name, ip_addr=None,
301 301 plugin_id='', plugin_cache_active=False, cache_ttl=0):
302 302 """
303 303 Checks permissions using action (push/pull) user and repository
304 304 name. If plugin_cache and ttl is set it will use the plugin which
305 305 authenticated the user to store the cached permissions result for N
306 306 amount of seconds as in cache_ttl
307 307
308 308 :param action: push or pull action
309 309 :param user: user instance
310 310 :param repo_name: repository name
311 311 """
312 312
313 313 log.debug('AUTH_CACHE_TTL for permissions `%s` active: %s (TTL: %s)',
314 314 plugin_id, plugin_cache_active, cache_ttl)
315 315
316 316 user_id = user.user_id
317 317 cache_namespace_uid = 'cache_user_auth.{}'.format(user_id)
318 318 region = rc_cache.get_or_create_region('cache_perms', cache_namespace_uid)
319 319
320 320 @region.conditional_cache_on_arguments(namespace=cache_namespace_uid,
321 321 expiration_time=cache_ttl,
322 322 condition=plugin_cache_active)
323 323 def compute_perm_vcs(
324 324 cache_name, plugin_id, action, user_id, repo_name, ip_addr):
325 325
326 326 log.debug('auth: calculating permission access now...')
327 327 # check IP
328 328 inherit = user.inherit_default_permissions
329 329 ip_allowed = AuthUser.check_ip_allowed(
330 330 user_id, ip_addr, inherit_from_default=inherit)
331 331 if ip_allowed:
332 332 log.info('Access for IP:%s allowed', ip_addr)
333 333 else:
334 334 return False
335 335
336 336 if action == 'push':
337 337 perms = ('repository.write', 'repository.admin')
338 338 if not HasPermissionAnyMiddleware(*perms)(user, repo_name):
339 339 return False
340 340
341 341 else:
342 342 # any other action need at least read permission
343 343 perms = (
344 344 'repository.read', 'repository.write', 'repository.admin')
345 345 if not HasPermissionAnyMiddleware(*perms)(user, repo_name):
346 346 return False
347 347
348 348 return True
349 349
350 350 start = time.time()
351 351 log.debug('Running plugin `%s` permissions check', plugin_id)
352 352
353 353 # for environ based auth, password can be empty, but then the validation is
354 354 # on the server that fills in the env data needed for authentication
355 355 perm_result = compute_perm_vcs(
356 356 'vcs_permissions', plugin_id, action, user.user_id, repo_name, ip_addr)
357 357
358 358 auth_time = time.time() - start
359 359 log.debug('Permissions for plugin `%s` completed in %.3fs, '
360 360 'expiration time of fetched cache %.1fs.',
361 361 plugin_id, auth_time, cache_ttl)
362 362
363 363 return perm_result
364 364
365 365 def _check_ssl(self, environ, start_response):
366 366 """
367 367 Checks the SSL check flag and returns False if SSL is not present
368 368 and required True otherwise
369 369 """
370 370 org_proto = environ['wsgi._org_proto']
371 371 # check if we have SSL required ! if not it's a bad request !
372 372 require_ssl = str2bool(self.repo_vcs_config.get('web', 'push_ssl'))
373 373 if require_ssl and org_proto == 'http':
374 374 log.debug(
375 375 'Bad request: detected protocol is `%s` and '
376 376 'SSL/HTTPS is required.', org_proto)
377 377 return False
378 378 return True
379 379
380 380 def _get_default_cache_ttl(self):
381 381 # take AUTH_CACHE_TTL from the `rhodecode` auth plugin
382 382 plugin = loadplugin('egg:rhodecode-enterprise-ce#rhodecode')
383 383 plugin_settings = plugin.get_settings()
384 384 plugin_cache_active, cache_ttl = plugin.get_ttl_cache(
385 385 plugin_settings) or (False, 0)
386 386 return plugin_cache_active, cache_ttl
387 387
388 388 def __call__(self, environ, start_response):
389 389 try:
390 390 return self._handle_request(environ, start_response)
391 391 except Exception:
392 392 log.exception("Exception while handling request")
393 393 appenlight.track_exception(environ)
394 394 return HTTPInternalServerError()(environ, start_response)
395 395 finally:
396 396 meta.Session.remove()
397 397
398 398 def _handle_request(self, environ, start_response):
399 399
400 400 if not self._check_ssl(environ, start_response):
401 401 reason = ('SSL required, while RhodeCode was unable '
402 402 'to detect this as SSL request')
403 403 log.debug('User not allowed to proceed, %s', reason)
404 404 return HTTPNotAcceptable(reason)(environ, start_response)
405 405
406 406 if not self.url_repo_name:
407 407 log.warning('Repository name is empty: %s', self.url_repo_name)
408 408 # failed to get repo name, we fail now
409 409 return HTTPNotFound()(environ, start_response)
410 410 log.debug('Extracted repo name is %s', self.url_repo_name)
411 411
412 412 ip_addr = get_ip_addr(environ)
413 413 user_agent = get_user_agent(environ)
414 414 username = None
415 415
416 416 # skip passing error to error controller
417 417 environ['pylons.status_code_redirect'] = True
418 418
419 419 # ======================================================================
420 420 # GET ACTION PULL or PUSH
421 421 # ======================================================================
422 422 action = self._get_action(environ)
423 423
424 424 # ======================================================================
425 425 # Check if this is a request to a shadow repository of a pull request.
426 426 # In this case only pull action is allowed.
427 427 # ======================================================================
428 428 if self.is_shadow_repo and action != 'pull':
429 429 reason = 'Only pull action is allowed for shadow repositories.'
430 430 log.debug('User not allowed to proceed, %s', reason)
431 431 return HTTPNotAcceptable(reason)(environ, start_response)
432 432
433 433 # Check if the shadow repo actually exists, in case someone refers
434 434 # to it, and it has been deleted because of successful merge.
435 435 if self.is_shadow_repo and not self.is_shadow_repo_dir:
436 436 log.debug(
437 437 'Shadow repo detected, and shadow repo dir `%s` is missing',
438 438 self.is_shadow_repo_dir)
439 439 return HTTPNotFound()(environ, start_response)
440 440
441 441 # ======================================================================
442 442 # CHECK ANONYMOUS PERMISSION
443 443 # ======================================================================
444 444 if action in ['pull', 'push']:
445 445 anonymous_user = User.get_default_user()
446 446 username = anonymous_user.username
447 447 if anonymous_user.active:
448 448 plugin_cache_active, cache_ttl = self._get_default_cache_ttl()
449 449 # ONLY check permissions if the user is activated
450 450 anonymous_perm = self._check_permission(
451 451 action, anonymous_user, self.acl_repo_name, ip_addr,
452 452 plugin_id='anonymous_access',
453 453 plugin_cache_active=plugin_cache_active,
454 454 cache_ttl=cache_ttl,
455 455 )
456 456 else:
457 457 anonymous_perm = False
458 458
459 459 if not anonymous_user.active or not anonymous_perm:
460 460 if not anonymous_user.active:
461 461 log.debug('Anonymous access is disabled, running '
462 462 'authentication')
463 463
464 464 if not anonymous_perm:
465 465 log.debug('Not enough credentials to access this '
466 466 'repository as anonymous user')
467 467
468 468 username = None
469 469 # ==============================================================
470 470 # DEFAULT PERM FAILED OR ANONYMOUS ACCESS IS DISABLED SO WE
471 471 # NEED TO AUTHENTICATE AND ASK FOR AUTH USER PERMISSIONS
472 472 # ==============================================================
473 473
474 474 # try to auth based on environ, container auth methods
475 475 log.debug('Running PRE-AUTH for container based authentication')
476 476 pre_auth = authenticate(
477 477 '', '', environ, VCS_TYPE, registry=self.registry,
478 478 acl_repo_name=self.acl_repo_name)
479 479 if pre_auth and pre_auth.get('username'):
480 480 username = pre_auth['username']
481 481 log.debug('PRE-AUTH got %s as username', username)
482 482 if pre_auth:
483 483 log.debug('PRE-AUTH successful from %s',
484 484 pre_auth.get('auth_data', {}).get('_plugin'))
485 485
486 486 # If not authenticated by the container, running basic auth
487 487 # before inject the calling repo_name for special scope checks
488 488 self.authenticate.acl_repo_name = self.acl_repo_name
489 489
490 490 plugin_cache_active, cache_ttl = False, 0
491 491 plugin = None
492 492 if not username:
493 493 self.authenticate.realm = self.authenticate.get_rc_realm()
494 494
495 495 try:
496 496 auth_result = self.authenticate(environ)
497 497 except (UserCreationError, NotAllowedToCreateUserError) as e:
498 498 log.error(e)
499 499 reason = safe_str(e)
500 500 return HTTPNotAcceptable(reason)(environ, start_response)
501 501
502 502 if isinstance(auth_result, dict):
503 503 AUTH_TYPE.update(environ, 'basic')
504 504 REMOTE_USER.update(environ, auth_result['username'])
505 505 username = auth_result['username']
506 506 plugin = auth_result.get('auth_data', {}).get('_plugin')
507 507 log.info(
508 508 'MAIN-AUTH successful for user `%s` from %s plugin',
509 509 username, plugin)
510 510
511 511 plugin_cache_active, cache_ttl = auth_result.get(
512 512 'auth_data', {}).get('_ttl_cache') or (False, 0)
513 513 else:
514 514 return auth_result.wsgi_application(
515 515 environ, start_response)
516 516
517 517 # ==============================================================
518 518 # CHECK PERMISSIONS FOR THIS REQUEST USING GIVEN USERNAME
519 519 # ==============================================================
520 520 user = User.get_by_username(username)
521 521 if not self.valid_and_active_user(user):
522 522 return HTTPForbidden()(environ, start_response)
523 523 username = user.username
524 user_id = user.user_id
524 525
525 526 # check user attributes for password change flag
526 527 user_obj = user
527 528 if user_obj and user_obj.username != User.DEFAULT_USER and \
528 529 user_obj.user_data.get('force_password_change'):
529 530 reason = 'password change required'
530 531 log.debug('User not allowed to authenticate, %s', reason)
531 532 return HTTPNotAcceptable(reason)(environ, start_response)
532 533
533 534 # check permissions for this repository
534 535 perm = self._check_permission(
535 536 action, user, self.acl_repo_name, ip_addr,
536 537 plugin, plugin_cache_active, cache_ttl)
537 538 if not perm:
538 539 return HTTPForbidden()(environ, start_response)
540 environ['rc_auth_user_id'] = user_id
539 541
540 542 # extras are injected into UI object and later available
541 543 # in hooks executed by RhodeCode
542 544 check_locking = _should_check_locking(environ.get('QUERY_STRING'))
543 545 extras = vcs_operation_context(
544 546 environ, repo_name=self.acl_repo_name, username=username,
545 547 action=action, scm=self.SCM, check_locking=check_locking,
546 548 is_shadow_repo=self.is_shadow_repo
547 549 )
548 550
549 551 # ======================================================================
550 552 # REQUEST HANDLING
551 553 # ======================================================================
552 554 repo_path = os.path.join(
553 555 safe_str(self.base_path), safe_str(self.vcs_repo_name))
554 556 log.debug('Repository path is %s', repo_path)
555 557
556 558 fix_PATH()
557 559
558 560 log.info(
559 561 '%s action on %s repo "%s" by "%s" from %s %s',
560 562 action, self.SCM, safe_str(self.url_repo_name),
561 563 safe_str(username), ip_addr, user_agent)
562 564
563 565 return self._generate_vcs_response(
564 566 environ, start_response, repo_path, extras, action)
565 567
566 568 @initialize_generator
567 569 def _generate_vcs_response(
568 570 self, environ, start_response, repo_path, extras, action):
569 571 """
570 572 Returns a generator for the response content.
571 573
572 574 This method is implemented as a generator, so that it can trigger
573 575 the cache validation after all content sent back to the client. It
574 576 also handles the locking exceptions which will be triggered when
575 577 the first chunk is produced by the underlying WSGI application.
576 578 """
577 579 txn_id = ''
578 580 if 'CONTENT_LENGTH' in environ and environ['REQUEST_METHOD'] == 'MERGE':
579 581 # case for SVN, we want to re-use the callback daemon port
580 582 # so we use the txn_id, for this we peek the body, and still save
581 583 # it as wsgi.input
582 584 data = environ['wsgi.input'].read()
583 585 environ['wsgi.input'] = StringIO(data)
584 586 txn_id = extract_svn_txn_id(self.acl_repo_name, data)
585 587
586 588 callback_daemon, extras = self._prepare_callback_daemon(
587 589 extras, environ, action, txn_id=txn_id)
588 590 log.debug('HOOKS extras is %s', extras)
589 591
590 592 config = self._create_config(extras, self.acl_repo_name)
591 593 app = self._create_wsgi_app(repo_path, self.url_repo_name, config)
592 594 with callback_daemon:
593 595 app.rc_extras = extras
594 596
595 597 try:
596 598 response = app(environ, start_response)
597 599 finally:
598 600 # This statement works together with the decorator
599 601 # "initialize_generator" above. The decorator ensures that
600 602 # we hit the first yield statement before the generator is
601 603 # returned back to the WSGI server. This is needed to
602 604 # ensure that the call to "app" above triggers the
603 605 # needed callback to "start_response" before the
604 606 # generator is actually used.
605 607 yield "__init__"
606 608
607 609 # iter content
608 610 for chunk in response:
609 611 yield chunk
610 612
611 613 try:
612 614 # invalidate cache on push
613 615 if action == 'push':
614 616 self._invalidate_cache(self.url_repo_name)
615 617 finally:
616 618 meta.Session.remove()
617 619
618 620 def _get_repository_name(self, environ):
619 621 """Get repository name out of the environmnent
620 622
621 623 :param environ: WSGI environment
622 624 """
623 625 raise NotImplementedError()
624 626
625 627 def _get_action(self, environ):
626 628 """Map request commands into a pull or push command.
627 629
628 630 :param environ: WSGI environment
629 631 """
630 632 raise NotImplementedError()
631 633
632 634 def _create_wsgi_app(self, repo_path, repo_name, config):
633 635 """Return the WSGI app that will finally handle the request."""
634 636 raise NotImplementedError()
635 637
636 638 def _create_config(self, extras, repo_name):
637 639 """Create a safe config representation."""
638 640 raise NotImplementedError()
639 641
640 642 def _should_use_callback_daemon(self, extras, environ, action):
641 643 return True
642 644
643 645 def _prepare_callback_daemon(self, extras, environ, action, txn_id=None):
644 646 direct_calls = vcs_settings.HOOKS_DIRECT_CALLS
645 647 if not self._should_use_callback_daemon(extras, environ, action):
646 648 # disable callback daemon for actions that don't require it
647 649 direct_calls = True
648 650
649 651 return prepare_callback_daemon(
650 652 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
651 653 host=vcs_settings.HOOKS_HOST, use_direct_calls=direct_calls, txn_id=txn_id)
652 654
653 655
654 656 def _should_check_locking(query_string):
655 657 # this is kind of hacky, but due to how mercurial handles client-server
656 658 # server see all operation on commit; bookmarks, phases and
657 659 # obsolescence marker in different transaction, we don't want to check
658 660 # locking on those
659 661 return query_string not in ['cmd=listkeys']
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
@@ -1,328 +1,329 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2018 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 import io
21 21 import re
22 22 import datetime
23 23 import logging
24 24 import Queue
25 25 import subprocess32
26 26 import os
27 27
28 28
29 29 from dateutil.parser import parse
30 30 from pyramid.i18n import get_localizer
31 31 from pyramid.threadlocal import get_current_request
32 32 from pyramid.interfaces import IRoutesMapper
33 33 from pyramid.settings import asbool
34 34 from pyramid.path import AssetResolver
35 35 from threading import Thread
36 36
37 37 from rhodecode.translation import _ as tsf
38 38 from rhodecode.config.jsroutes import generate_jsroutes_content
39 39 from rhodecode.lib import auth
40 40 from rhodecode.lib.base import get_auth_user
41 41
42 42
43 43 import rhodecode
44 44
45 45
46 46 log = logging.getLogger(__name__)
47 47
48 48
49 49 def add_renderer_globals(event):
50 50 from rhodecode.lib import helpers
51 51
52 52 # TODO: When executed in pyramid view context the request is not available
53 53 # in the event. Find a better solution to get the request.
54 54 request = event['request'] or get_current_request()
55 55
56 56 # Add Pyramid translation as '_' to context
57 57 event['_'] = request.translate
58 58 event['_ungettext'] = request.plularize
59 59 event['h'] = helpers
60 60
61 61
62 62 def add_localizer(event):
63 63 request = event.request
64 64 localizer = request.localizer
65 65
66 66 def auto_translate(*args, **kwargs):
67 67 return localizer.translate(tsf(*args, **kwargs))
68 68
69 69 request.translate = auto_translate
70 70 request.plularize = localizer.pluralize
71 71
72 72
73 73 def set_user_lang(event):
74 74 request = event.request
75 75 cur_user = getattr(request, 'user', None)
76 76
77 77 if cur_user:
78 78 user_lang = cur_user.get_instance().user_data.get('language')
79 79 if user_lang:
80 80 log.debug('lang: setting current user:%s language to: %s', cur_user, user_lang)
81 81 event.request._LOCALE_ = user_lang
82 82
83 83
84 84 def add_request_user_context(event):
85 85 """
86 86 Adds auth user into request context
87 87 """
88 88 request = event.request
89 89 # access req_id as soon as possible
90 90 req_id = request.req_id
91 91
92 92 if hasattr(request, 'vcs_call'):
93 93 # skip vcs calls
94 94 return
95 95
96 96 if hasattr(request, 'rpc_method'):
97 97 # skip api calls
98 98 return
99 99
100 100 auth_user = get_auth_user(request)
101 101 request.user = auth_user
102 102 request.environ['rc_auth_user'] = auth_user
103 request.environ['rc_auth_user_id'] = auth_user.user_id
103 104 request.environ['rc_req_id'] = req_id
104 105
105 106
106 107 def inject_app_settings(event):
107 108 settings = event.app.registry.settings
108 109 # inject info about available permissions
109 110 auth.set_available_permissions(settings)
110 111
111 112
112 113 def scan_repositories_if_enabled(event):
113 114 """
114 115 This is subscribed to the `pyramid.events.ApplicationCreated` event. It
115 116 does a repository scan if enabled in the settings.
116 117 """
117 118 settings = event.app.registry.settings
118 119 vcs_server_enabled = settings['vcs.server.enable']
119 120 import_on_startup = settings['startup.import_repos']
120 121 if vcs_server_enabled and import_on_startup:
121 122 from rhodecode.model.scm import ScmModel
122 123 from rhodecode.lib.utils import repo2db_mapper, get_rhodecode_base_path
123 124 repositories = ScmModel().repo_scan(get_rhodecode_base_path())
124 125 repo2db_mapper(repositories, remove_obsolete=False)
125 126
126 127
127 128 def write_metadata_if_needed(event):
128 129 """
129 130 Writes upgrade metadata
130 131 """
131 132 import rhodecode
132 133 from rhodecode.lib import system_info
133 134 from rhodecode.lib import ext_json
134 135
135 136 fname = '.rcmetadata.json'
136 137 ini_loc = os.path.dirname(rhodecode.CONFIG.get('__file__'))
137 138 metadata_destination = os.path.join(ini_loc, fname)
138 139
139 140 def get_update_age():
140 141 now = datetime.datetime.utcnow()
141 142
142 143 with open(metadata_destination, 'rb') as f:
143 144 data = ext_json.json.loads(f.read())
144 145 if 'created_on' in data:
145 146 update_date = parse(data['created_on'])
146 147 diff = now - update_date
147 148 return diff.total_seconds() / 60.0
148 149
149 150 return 0
150 151
151 152 def write():
152 153 configuration = system_info.SysInfo(
153 154 system_info.rhodecode_config)()['value']
154 155 license_token = configuration['config']['license_token']
155 156
156 157 setup = dict(
157 158 workers=configuration['config']['server:main'].get(
158 159 'workers', '?'),
159 160 worker_type=configuration['config']['server:main'].get(
160 161 'worker_class', 'sync'),
161 162 )
162 163 dbinfo = system_info.SysInfo(system_info.database_info)()['value']
163 164 del dbinfo['url']
164 165
165 166 metadata = dict(
166 167 desc='upgrade metadata info',
167 168 license_token=license_token,
168 169 created_on=datetime.datetime.utcnow().isoformat(),
169 170 usage=system_info.SysInfo(system_info.usage_info)()['value'],
170 171 platform=system_info.SysInfo(system_info.platform_type)()['value'],
171 172 database=dbinfo,
172 173 cpu=system_info.SysInfo(system_info.cpu)()['value'],
173 174 memory=system_info.SysInfo(system_info.memory)()['value'],
174 175 setup=setup
175 176 )
176 177
177 178 with open(metadata_destination, 'wb') as f:
178 179 f.write(ext_json.json.dumps(metadata))
179 180
180 181 settings = event.app.registry.settings
181 182 if settings.get('metadata.skip'):
182 183 return
183 184
184 185 # only write this every 24h, workers restart caused unwanted delays
185 186 try:
186 187 age_in_min = get_update_age()
187 188 except Exception:
188 189 age_in_min = 0
189 190
190 191 if age_in_min > 60 * 60 * 24:
191 192 return
192 193
193 194 try:
194 195 write()
195 196 except Exception:
196 197 pass
197 198
198 199
199 200 def write_js_routes_if_enabled(event):
200 201 registry = event.app.registry
201 202
202 203 mapper = registry.queryUtility(IRoutesMapper)
203 204 _argument_prog = re.compile('\{(.*?)\}|:\((.*)\)')
204 205
205 206 def _extract_route_information(route):
206 207 """
207 208 Convert a route into tuple(name, path, args), eg:
208 209 ('show_user', '/profile/%(username)s', ['username'])
209 210 """
210 211
211 212 routepath = route.pattern
212 213 pattern = route.pattern
213 214
214 215 def replace(matchobj):
215 216 if matchobj.group(1):
216 217 return "%%(%s)s" % matchobj.group(1).split(':')[0]
217 218 else:
218 219 return "%%(%s)s" % matchobj.group(2)
219 220
220 221 routepath = _argument_prog.sub(replace, routepath)
221 222
222 223 if not routepath.startswith('/'):
223 224 routepath = '/'+routepath
224 225
225 226 return (
226 227 route.name,
227 228 routepath,
228 229 [(arg[0].split(':')[0] if arg[0] != '' else arg[1])
229 230 for arg in _argument_prog.findall(pattern)]
230 231 )
231 232
232 233 def get_routes():
233 234 # pyramid routes
234 235 for route in mapper.get_routes():
235 236 if not route.name.startswith('__'):
236 237 yield _extract_route_information(route)
237 238
238 239 if asbool(registry.settings.get('generate_js_files', 'false')):
239 240 static_path = AssetResolver().resolve('rhodecode:public').abspath()
240 241 jsroutes = get_routes()
241 242 jsroutes_file_content = generate_jsroutes_content(jsroutes)
242 243 jsroutes_file_path = os.path.join(
243 244 static_path, 'js', 'rhodecode', 'routes.js')
244 245
245 246 try:
246 247 with io.open(jsroutes_file_path, 'w', encoding='utf-8') as f:
247 248 f.write(jsroutes_file_content)
248 249 except Exception:
249 250 log.exception('Failed to write routes.js into %s', jsroutes_file_path)
250 251
251 252
252 253 class Subscriber(object):
253 254 """
254 255 Base class for subscribers to the pyramid event system.
255 256 """
256 257 def __call__(self, event):
257 258 self.run(event)
258 259
259 260 def run(self, event):
260 261 raise NotImplementedError('Subclass has to implement this.')
261 262
262 263
263 264 class AsyncSubscriber(Subscriber):
264 265 """
265 266 Subscriber that handles the execution of events in a separate task to not
266 267 block the execution of the code which triggers the event. It puts the
267 268 received events into a queue from which the worker process takes them in
268 269 order.
269 270 """
270 271 def __init__(self):
271 272 self._stop = False
272 273 self._eventq = Queue.Queue()
273 274 self._worker = self.create_worker()
274 275 self._worker.start()
275 276
276 277 def __call__(self, event):
277 278 self._eventq.put(event)
278 279
279 280 def create_worker(self):
280 281 worker = Thread(target=self.do_work)
281 282 worker.daemon = True
282 283 return worker
283 284
284 285 def stop_worker(self):
285 286 self._stop = False
286 287 self._eventq.put(None)
287 288 self._worker.join()
288 289
289 290 def do_work(self):
290 291 while not self._stop:
291 292 event = self._eventq.get()
292 293 if event is not None:
293 294 self.run(event)
294 295
295 296
296 297 class AsyncSubprocessSubscriber(AsyncSubscriber):
297 298 """
298 299 Subscriber that uses the subprocess32 module to execute a command if an
299 300 event is received. Events are handled asynchronously.
300 301 """
301 302
302 303 def __init__(self, cmd, timeout=None):
303 304 super(AsyncSubprocessSubscriber, self).__init__()
304 305 self._cmd = cmd
305 306 self._timeout = timeout
306 307
307 308 def run(self, event):
308 309 cmd = self._cmd
309 310 timeout = self._timeout
310 311 log.debug('Executing command %s.', cmd)
311 312
312 313 try:
313 314 output = subprocess32.check_output(
314 315 cmd, timeout=timeout, stderr=subprocess32.STDOUT)
315 316 log.debug('Command finished %s', cmd)
316 317 if output:
317 318 log.debug('Command output: %s', output)
318 319 except subprocess32.TimeoutExpired as e:
319 320 log.exception('Timeout while executing command.')
320 321 if e.output:
321 322 log.error('Command output: %s', e.output)
322 323 except subprocess32.CalledProcessError as e:
323 324 log.exception('Error while executing command.')
324 325 if e.output:
325 326 log.error('Command output: %s', e.output)
326 327 except:
327 328 log.exception(
328 329 'Exception while executing command %s.', cmd)
General Comments 0
You need to be logged in to leave comments. Login now