api: record actions in journal (#48010)

This commit is contained in:
Valentin Deniaud 2021-05-19 17:29:38 +02:00
parent b7b9a3babb
commit 78b07aa497
10 changed files with 186 additions and 32 deletions

View File

@ -343,6 +343,7 @@ class PasswordChange(BaseRpcView):
def rpc(self, request, serializer):
serializer.user.set_password(serializer.validated_data['new_password'])
serializer.user.save()
request.journal.record('manager.user.password.change', form=serializer, api=True)
return {'result': 1}, status.HTTP_200_OK
@ -778,8 +779,19 @@ class UsersAPI(api_mixins.GetOrCreateMixinView, HookMixin, ExceptionHandlerMixin
if not self.request.user.has_perm(perm):
raise PermissionDenied(u'You do not have permission %s' % perm)
def perform_create(self, serializer):
super().perform_create(serializer)
self.request.journal.record('manager.user.creation', form=serializer, api=True)
def perform_update(self, serializer):
super().perform_update(serializer)
attributes = serializer.validated_data.pop('attributes', {})
serializer.validated_data.update(attributes)
self.request.journal.record('manager.user.profile.edit', form=serializer, api=True)
def perform_destroy(self, instance):
self.check_perm('custom_user.delete_user', instance.ou)
self.request.journal.record('manager.user.deletion', target_user=instance, api=True)
super(UsersAPI, self).perform_destroy(instance)
class SynchronizationSerializer(serializers.Serializer):
@ -820,6 +832,7 @@ class UsersAPI(api_mixins.GetOrCreateMixinView, HookMixin, ExceptionHandlerMixin
)
utils.send_password_reset_mail(user, request=request)
request.journal.record('manager.user.password.reset.request', target_user=user, api=True)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=['post'], permission_classes=(DjangoPermission('custom_user.change_user'),))
@ -875,8 +888,17 @@ class RolesAPI(api_mixins.GetOrCreateMixinView, ExceptionHandlerMixin, ModelView
def perform_destroy(self, instance):
if not self.request.user.has_perm(perm='a2_rbac.delete_role', obj=instance):
raise PermissionDenied(u'User %s can\'t create role %s' % (self.request.user, instance))
self.request.journal.record('manager.role.deletion', role=instance, api=True)
super(RolesAPI, self).perform_destroy(instance)
def perform_create(self, serializer):
super().perform_create(serializer)
self.request.journal.record('manager.role.creation', role=serializer.instance, api=True)
def perform_update(self, serializer):
super().perform_update(serializer)
self.request.journal.record('manager.role.edit', role=serializer.instance, form=serializer, api=True)
class RolesMembersAPI(UsersAPI):
def initial(self, request, *args, **kwargs):
@ -919,6 +941,7 @@ class RoleMembershipAPI(ExceptionHandlerMixin, APIView):
if not request.user.has_perm('a2_rbac.manage_members_role', obj=self.role):
raise PermissionDenied(u'User not allowed to manage role members')
self.role.members.add(self.member)
request.journal.record('manager.role.membership.grant', role=self.role, member=self.member, api=True)
return Response(
{'result': 1, 'detail': _('User successfully added to role')}, status=status.HTTP_201_CREATED
)
@ -927,6 +950,9 @@ class RoleMembershipAPI(ExceptionHandlerMixin, APIView):
if not request.user.has_perm('a2_rbac.manage_members_role', obj=self.role):
raise PermissionDenied(u'User not allowed to manage role members')
self.role.members.remove(self.member)
request.journal.record(
'manager.role.membership.removal', role=self.role, member=self.member, api=True
)
return Response(
{'result': 1, 'detail': _('User successfully removed from role')}, status=status.HTTP_200_OK
)
@ -944,7 +970,7 @@ class RoleMembershipsAPI(ExceptionHandlerMixin, APIView):
Role = get_role_model()
User = get_user_model()
self.role = get_object_or_404(Role, uuid=kwargs['role_uuid'])
self.members = []
self.members = set()
perm = 'a2_rbac.manage_members_role'
authorized = request.user.has_perm(perm, obj=self.role)
@ -967,7 +993,7 @@ class RoleMembershipsAPI(ExceptionHandlerMixin, APIView):
_("Missing 'uuid' key for dict entry %s " "of the 'data' payload") % entry
)
try:
self.members.append(User.objects.get(uuid=uuid))
self.members.add(User.objects.get(uuid=uuid))
except User.DoesNotExist:
raise ValidationError(_('No known user for UUID %s') % entry['uuid'])
@ -976,18 +1002,27 @@ class RoleMembershipsAPI(ExceptionHandlerMixin, APIView):
def post(self, request, *args, **kwargs):
self.role.members.add(*self.members)
for member in self.members:
request.journal.record('manager.role.membership.grant', role=self.role, member=member, api=True)
return Response(
{'result': 1, 'detail': _('Users successfully added to role')}, status=status.HTTP_201_CREATED
)
def delete(self, request, *args, **kwargs):
self.role.members.remove(*self.members)
for member in self.members:
request.journal.record('manager.role.membership.removal', role=self.role, member=member, api=True)
return Response(
{'result': 1, 'detail': _('Users successfully removed from role')}, status=status.HTTP_200_OK
)
def patch(self, request, *args, **kwargs):
old_members = set(self.role.members.all())
self.role.members.set(self.members)
for member in self.members:
request.journal.record('manager.role.membership.grant', role=self.role, member=member, api=True)
for member in old_members.difference(self.members):
request.journal.record('manager.role.membership.removal', role=self.role, member=member, api=True)
return Response(
{'result': 1, 'detail': _('Users successfully assigned to role')}, status=status.HTTP_200_OK
)

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.19 on 2021-05-19 15:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('journal', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='event',
name='api',
field=models.BooleanField(default=False, verbose_name='API'),
),
]

