Skip to content

Commit 5bd1f63

Browse files
committed
Organizations: show audit logs
1 parent fcc7140 commit 5bd1f63

File tree

9 files changed

+392
-51
lines changed

9 files changed

+392
-51
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: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1+
import csv
2+
import itertools
3+
14
import django_dynamic_fixture as fixture
25
from allauth.account.views import SignupView
36
from django.contrib.auth.models import User
47
from django.core.cache import cache
58
from django.test import TestCase
69
from django.test.utils import override_settings
710
from django.urls import reverse
11+
from django_dynamic_fixture import get
812

13+
from readthedocs.audit.models import AuditLog
914
from readthedocs.organizations.models import (
1015
Organization,
1116
Team,
@@ -54,6 +59,128 @@ def test_delete(self):
5459
.exists())
5560

5661

62+
@override_settings(RTD_ALLOW_ORGANIZATIONS=True)
63+
class OrganizationSecurityLogTests(TestCase):
64+
65+
def setUp(self):
66+
self.owner = get(User, username='owner')
67+
self.member = get(User, username='member')
68+
self.project = get(Project, slug='project')
69+
self.project_b = get(Project, slug='project-b')
70+
self.organization = get(
71+
Organization,
72+
owners=[self.owner],
73+
projects=[self.project, self.project_b],
74+
)
75+
self.team = get(
76+
Team,
77+
organization=self.organization,
78+
members=[self.member],
79+
)
80+
81+
self.another_owner = get(User, username='another-owner')
82+
self.another_member = get(User, username='another-member')
83+
self.another_project = get(Project, slug='another-project')
84+
self.another_organization = get(
85+
Organization,
86+
owners=[self.another_owner],
87+
projects=[self.another_project],
88+
)
89+
self.another_team = get(
90+
Team,
91+
organization=self.another_organization,
92+
members=[self.another_member],
93+
)
94+
self.client.force_login(self.owner)
95+
96+
actions = [
97+
AuditLog.AUTHN,
98+
AuditLog.AUTHN_FAILURE,
99+
AuditLog.LOGOUT,
100+
AuditLog.PAGEVIEW,
101+
]
102+
ips = [
103+
'10.10.10.1',
104+
'10.10.10.2',
105+
]
106+
users = [self.owner, self.member, self.another_owner, self.another_member]
107+
AuditLog.objects.all().delete()
108+
for action, ip, user in itertools.product(actions, ips, users):
109+
get(
110+
AuditLog,
111+
user=user,
112+
action=action,
113+
ip=ip,
114+
)
115+
for project in [self.project, self.project_b, self.another_project]:
116+
get(
117+
AuditLog,
118+
user=user,
119+
action=action,
120+
project=project,
121+
ip=ip,
122+
)
123+
124+
self.url = reverse('organization_security_log', args=[self.organization.slug])
125+
126+
def test_list_security_logs(self):
127+
self.assertEqual(AuditLog.objects.count(), 128)
128+
129+
# Show logs for self.organization only.
130+
resp = self.client.get(self.url)
131+
self.assertEqual(resp.status_code, 200)
132+
auditlogs = resp.context_data['object_list']
133+
self.assertEqual(auditlogs.count(), 48)
134+
135+
# Show logs filtered by project.
136+
resp = self.client.get(self.url + '?project=project')
137+
self.assertEqual(resp.status_code, 200)
138+
auditlogs = resp.context_data['object_list']
139+
self.assertEqual(auditlogs.count(), 24)
140+
141+
resp = self.client.get(self.url + '?project=another-project')
142+
self.assertEqual(resp.status_code, 200)
143+
auditlogs = resp.context_data['object_list']
144+
self.assertEqual(auditlogs.count(), 0)
145+
146+
# Show logs filtered by IP.
147+
resp = self.client.get(self.url + '?ip=10.10.10.2')
148+
self.assertEqual(resp.status_code, 200)
149+
auditlogs = resp.context_data['object_list']
150+
self.assertEqual(auditlogs.count(), 24)
151+
152+
# Show logs filtered by action.
153+
for action in [AuditLog.AUTHN, AuditLog.AUTHN_FAILURE, AuditLog.PAGEVIEW]:
154+
resp = self.client.get(self.url + f'?action={action}')
155+
self.assertEqual(resp.status_code, 200)
156+
auditlogs = resp.context_data['object_list']
157+
self.assertEqual(auditlogs.count(), 16)
158+
159+
# Show logs filtered by user.
160+
resp = self.client.get(self.url + '?user=member')
161+
self.assertEqual(resp.status_code, 200)
162+
auditlogs = resp.context_data['object_list']
163+
self.assertEqual(auditlogs.count(), 12)
164+
165+
def test_download_csv(self):
166+
self.assertEqual(AuditLog.objects.count(), 128)
167+
resp = self.client.get(
168+
self.url,
169+
{'download': 'true'}
170+
)
171+
self.assertEqual(resp.status_code, 200)
172+
self.assertEqual(resp['Content-Type'], 'text/csv')
173+
174+
# convert streaming data to csv format
175+
content = [
176+
line.decode()
177+
for line in b''.join(resp.streaming_content).splitlines()
178+
]
179+
csv_data = list(csv.reader(content))
180+
# All records + the header.
181+
self.assertEqual(len(csv_data), 48 + 1)
182+
183+
57184
@override_settings(RTD_ALLOW_ORGANIZATIONS=True)
58185
class OrganizationInviteViewTests(RequestFactoryTestMixin, TestCase):
59186

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)