Skip to content

Commit 9ad62c0

Browse files
committed
Organizations: show audit logs
1 parent fcc7140 commit 9ad62c0

File tree

9 files changed

+364
-56
lines changed

9 files changed

+364
-56
lines changed

readthedocs/audit/filters.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,18 @@ class UserSecurityLogFilter(FilterSet):
2121

2222
class Meta:
2323
model = AuditLog
24-
fields = ['ip', 'project', 'action']
24+
fields = []
25+
26+
27+
class OrganizationSecurityLogFilter(UserSecurityLogFilter):
28+
29+
action = ChoiceFilter(
30+
field_name='action',
31+
lookup_expr='exact',
32+
choices=[
33+
(AuditLog.AUTHN, _('Authentication success')),
34+
(AuditLog.AUTHN_FAILURE, _('Authentication failure')),
35+
(AuditLog.PAGEVIEW, _('Page view')),
36+
],
37+
)
38+
user = CharFilter(field_name='log_user_username', lookup_expr='exact')

readthedocs/audit/models.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,5 +190,23 @@ def save(self, **kwargs):
190190
self.log_organization_slug = organization.slug
191191
super().save(**kwargs)
192192

193+
def auth_backend_display(self):
194+
"""
195+
Get a string representation for backends that aren't part of the normal login.
196+
197+
.. note::
198+
199+
The backends listed here are implemented on .com only.
200+
"""
201+
backend = self.auth_backend or ''
202+
backend_displays = {
203+
'TemporaryAccessTokenBackend': _('shared link'),
204+
'TemporaryAccessPasswordBackend': _('shared password'),
205+
}
206+
for name, display in backend_displays.items():
207+
if name in backend:
208+
return display
209+
return ''
210+
193211
def __str__(self):
194212
return self.action
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
{% load i18n %}
2+
3+
{% comment %}
4+
If `omit_user` is given, the username attached to the log isn't shown.
5+
This is useful when listing logs for the same user.
6+
{% endcomment %}
7+
8+
<div class="module-list">
9+
<div class="module-list-wrapper">
10+
<ul>
11+
{% for log in object_list %}
12+
<li class="module-item">
13+
{% if log.action == AuditLog.AUTHN %}
14+
{% if omit_user %}
15+
{% blocktrans trimmed with action=log.action method=log.auth_backend_display %}
16+
<a href="?action={{ action }}" title="{{ method }}">Authenticated</a>
17+
{% endblocktrans %}
18+
{% elif log.log_user_id %}
19+
{% blocktrans trimmed with action=log.action user=log.log_user_username method=log.auth_backend_display %}
20+
<a href="?user={{ user }}">
21+
<code>{{ user }}</code>
22+
</a>
23+
<a href="?action={{ action }}" title="{{ method }}">authenticated</a>
24+
{% endblocktrans %}
25+
{% else %}
26+
{% blocktrans trimmed with action=log.action method=log.auth_backend_display %}
27+
User <a href="?action={{ action }}" title="{{ method }}">authenticated</a>
28+
{% endblocktrans %}
29+
{% endif %}
30+
{% elif log.action == AuditLog.AUTHN_FAILURE %}
31+
{% blocktrans trimmed with action=log.action method=log.auth_backend_display %}
32+
<a href="?action={{ action }}" title="{{ method }}">Authentication attempt</a>
33+
{% endblocktrans %}
34+
{% elif log.action == AuditLog.PAGEVIEW %}
35+
{% if log.log_user_id %}
36+
{% blocktrans trimmed with action=log.action user=log.log_user_username path=log.resource %}
37+
<a href="?user={{ user }}">
38+
<code>{{ user }}</code>
39+
</a>
40+
<a href="?action={{ action }}" title="{{ path }}">visited</a> a page
41+
{% endblocktrans %}
42+
{% else %}
43+
{% blocktrans trimmed with action=log.action user=log.log_user_username path=log.resource %}
44+
A user <a href="?action={{ action }}" title="{{ path }}">visited</a> a page
45+
{% endblocktrans %}
46+
{% endif %}
47+
{% endif %}
48+
49+
{% trans "from" %}
50+
51+
<a href="?ip={{ log.ip }}" title="{{ log.browser }}">
52+
<code>{{ log.ip }}</code>
53+
</a>
54+
55+
{# If the authentication was on a doc domain. #}
56+
{% if log.log_project_id %}
57+
{% trans "on" %}
58+
{% if log.project %}
59+
<a href="{% url 'projects_detail' log.log_project_slug %}">
60+
<code>{{ log.log_project_slug }}</code>
61+
</a>
62+
{% else %}
63+
{# The original project has been deleted, don't link to it. #}
64+
<code title="{{ log.resource }}">{{ log.log_project_slug }}</code>
65+
{% endif %}
66+
{% endif %}
67+
68+
<span class="quiet right" title="{{ log.created|date:"DATETIME_FORMAT" }}">
69+
{% blocktrans trimmed with log.created|timesince as date %}
70+
{{ date }} ago
71+
{% endblocktrans %}
72+
</span>
73+
</li>
74+
{% empty %}
75+
<li class="module-item">
76+
<p class="quiet">
77+
{% trans 'No activity logged yet' %}
78+
</p>
79+
</li>
80+
{% endfor %}
81+
</ul>
82+
</div>
83+
</div>

readthedocs/organizations/templates/organizations/admin/base.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
<ul>
1212
<li class="{% block organization-admin-details %}{% endblock %}"><a href="{% url 'organization_edit' organization.slug %}">{% trans "Details" %}</a></li>
1313
<li class="{% block organization-admin-owners %}{% endblock %}"><a href="{% url 'organization_owners' organization.slug %}">{% trans "Owners" %}</a></li>
14+
<li class="{% block organization-admin-security-log %}{% endblock %}"><a href="{% url 'organization_security_log' organization.slug %}">{% trans "Security Log" %}</a></li>
1415
</ul>
1516
<div>
1617
<h2>{% block edit_content_header %}{% endblock %}</h2>
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{% extends "organizations/admin/base.html" %}
2+
3+
{% load i18n %}
4+
{% load pagination_tags %}
5+
6+
{% block title %}{% trans "Security Log" %}{% endblock %}
7+
8+
{% block organization-admin-security-log %}active{% endblock %}
9+
10+
{% block edit_content_header %} {% trans "Security Log" %} {% endblock %}
11+
12+
{% block edit_content %}
13+
14+
{% if not enabled %}
15+
{% include 'projects/includes/feature_disabled.html' with organization=organization %}
16+
{% elif days_limit and days_limit > 0 %}
17+
<p>
18+
{% blocktrans trimmed with days_limit as days_limit %}
19+
Showing logs from the last {{ days_limit }} days.
20+
{% endblocktrans %}
21+
</p>
22+
{% else %}
23+
<p>{% trans "Showing logs from all time." %}</p>
24+
{% endif %}
25+
</p>
26+
27+
{% autopaginate object_list 15 %}
28+
29+
<div class="module">
30+
<form method="get">
31+
<button type="submit" name="download" value="true">{% trans "Download all data" %}</button>
32+
</form>
33+
34+
{% include "audit/list_logs.html" with omit_user=False %}
35+
</div>
36+
{% paginate %}
37+
{% endblock %}

readthedocs/organizations/tests/test_views.py

Lines changed: 99 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1+
import itertools
2+
13
import django_dynamic_fixture as fixture
24
from allauth.account.views import SignupView
35
from django.contrib.auth.models import User
46
from django.core.cache import cache
57
from django.test import TestCase
68
from django.test.utils import override_settings
79
from django.urls import reverse
10+
from django_dynamic_fixture import get
811

12+
from readthedocs.audit.models import AuditLog
913
from readthedocs.organizations.models import (
1014
Organization,
1115
Team,
@@ -24,14 +28,34 @@ class OrganizationViewTests(RequestFactoryTestMixin, TestCase):
2428
"""Organization views tests."""
2529

2630
def setUp(self):
27-
self.owner = fixture.get(User)
28-
self.project = fixture.get(Project)
29-
self.organization = fixture.get(
31+
self.owner = get(User, username='owner')
32+
self.member = get(User, username='member')
33+
self.project = get(Project, slug='project')
34+
self.project_b = get(Project, slug='project-b')
35+
self.organization = get(
3036
Organization,
3137
owners=[self.owner],
32-
projects=[self.project],
38+
projects=[self.project, self.project_b],
39+
)
40+
self.team = get(
41+
Team,
42+
organization=self.organization,
43+
members=[self.member],
44+
)
45+
46+
self.another_owner = get(User, username='another-owner')
47+
self.another_member = get(User, username='another-member')
48+
self.another_project = get(Project, slug='another-project')
49+
self.another_organization = get(
50+
Organization,
51+
owners=[self.another_owner],
52+
projects=[self.another_project],
53+
)
54+
self.another_team = get(
55+
Team,
56+
organization=self.another_organization,
57+
members=[self.another_member],
3358
)
34-
self.team = fixture.get(Team, organization=self.organization)
3559

3660
def test_delete(self):
3761
"""Delete organization on post."""
@@ -53,6 +77,76 @@ def test_delete(self):
5377
.filter(pk=self.project.pk)
5478
.exists())
5579

80+
def test_list_security_logs(self):
81+
actions = [
82+
AuditLog.AUTHN,
83+
AuditLog.AUTHN_FAILURE,
84+
AuditLog.LOGOUT,
85+
AuditLog.PAGEVIEW,
86+
]
87+
ips = [
88+
'10.10.10.1',
89+
'10.10.10.2',
90+
]
91+
users = [self.owner, self.member, self.another_owner, self.another_member]
92+
AuditLog.objects.all().delete()
93+
for action, ip, user in itertools.product(actions, ips, users):
94+
get(
95+
AuditLog,
96+
user=user,
97+
action=action,
98+
ip=ip,
99+
)
100+
for project in [self.project, self.project_b, self.another_project]:
101+
get(
102+
AuditLog,
103+
user=user,
104+
action=action,
105+
project=project,
106+
ip=ip,
107+
)
108+
109+
self.assertEqual(AuditLog.objects.count(), 128)
110+
self.client.force_login(self.owner)
111+
112+
url = reverse('organization_security_log', args=[self.organization.slug])
113+
114+
# Show logs for self.organization only.
115+
resp = self.client.get(url)
116+
self.assertEqual(resp.status_code, 200)
117+
auditlogs = resp.context_data['object_list']
118+
self.assertEqual(auditlogs.count(), 48)
119+
120+
# Show logs filtered by project.
121+
resp = self.client.get(url + '?project=project')
122+
self.assertEqual(resp.status_code, 200)
123+
auditlogs = resp.context_data['object_list']
124+
self.assertEqual(auditlogs.count(), 24)
125+
126+
resp = self.client.get(url + '?project=another-project')
127+
self.assertEqual(resp.status_code, 200)
128+
auditlogs = resp.context_data['object_list']
129+
self.assertEqual(auditlogs.count(), 0)
130+
131+
# Show logs filtered by IP.
132+
resp = self.client.get(url + '?ip=10.10.10.2')
133+
self.assertEqual(resp.status_code, 200)
134+
auditlogs = resp.context_data['object_list']
135+
self.assertEqual(auditlogs.count(), 24)
136+
137+
# Show logs filtered by action.
138+
for action in [AuditLog.AUTHN, AuditLog.AUTHN_FAILURE, AuditLog.PAGEVIEW]:
139+
resp = self.client.get(url + f'?action={action}')
140+
self.assertEqual(resp.status_code, 200)
141+
auditlogs = resp.context_data['object_list']
142+
self.assertEqual(auditlogs.count(), 16)
143+
144+
# Show logs filtered by user.
145+
resp = self.client.get(url + '?user=member')
146+
self.assertEqual(resp.status_code, 200)
147+
auditlogs = resp.context_data['object_list']
148+
self.assertEqual(auditlogs.count(), 12)
149+
56150

57151
@override_settings(RTD_ALLOW_ORGANIZATIONS=True)
58152
class OrganizationInviteViewTests(RequestFactoryTestMixin, TestCase):

readthedocs/organizations/urls/private.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@
2424
views.DeleteOrganization.as_view(),
2525
name='organization_delete',
2626
),
27+
url(
28+
r'^(?P<slug>[\w.-]+)/security-log/$',
29+
views.OrganizationSecurityLog.as_view(),
30+
name='organization_security_log',
31+
),
2732
# Owners
2833
url(
2934
r'^(?P<slug>[\w.-]+)/owners/(?P<owner>\d+)/delete/$',

0 commit comments

Comments
 (0)