View File

@ -86,15 +86,20 @@ class EventTypeDefinition(metaclass=EventTypeDefinitionMeta):
retention_days = None
@classmethod
def record(cls, user=None, session=None, references=None, data=None):
def record(cls, user=None, session=None, references=None, data=None, api=False):
event_type = EventType.objects.get_for_name(cls.name)
if user and not isinstance(user, User):
# API user from DRF or OIDC
user = None
Event.objects.create(
type=event_type,
user=user,
session_id=session and session.session_key,
references=references or None, # NULL values take less space
data=data or None, # NULL values take less space
api=api,
)
@classmethod
@ -321,6 +326,8 @@ class Event(models.Model):
data = JSONField(verbose_name=_('data'), null=True)
api = models.BooleanField(verbose_name=_('API'), default=False)
objects = EventManager.from_queryset(EventQuerySet)()
def __init__(self, *args, **kwargs):

View File

@ -136,3 +136,12 @@ to users with this email address.</tt>.'''
You can use <tt>username:john</tt> to find all events related \
to users whose username is <tt>john</tt>.'''
)
def search_by_api(self, lexem):
yield Q(api=bool(lexem == 'true'))
search_by_api.documentation = _(
'''\
You can use <tt>api:true</tt> to find all events related \
to API calls.'''
)

View File

@ -16,7 +16,7 @@
<tr data-event-id="{{ event.id }}" data-event-cursor="{{ event.cursor }}" data-event-type="{{ event.type.name }}">
<td class="journal-list--timestamp-column">{% block event-timestamp %}{{ event.timestamp }}{% endblock %}</td>
<td class="journal-list--user-column" {% if event.user %}data-user-id="{{ event.user.id }}"{% endif %}>{% block event-user %}{% firstof event.user.get_full_name event.user "-" %}{% endblock %}</td>
<td class="journal-list--session-column">{% block event-session %}{{ event.session_id_shortened|default:"-" }}{% endblock %}</td>
<td class="journal-list--session-column">{% block event-session %}{% if event.api %}API{% else %}{{ event.session_id_shortened|default:"-" }}{% endif %}{% endblock %}</td>
<td class="journal-list--message-column">{% block event-message %}{{ event.message|default:"-" }}{% endblock %}</td>
</tr>
{% if forloop.last %}

