diff --git a/rhodecode/apps/admin/__init__.py b/rhodecode/apps/admin/__init__.py --- a/rhodecode/apps/admin/__init__.py +++ b/rhodecode/apps/admin/__init__.py @@ -79,6 +79,11 @@ def admin_routes(config): name='edit_user_groups_management_updates', pattern='/users/{user_id:\d+}/edit/edit_user_groups_management/updates') + # user audit logs + config.add_route( + name='edit_user_audit_logs', + pattern='/users/{user_id:\d+}/edit/audit') + def includeme(config): settings = config.get_settings() diff --git a/rhodecode/apps/admin/views/users.py b/rhodecode/apps/admin/views/users.py --- a/rhodecode/apps/admin/views/users.py +++ b/rhodecode/apps/admin/views/users.py @@ -22,6 +22,8 @@ import logging from pyramid.httpexceptions import HTTPFound from pyramid.view import view_config + +from rhodecode.lib.helpers import Page from rhodecode_tools.lib.ext_json import json from rhodecode.apps._base import BaseAppView @@ -31,6 +33,7 @@ from rhodecode.lib import helpers as h from rhodecode.lib.utils import PartialRenderer from rhodecode.lib.utils2 import safe_int, safe_unicode from rhodecode.model.auth_token import AuthTokenModel +from rhodecode.model.user import UserModel from rhodecode.model.user_group import UserGroupModel from rhodecode.model.db import User, or_ from rhodecode.model.meta import Session @@ -238,7 +241,6 @@ class AdminUsersView(BaseAppView): return HTTPFound(h.route_path('edit_user_auth_tokens', user_id=user_id)) - @LoginRequired() @HasPermissionAllDecorator('hg.admin') @view_config( @@ -257,7 +259,6 @@ class AdminUsersView(BaseAppView): return self._get_template_context(c) - @LoginRequired() @HasPermissionAllDecorator('hg.admin') @view_config( @@ -282,4 +283,35 @@ class AdminUsersView(BaseAppView): c.active = 'user_groups_management' h.flash(_("Groups successfully changed"), category='success') - return HTTPFound(h.route_path('edit_user_groups_management', user_id=user_id)) + return HTTPFound(h.route_path( + 'edit_user_groups_management', user_id=user_id)) + + @LoginRequired() + @HasPermissionAllDecorator('hg.admin') + @view_config( + route_name='edit_user_audit_logs', request_method='GET', + renderer='rhodecode:templates/admin/users/user_edit.mako') + def user_audit_logs(self): + _ = self.request.translate + c = self.load_default_context() + + user_id = self.request.matchdict.get('user_id') + c.user = User.get_or_404(user_id, pyramid_exc=True) + self._redirect_for_default_user(c.user.username) + c.active = 'audit' + + p = safe_int(self.request.GET.get('page', 1), 1) + + filter_term = self.request.GET.get('filter') + c.user_log = UserModel().get_user_log(c.user, filter_term) + + def url_generator(**kw): + if filter_term: + kw['filter'] = filter_term + return self.request.current_route_path(_query=kw) + + c.user_log = Page(c.user_log, page=p, items_per_page=10, + url=url_generator) + c.filter_term = filter_term + return self._get_template_context(c) + diff --git a/rhodecode/controllers/admin/admin.py b/rhodecode/controllers/admin/admin.py --- a/rhodecode/controllers/admin/admin.py +++ b/rhodecode/controllers/admin/admin.py @@ -28,101 +28,17 @@ import logging from pylons import request, tmpl_context as c, url from pylons.controllers.util import redirect from sqlalchemy.orm import joinedload -from whoosh.qparser.default import QueryParser, query -from whoosh.qparser.dateparse import DateParserPlugin -from whoosh.fields import (TEXT, Schema, DATETIME) -from sqlalchemy.sql.expression import or_, and_, func from rhodecode.model.db import UserLog, PullRequest +from rhodecode.lib.user_log_filter import user_log_filter from rhodecode.lib.auth import LoginRequired, HasPermissionAllDecorator from rhodecode.lib.base import BaseController, render -from rhodecode.lib.utils2 import safe_int, remove_prefix, remove_suffix +from rhodecode.lib.utils2 import safe_int from rhodecode.lib.helpers import Page log = logging.getLogger(__name__) -# JOURNAL SCHEMA used only to generate queries in journal. We use whoosh -# querylang to build sql queries and filter journals -JOURNAL_SCHEMA = Schema( - username=TEXT(), - date=DATETIME(), - action=TEXT(), - repository=TEXT(), - ip=TEXT(), -) - - -def _journal_filter(user_log, search_term): - """ - Filters sqlalchemy user_log based on search_term with whoosh Query language - http://packages.python.org/Whoosh/querylang.html - - :param user_log: - :param search_term: - """ - log.debug('Initial search term: %r' % search_term) - qry = None - if search_term: - qp = QueryParser('repository', schema=JOURNAL_SCHEMA) - qp.add_plugin(DateParserPlugin()) - qry = qp.parse(unicode(search_term)) - log.debug('Filtering using parsed query %r' % qry) - - def wildcard_handler(col, wc_term): - if wc_term.startswith('*') and not wc_term.endswith('*'): - # postfix == endswith - wc_term = remove_prefix(wc_term, prefix='*') - return func.lower(col).endswith(wc_term) - elif wc_term.startswith('*') and wc_term.endswith('*'): - # wildcard == ilike - wc_term = remove_prefix(wc_term, prefix='*') - wc_term = remove_suffix(wc_term, suffix='*') - return func.lower(col).contains(wc_term) - - def get_filterion(field, val, term): - - if field == 'repository': - field = getattr(UserLog, 'repository_name') - elif field == 'ip': - field = getattr(UserLog, 'user_ip') - elif field == 'date': - field = getattr(UserLog, 'action_date') - elif field == 'username': - field = getattr(UserLog, 'username') - else: - field = getattr(UserLog, field) - log.debug('filter field: %s val=>%s' % (field, val)) - - # sql filtering - if isinstance(term, query.Wildcard): - return wildcard_handler(field, val) - elif isinstance(term, query.Prefix): - return func.lower(field).startswith(func.lower(val)) - elif isinstance(term, query.DateRange): - return and_(field >= val[0], field <= val[1]) - return func.lower(field) == func.lower(val) - - if isinstance(qry, (query.And, query.Term, query.Prefix, query.Wildcard, - query.DateRange)): - if not isinstance(qry, query.And): - qry = [qry] - for term in qry: - field = term.fieldname - val = (term.text if not isinstance(term, query.DateRange) - else [term.startdate, term.enddate]) - user_log = user_log.filter(get_filterion(field, val, term)) - elif isinstance(qry, query.Or): - filters = [] - for term in qry: - field = term.fieldname - val = (term.text if not isinstance(term, query.DateRange) - else [term.startdate, term.enddate]) - filters.append(get_filterion(field, val, term)) - user_log = user_log.filter(or_(*filters)) - - return user_log - class AdminController(BaseController): @@ -139,7 +55,7 @@ class AdminController(BaseController): # FILTERING c.search_term = request.GET.get('filter') try: - users_log = _journal_filter(users_log, c.search_term) + users_log = user_log_filter(users_log, c.search_term) except Exception: # we want this to crash for now raise diff --git a/rhodecode/controllers/journal.py b/rhodecode/controllers/journal.py --- a/rhodecode/controllers/journal.py +++ b/rhodecode/controllers/journal.py @@ -34,7 +34,7 @@ from webob.exc import HTTPBadRequest from pylons import request, tmpl_context as c, response, url from pylons.i18n.translation import _ -from rhodecode.controllers.admin.admin import _journal_filter +from rhodecode.controllers.admin.admin import user_log_filter from rhodecode.model.db import UserLog, UserFollowing, User, UserApiKeys from rhodecode.model.meta import Session import rhodecode.lib.helpers as h @@ -89,7 +89,7 @@ class JournalController(BaseController): .options(joinedload(UserLog.repository)) #filter try: - journal = _journal_filter(journal, c.search_term) + journal = user_log_filter(journal, c.search_term) except Exception: # we want this to crash for now raise diff --git a/rhodecode/lib/user_log_filter.py b/rhodecode/lib/user_log_filter.py new file mode 100644 --- /dev/null +++ b/rhodecode/lib/user_log_filter.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2010-2017 RhodeCode GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License, version 3 +# (only), as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# This program is dual-licensed. If you wish to learn more about the +# RhodeCode Enterprise Edition, including its added features, Support services, +# and proprietary license terms, please see https://rhodecode.com/licenses/ + +import logging + +from whoosh.qparser.default import QueryParser, query +from whoosh.qparser.dateparse import DateParserPlugin +from whoosh.fields import (TEXT, Schema, DATETIME) +from sqlalchemy.sql.expression import or_, and_, func + +from rhodecode.model.db import UserLog +from rhodecode.lib.utils2 import remove_prefix, remove_suffix + +# JOURNAL SCHEMA used only to generate queries in journal. We use whoosh +# querylang to build sql queries and filter journals +JOURNAL_SCHEMA = Schema( + username=TEXT(), + date=DATETIME(), + action=TEXT(), + repository=TEXT(), + ip=TEXT(), +) + +log = logging.getLogger(__name__) + + +def user_log_filter(user_log, search_term): + """ + Filters sqlalchemy user_log based on search_term with whoosh Query language + http://packages.python.org/Whoosh/querylang.html + + :param user_log: + :param search_term: + """ + log.debug('Initial search term: %r' % search_term) + qry = None + if search_term: + qp = QueryParser('repository', schema=JOURNAL_SCHEMA) + qp.add_plugin(DateParserPlugin()) + qry = qp.parse(unicode(search_term)) + log.debug('Filtering using parsed query %r' % qry) + + def wildcard_handler(col, wc_term): + if wc_term.startswith('*') and not wc_term.endswith('*'): + # postfix == endswith + wc_term = remove_prefix(wc_term, prefix='*') + return func.lower(col).endswith(wc_term) + elif wc_term.startswith('*') and wc_term.endswith('*'): + # wildcard == ilike + wc_term = remove_prefix(wc_term, prefix='*') + wc_term = remove_suffix(wc_term, suffix='*') + return func.lower(col).contains(wc_term) + + def get_filterion(field, val, term): + + if field == 'repository': + field = getattr(UserLog, 'repository_name') + elif field == 'ip': + field = getattr(UserLog, 'user_ip') + elif field == 'date': + field = getattr(UserLog, 'action_date') + elif field == 'username': + field = getattr(UserLog, 'username') + else: + field = getattr(UserLog, field) + log.debug('filter field: %s val=>%s' % (field, val)) + + # sql filtering + if isinstance(term, query.Wildcard): + return wildcard_handler(field, val) + elif isinstance(term, query.Prefix): + return func.lower(field).startswith(func.lower(val)) + elif isinstance(term, query.DateRange): + return and_(field >= val[0], field <= val[1]) + return func.lower(field) == func.lower(val) + + if isinstance(qry, (query.And, query.Term, query.Prefix, query.Wildcard, + query.DateRange)): + if not isinstance(qry, query.And): + qry = [qry] + for term in qry: + field = term.fieldname + val = (term.text if not isinstance(term, query.DateRange) + else [term.startdate, term.enddate]) + user_log = user_log.filter(get_filterion(field, val, term)) + elif isinstance(qry, query.Or): + filters = [] + for term in qry: + field = term.fieldname + val = (term.text if not isinstance(term, query.DateRange) + else [term.startdate, term.enddate]) + filters.append(get_filterion(field, val, term)) + user_log = user_log.filter(or_(*filters)) + + return user_log diff --git a/rhodecode/model/user.py b/rhodecode/model/user.py --- a/rhodecode/model/user.py +++ b/rhodecode/model/user.py @@ -33,6 +33,7 @@ from sqlalchemy.exc import DatabaseError from sqlalchemy.sql.expression import true, false from rhodecode import events +from rhodecode.lib.user_log_filter import user_log_filter from rhodecode.lib.utils2 import ( safe_unicode, get_current_rhodecode_user, action_logger_generic, AttributeDict, str2bool) @@ -40,7 +41,7 @@ from rhodecode.lib.caching_query import from rhodecode.model import BaseModel from rhodecode.model.auth_token import AuthTokenModel from rhodecode.model.db import ( - User, UserToPerm, UserEmailMap, UserIpMap) + or_, joinedload, User, UserToPerm, UserEmailMap, UserIpMap, UserLog) from rhodecode.lib.exceptions import ( DefaultUserException, UserOwnsReposException, UserOwnsRepoGroupsException, UserOwnsUserGroupsException, NotAllowedToCreateUserError) @@ -847,3 +848,14 @@ class UserModel(BaseModel): Session().commit() return + + def get_user_log(self, user, filter_term): + user_log = UserLog.query()\ + .filter(or_(UserLog.user_id == user.user_id, + UserLog.username == user.username))\ + .options(joinedload(UserLog.user))\ + .options(joinedload(UserLog.repository))\ + .order_by(UserLog.action_date.desc()) + + user_log = user_log_filter(user_log, filter_term) + return user_log diff --git a/rhodecode/templates/admin/users/user_edit.mako b/rhodecode/templates/admin/users/user_edit.mako --- a/rhodecode/templates/admin/users/user_edit.mako +++ b/rhodecode/templates/admin/users/user_edit.mako @@ -37,11 +37,8 @@
  • ${_('Permissions summary')}
  • ${_('Emails')}
  • ${_('Ip Whitelist')}
  • - -
  • - ${_('User Groups Management')} -
  • - +
  • ${_('User Groups Management')}
  • +
  • ${_('User audit')}
  • diff --git a/rhodecode/templates/admin/users/user_edit_audit.mako b/rhodecode/templates/admin/users/user_edit_audit.mako new file mode 100644 --- /dev/null +++ b/rhodecode/templates/admin/users/user_edit_audit.mako @@ -0,0 +1,65 @@ +## -*- coding: utf-8 -*- +<%namespace name="base" file="/base/base.mako"/> + + +
    +
    +

    ${_('User Audit Logs')} - + ${_ungettext('%s entry', '%s entries', c.user_log.item_count) % (c.user_log.item_count)} +

    +
    +
    + + ${h.form(None, id_="filter_form", method="get")} + + + ${h.end_form()} +

    ${_('Example Queries')}

    + + % if c.user_log: + + + + + + + + + + %for cnt,l in enumerate(c.user_log): + + + + + + + + + %endfor +
    ${_('Username')}${_('Action')}${_('Repository')}${_('Date')}${_('From IP')}
    + %if l.user is not None: + ${base.gravatar_with_user(l.user.email)} + %else: + ${l.username} + %endif + ${h.action_parser(l)[0]()} +
    + ${h.literal(h.action_parser(l)[1]())} +
    +
    + %if l.repository is not None: + ${h.link_to(l.repository.repo_name,h.url('summary_home',repo_name=l.repository.repo_name))} + %else: + ${l.repository_name} + %endif + ${h.format_date(l.action_date)}${l.user_ip}
    + +
    + ${c.user_log.pager('$link_previous ~2~ $link_next')} +
    + % else: + ${_('No actions yet')} + % endif + +
    +