admin-users: add audit page to allow showing user actions in RhodeCode.

- this is a helper filter that allows to filter out admin journal for particular user.
- refs #5256
This commit is contained in:
Marcin Kuzminski 2017-03-23 13:50:55 +01:00
parent e621d8dd88
commit a5b6055c00
8 changed files with 237 additions and 98 deletions

View file

@ -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()

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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 <http://www.gnu.org/licenses/>.
#
# 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

View file

@ -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 FromCache
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

View file

@ -37,11 +37,8 @@
<li class="${'active' if c.active=='perms_summary' else ''}"><a href="${h.url('edit_user_perms_summary', user_id=c.user.user_id)}">${_('Permissions summary')}</a></li>
<li class="${'active' if c.active=='emails' else ''}"><a href="${h.url('edit_user_emails', user_id=c.user.user_id)}">${_('Emails')}</a></li>
<li class="${'active' if c.active=='ips' else ''}"><a href="${h.url('edit_user_ips', user_id=c.user.user_id)}">${_('Ip Whitelist')}</a></li>
<li class="${'active' if c.active=='groups' else ''}">
<a href="${h.route_path('edit_user_groups_management', user_id=c.user.user_id)}">${_('User Groups Management')}</a>
</li>
<li class="${'active' if c.active=='groups' else ''}"><a href="${h.route_path('edit_user_groups_management', user_id=c.user.user_id)}">${_('User Groups Management')}</a></li>
<li class="${'active' if c.active=='audit' else ''}"><a href="${h.route_path('edit_user_audit_logs', user_id=c.user.user_id)}">${_('User audit')}</a></li>
</ul>
</div>

View file

@ -0,0 +1,65 @@
## -*- coding: utf-8 -*-
<%namespace name="base" file="/base/base.mako"/>
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">${_('User Audit Logs')} -
${_ungettext('%s entry', '%s entries', c.user_log.item_count) % (c.user_log.item_count)}
</h3>
</div>
<div class="panel-body">
${h.form(None, id_="filter_form", method="get")}
<input class="q_filter_box ${'' if c.filter_term else 'initial'}" id="j_filter" size="15" type="text" name="filter" value="${c.filter_term or ''}" placeholder="${_('audit filter...')}"/>
<input type='submit' value="${_('filter')}" class="btn" />
${h.end_form()}
<p class="tooltip filterexample" style="position: inherit" title="${h.tooltip(h.journal_filter_help())}">${_('Example Queries')}</p>
% if c.user_log:
<table class="rctable admin_log">
<tr>
<th>${_('Username')}</th>
<th>${_('Action')}</th>
<th>${_('Repository')}</th>
<th>${_('Date')}</th>
<th>${_('From IP')}</th>
</tr>
%for cnt,l in enumerate(c.user_log):
<tr class="parity${cnt%2}">
<td class="td-user">
%if l.user is not None:
${base.gravatar_with_user(l.user.email)}
%else:
${l.username}
%endif
</td>
<td class="td-journalaction">${h.action_parser(l)[0]()}
<div class="journal_action_params">
${h.literal(h.action_parser(l)[1]())}
</div>
</td>
<td class="td-componentname">
%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
</td>
<td class="td-time">${h.format_date(l.action_date)}</td>
<td class="td-ip">${l.user_ip}</td>
</tr>
%endfor
</table>
<div class="pagination-wh pagination-left">
${c.user_log.pager('$link_previous ~2~ $link_next')}
</div>
% else:
${_('No actions yet')}
% endif
</div>
</div>