View File

@ -26,6 +26,9 @@ def _json_value(value):
def form_to_old_new(form):
if hasattr(form, 'validated_data'):
# form is a DRF serializer
return {'new': {k: _json_value(v) for k, v in form.validated_data.items()}}
old = {}
new = {}
for key in form.changed_data:

View File

@ -42,8 +42,8 @@ class ManagerUserCreation(EventTypeDefinition):
label = _('user creation')
@classmethod
def record(cls, user, session, form):
super().record(user=user, session=session, references=[form.instance])
def record(cls, user, session, form, api=False):
super().record(user=user, session=session, references=[form.instance], api=api)
@classmethod
def get_message(cls, event, context):
@ -62,8 +62,10 @@ class ManagerUserProfileEdit(EventTypeDefinition):
label = _('user profile edit')
@classmethod
def record(cls, user, session, form):
super().record(user=user, session=session, references=[form.instance], data=form_to_old_new(form))
def record(cls, user, session, form, api=False):
super().record(
user=user, session=session, references=[form.instance], data=form_to_old_new(form), api=api
)
@classmethod
def get_message(cls, event, context):
@ -107,12 +109,13 @@ class ManagerUserPasswordChange(EventTypeDefinition):
label = _('user password change')
@classmethod
def record(cls, user, session, form):
def record(cls, user, session, form, api=False):
cleaned_data = getattr(form, 'cleaned_data', {})
data = {
'generate_password': form.cleaned_data['generate_password'],
'send_mail': form.cleaned_data['send_mail'],
'generate_password': cleaned_data.get('generate_password', False),
'send_mail': cleaned_data.get('send_mail', False),
}
super().record(user=user, session=session, references=[form.instance], data=data)
super().record(user=user, session=session, references=[form.instance], data=data, api=api)
@classmethod
def get_message(cls, event, context):
@ -137,9 +140,9 @@ class ManagerUserPasswordResetRequest(EventTypeDefinition):
label = _('user password reset request')
@classmethod
def record(cls, user, session, target_user):
def record(cls, user, session, target_user, api=False):
super().record(
user=user, session=session, references=[target_user], data={'email': target_user.email}
user=user, session=session, references=[target_user], data={'email': target_user.email}, api=api
)
@classmethod
@ -256,8 +259,8 @@ class ManagerUserDeletion(EventTypeDefinition):
label = _('user deletion')
@classmethod
def record(cls, user, session, target_user):
super().record(user=user, session=session, references=[target_user])
def record(cls, user, session, target_user, api=False):
super().record(user=user, session=session, references=[target_user], api=api)
@classmethod
def get_message(cls, event, context):
@ -296,7 +299,7 @@ class ManagerUserSSOAuthorizationDeletion(EventTypeWithService):
class RoleEventsMixin(EventTypeDefinition):
@classmethod
def record(self, user, session, role, references=None, data=None):
def record(self, user, role, session=None, references=None, data=None, api=False):
references = references or []
references = [role] + references
data = data or {}
@ -306,6 +309,7 @@ class RoleEventsMixin(EventTypeDefinition):
session=session,
references=references,
data=data,
api=api,
)
@ -328,8 +332,8 @@ class ManagerRoleEdit(RoleEventsMixin):
label = _('role edit')
@classmethod
def record(cls, user, session, role, form):
super().record(user=user, session=session, role=role, data=form_to_old_new(form))
def record(cls, user, session, role, form, api=False):
super().record(user=user, session=session, role=role, data=form_to_old_new(form), api=api)
@classmethod
def get_message(cls, event, context):
@ -362,9 +366,9 @@ class ManagerRoleMembershipGrant(RoleEventsMixin):
label = _('role membership grant')
@classmethod
def record(cls, user, session, role, member):
def record(cls, user, session, role, member, api=False):
data = {'member_name': member.get_full_name()}
super().record(user=user, session=session, role=role, references=[member], data=data)
super().record(user=user, session=session, role=role, references=[member], data=data, api=api)
@classmethod
def get_message(cls, event, context):
@ -384,9 +388,9 @@ class ManagerRoleMembershipRemoval(RoleEventsMixin):
label = _('role membership removal')
@classmethod
def record(cls, user, session, role, member):
def record(cls, user, session, role, member, api=False):
data = {'member_name': member.get_full_name()}
super().record(user=user, session=session, role=role, references=[member], data=data)
super().record(user=user, session=session, role=role, references=[member], data=data, api=api)
@classmethod
def get_message(cls, event, context):

View File

@ -42,7 +42,7 @@ from authentic2.utils import good_next_url
from django_rbac.models import SEARCH_OP
from django_rbac.utils import get_ou_model, get_role_model
from .utils import basic_authorization_header, get_link_from_mail, login
from .utils import assert_event, basic_authorization_header, get_link_from_mail, login
pytestmark = pytest.mark.django_db
@ -243,6 +243,7 @@ def test_api_users_update_with_email_verified(settings, app, admin, simple_user)
user = User.objects.get(id=simple_user.id)
assert user.email_verified
assert resp.json['email_verified']
assert_event('manager.user.profile.edit', user=admin, api=True)
user.email_verified = True
user.email = 'johnny.doeny@foo.bar'
@ -329,6 +330,7 @@ def test_api_users_create_with_email_verified(settings, app, admin):
assert resp.json['email_verified']
user = User.objects.get(uuid=resp.json['uuid'])
assert user.email_verified
assert_event('manager.user.creation', user=admin, api=True)
def test_api_users_create_without_email_verified(settings, app, admin):
@ -786,6 +788,13 @@ def test_api_role_add_member(app, api_user, role, member):
elif authorized:
assert resp.json['result'] == 1
assert resp.json['detail'] == 'User successfully added to role'
assert_event(
'manager.role.membership.grant',
user=api_user if isinstance(api_user, User) else None,
api=True,
role_name=role.name,
member_name=member.get_full_name(),
)
else:
assert resp.json['result'] == 0
assert resp.json['errors'] == 'User not allowed to manage role members'
@ -812,6 +821,13 @@ def test_api_role_remove_member(app, api_user, role, member):
assert resp.json['detail'] == 'User successfully removed from role'
resp = app.get('/api/roles/{0}/members/{1}/'.format(role.uuid, member.uuid), status=404)
assert resp.json == {'result': 0, 'errors': {'detail': 'Not found.'}}
assert_event(
'manager.role.membership.removal',
user=api_user if isinstance(api_user, User) else None,
api=True,
role_name=role.name,
member_name=member.get_full_name(),
)
else:
assert resp.json['result'] == 0
assert resp.json['errors'] == 'User not allowed to manage role members'
@ -847,6 +863,13 @@ def test_api_role_add_members(app, api_user, role, member, member_rando2):
assert resp.json['detail'] == 'Users successfully added to role'
for m in [member, member_rando2]:
assert m in role.members.all()
assert_event(
'manager.role.membership.grant',
user=api_user if isinstance(api_user, User) else None,
api=True,
role_name=role.name,
member_name=m.get_full_name(),
)
else:
assert resp.json['result'] == 0
assert resp.json['errors'] == 'User not allowed to manage role members'
@ -882,12 +905,22 @@ def test_api_role_remove_members(app, api_user, role, member, member_rando2):
assert resp.json['detail'] == 'Users successfully removed from role'
for m in [member, member_rando2]:
assert m not in role.members.all()
assert_event(
'manager.role.membership.removal',
user=api_user if isinstance(api_user, User) else None,
api=True,
role_name=role.name,
member_name=m.get_full_name(),
)
else:
assert resp.json['result'] == 0
assert resp.json['errors'] == 'User not allowed to manage role members'
def test_api_role_set_members(app, api_user, role, member, member_rando2):
def test_api_role_set_members(app, api_user, role, member, member_rando2, ou_rando):
user = User.objects.create(
username='test3', first_name='test3', last_name='test3', email='test3@test.org', ou=ou_rando
)
app.authorization = ('Basic', (api_user.username, api_user.username))
authorized = api_user.has_perm('a2_rbac.manage_members_role', role)
@ -903,6 +936,7 @@ def test_api_role_set_members(app, api_user, role, member, member_rando2):
payload = {"data": []}
role.members.add(user)
for m in [member, member_rando2, member_rando2]: # test no duplicate
payload['data'].append({"uuid": m.uuid})
@ -918,6 +952,20 @@ def test_api_role_set_members(app, api_user, role, member, member_rando2):
assert len(role.members.all()) == 2
for m in [member, member_rando2]:
assert m in role.members.all()
assert_event(
'manager.role.membership.grant',
user=api_user if isinstance(api_user, User) else None,
api=True,
role_name=role.name,
member_name=m.get_full_name(),
)
assert_event(
'manager.role.membership.removal',
user=api_user if isinstance(api_user, User) else None,
api=True,
role_name=role.name,
member_name=user.get_full_name(),
)
else:
assert resp.json['result'] == 0
assert resp.json['errors'] == 'User not allowed to manage role members'
@ -1226,6 +1274,7 @@ def test_password_change(app, ou1, admin):
response = app.post_json(url, params=payload)
assert response.json['result'] == 1
assert User.objects.get(username='john.doe').check_password('password2')
assert_event('manager.user.password.change', user=admin, api=True)
def test_password_reset(app, ou1, admin, user_ou1, mailoutbox):
@ -1247,6 +1296,7 @@ def test_password_reset(app, ou1, admin, user_ou1, mailoutbox):
mail = mailoutbox[0]
assert mail.to[0] == email
assert 'http://testserver/accounts/password/reset/confirm/' in mail.body
assert_event('manager.user.password.reset.request', user=admin, api=True)
def test_users_email(app, ou1, admin, user_ou1, mailoutbox):
@ -1274,6 +1324,7 @@ def test_api_delete_role(app, admin_ou1, role_ou1):
app.authorization = ('Basic', (admin_ou1.username, admin_ou1.username))
app.delete('/api/roles/{}/'.format(role_ou1.uuid))
assert not len(Role.objects.filter(slug='role_ou1'))
assert_event('manager.role.deletion', user=admin_ou1, api=True, role_name=role_ou1.name)
def test_api_delete_role_unauthorized(app, simple_user, role_ou1):
@ -1289,6 +1340,7 @@ def test_api_patch_role(app, admin_ou1, role_ou1):
'slug': 'updated-role',
}
app.patch_json('/api/roles/{}/'.format(role_ou1.uuid), params=role_data)
assert_event('manager.role.edit', user=admin_ou1, api=True, role_name=role_ou1.name)
# The role API won't change the organizational unit attribute:
role_ou1.refresh_from_db()
@ -1317,6 +1369,7 @@ def test_api_put_role(app, admin_ou1, role_ou1, ou1):
assert role_ou1.name == 'updated-role'
assert role_ou1.slug == 'updated-role'
assert role_ou1.ou.slug == 'ou1'
assert_event('manager.role.edit', user=admin_ou1, api=True, role_name=role_ou1.name)
def test_api_put_role_unauthorized(app, simple_user, role_ou1, ou1):
@ -1334,6 +1387,7 @@ def test_api_post_role(app, admin_ou1, ou1):
role_data = {'slug': 'coffee-manager', 'name': 'Coffee Manager', 'ou': 'ou1'}
resp = app.post_json('/api/roles/', params=role_data)
assert_event('manager.role.creation', user=admin_ou1, api=True, role_name='Coffee Manager')
assert isinstance(resp.json, dict)
uuid = resp.json['uuid']
@ -1870,12 +1924,14 @@ def test_api_users_required_date_attributes(settings, app, admin, simple_user):
]
# update with values pass
del payload['id']
payload['prefered_color'] = 'blue'
payload['date'] = '1515-1-15'
payload['birthdate'] = '1900-2-22'
payload['date'] = '1515-01-15'
payload['birthdate'] = '1900-02-22'
resp = app.put_json(
'/api/users/{}/'.format(simple_user.uuid), params=payload, headers=headers, status=200
)
assert_event('manager.user.profile.edit', user=admin, api=True, new=payload)
# value are properly returned on a get
resp = app.get('/api/users/{}/'.format(simple_user.uuid), headers=headers, status=200)
@ -2247,6 +2303,13 @@ def test_api_password_change_user_delete(app, settings, admin, ou1):
assert User.objects.get(username='john.doe').check_password('password2')
def test_api_users_delete(settings, app, admin, simple_user):
headers = basic_authorization_header(admin)
resp = app.delete_json('/api/users/{}/'.format(simple_user.uuid), headers=headers)
assert not User.objects.filter(pk=simple_user.pk).exists()
assert_event('manager.user.deletion', user=admin, api=True)
@pytest.mark.skipif(drf_version.startswith('3.4'), reason='no support for old django rest framework')
def test_api_statistics_list(app, admin):
OU = get_ou_model()

View File

@ -79,7 +79,7 @@ def events(db, freezer):
make("user.login.failure", username="agent")
make("user.login", user=user, session=session1, how="password")
make("user.password.change", user=user, session=session1)
edit_profile_form = mock.Mock()
edit_profile_form = mock.Mock(spec=["instance", "initial", "changed_data", "cleaned_data"])
edit_profile_form.initial = {'email': "user@example.com", 'first_name': "John"}
edit_profile_form.changed_data = ["first_name"]
edit_profile_form.cleaned_data = {'first_name': "Jane"}
@ -1030,6 +1030,15 @@ def test_search(app, superuser, events):
for p in zip(pq('tbody td.journal-list--user-column'), pq('tbody td.journal-list--message-column'))
] == [['Johnny doe', 'login using password']]
Event.objects.filter(type__name='manager.user.creation').update(api=True)
response.form.set('search', 'api:true')
response = response.form.submit()
assert (
text_content(response.pyquery('tbody tr td.journal-list--message-column')[0]).strip()
== 'creation of user "Johnny doe"'
)
assert text_content(response.pyquery('tbody tr td.journal-list--session-column')[0]).strip() == 'API'
response.form.set('search', '')
response.form['event_type'].select(text='Profile changes')
response = response.form.submit()

View File

@ -264,8 +264,8 @@ def text_content(node):
return ''.join(node.itertext()) if node is not None else ''
def assert_event(event_type_name, user=None, session=None, service=None, target_user=None, **data):
qs = Event.objects.filter(type__name=event_type_name)
def assert_event(event_type_name, user=None, session=None, service=None, target_user=None, api=False, **data):
qs = Event.objects.filter(type__name=event_type_name, api=api)
if user:
qs = qs.filter(user=user)
else:
@ -281,9 +281,13 @@ def assert_event(event_type_name, user=None, session=None, service=None, target_
if target_user:
qs = qs.which_references(target_user)
assert qs.count() == 1
count = qs.count()
assert count > 0
if data:
if not data:
assert count == 1
if data and count == 1:
event = qs.get()
assert event.data, 'no event.data, should be %s' % data
for key, value in data.items():
@ -293,6 +297,8 @@ def assert_event(event_type_name, user=None, session=None, service=None, target_
event.data.get(key),
value,
)
elif data and count > 1:
assert qs.filter(**{'data__' + k: v for k, v in data.items()}).count() == 1
@httmock.HTTMock