Skip to content

Commit 80963c9

Browse files
committed
Organizations: show audit logs
1 parent 12b38ff commit 80963c9

File tree

9 files changed

+253
-53
lines changed

9 files changed

+253
-53
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: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,5 +190,16 @@ def save(self, **kwargs):
190190
self.log_organization_slug = organization.slug
191191
super().save(**kwargs)
192192

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

readthedocs/organizations/views/private.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,31 @@
11
"""Views that require login."""
22
# pylint: disable=too-many-ancestors
3+
4+
from django.conf import settings
35
from django.contrib import messages
46
from django.urls import reverse_lazy
7+
from django.utils import timezone
58
from django.utils.translation import ugettext_lazy as _
69
from vanilla import CreateView, DeleteView, ListView, UpdateView
710

11+
from readthedocs.audit.filters import OrganizationSecurityLogFilter
12+
from readthedocs.audit.models import AuditLog
813
from readthedocs.core.history import UpdateChangeReasonPostView
914
from readthedocs.core.mixins import PrivateViewMixin
15+
from readthedocs.core.utils.extend import SettingsOverrideObject
1016
from readthedocs.organizations.forms import (
1117
OrganizationSignupForm,
1218
OrganizationTeamProjectForm,
1319
)
1420
from readthedocs.organizations.models import Organization
1521
from readthedocs.organizations.views.base import (
22+
OrganizationMixin,
1623
OrganizationOwnerView,
1724
OrganizationTeamMemberView,
1825
OrganizationTeamView,
1926
OrganizationView,
2027
)
28+
from readthedocs.projects.utils import get_csv_file
2129

2230

2331
# Organization views
@@ -160,3 +168,99 @@ def post(self, request, *args, **kwargs):
160168
resp = super().post(request, *args, **kwargs)
161169
messages.success(self.request, self.success_message)
162170
return resp
171+
172+
173+
class OrganizationSecurityLogBase(PrivateViewMixin, OrganizationMixin, ListView):
174+
175+
"""Display security logs related to this organization."""
176+
177+
model = AuditLog
178+
template_name = 'organizations/security_log.html'
179+
180+
def get(self, request, *args, **kwargs):
181+
download_data = request.GET.get('download', False)
182+
if download_data:
183+
return self._get_csv_data()
184+
return super().get(request, *args, **kwargs)
185+
186+
def _get_csv_data(self):
187+
organization = self.get_organization()
188+
now = timezone.now().date()
189+
retention_limit = self._get_retention_days_limit(organization)
190+
if retention_limit in [None, -1]:
191+
# Unlimited.
192+
days_ago = organization.pub_date.date()
193+
else:
194+
days_ago = now - timezone.timedelta(days=retention_limit)
195+
196+
values = [
197+
('Date', 'created'),
198+
('User', 'log_user_username'),
199+
('Project', 'log_project_slug'),
200+
('Organization', 'log_organization_slug'),
201+
('Action', 'action'),
202+
('Resource', 'resource'),
203+
('IP', 'ip'),
204+
('Browser', 'browser'),
205+
]
206+
data = self._get_queryset().values_list(*[value for _, value in values])
207+
csv_data = [
208+
[timezone.datetime.strftime(date, '%Y-%m-%d %H:%M:%S'), *rest]
209+
for date, *rest in data
210+
]
211+
csv_data.insert(0, [header for header, _ in values])
212+
filename = 'readthedocs_organization_security_logs_{organization}_{start}_{end}.csv'.format(
213+
organization=organization.slug,
214+
start=timezone.datetime.strftime(days_ago, '%Y-%m-%d'),
215+
end=timezone.datetime.strftime(now, '%Y-%m-%d'),
216+
)
217+
return get_csv_file(filename=filename, csv_data=csv_data)
218+
219+
def get_context_data(self, **kwargs):
220+
organization = self.get_organization()
221+
context = super().get_context_data(**kwargs)
222+
context['enabled'] = self._is_enabled(organization)
223+
context['days_limit'] = self._get_retention_days_limit(organization)
224+
context['filter'] = self.filter
225+
context['AuditLog'] = AuditLog
226+
return context
227+
228+
def _get_queryset(self):
229+
organization = self.get_organization()
230+
if not self._is_enabled(organization):
231+
return AuditLog.objects.none()
232+
233+
retention_limit = self._get_retention_days_limit(organization)
234+
if retention_limit in [None, -1]:
235+
# Unlimited.
236+
days_ago = organization.pub_date.date()
237+
else:
238+
days_ago = timezone.now() - timezone.timedelta(days=retention_limit)
239+
queryset = AuditLog.objects.filter(
240+
log_organization_id=organization.id,
241+
action__in=[AuditLog.AUTHN, AuditLog.AUTHN_FAILURE, AuditLog.PAGEVIEW],
242+
created__gte=days_ago,
243+
)
244+
return queryset
245+
246+
def get_queryset(self):
247+
queryset = self._get_queryset()
248+
# Set filter on self, so we can use it in the context.
249+
# Without executing it twice.
250+
self.filter = OrganizationSecurityLogFilter(
251+
self.request.GET,
252+
queryset=queryset,
253+
)
254+
return self.filter.qs
255+
256+
def _get_retention_days_limit(self, organization):
257+
"""From how many days we need to show data for this project?"""
258+
return settings.RTD_AUDITLOGS_DEFAULT_RETENTION_DAYS
259+
260+
def _is_enabled(self, organization):
261+
"""Should we show audit logs for this organization?"""
262+
return True
263+
264+
265+
class OrganizationSecurityLog(SettingsOverrideObject):
266+
_default_class = OrganizationSecurityLogBase

readthedocs/projects/views/private.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from django.utils import timezone
1414
from django.utils.safestring import mark_safe
1515
from django.utils.translation import ugettext_lazy as _
16-
from django.views.generic import ListView, TemplateView, View
16+
from django.views.generic import ListView, TemplateView
1717
from formtools.wizard.views import SessionWizardView
1818
from vanilla import (
1919
CreateView,
@@ -35,7 +35,6 @@
3535
)
3636
from readthedocs.core.history import UpdateChangeReasonPostView
3737
from readthedocs.core.mixins import ListViewWithForm, PrivateViewMixin
38-
from readthedocs.core.utils import trigger_build
3938
from readthedocs.core.utils.extend import SettingsOverrideObject
4039
from readthedocs.integrations.models import HttpExchange, Integration
4140
from readthedocs.oauth.services import registry

readthedocs/templates/profiles/private/security_log.html

Lines changed: 1 addition & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -24,56 +24,7 @@
2424
<button type="submit" name="download" value="true">{% trans "Download all data" %}</button>
2525
</form>
2626

27-
<div class="module-list">
28-
<div class="module-list-wrapper">
29-
<ul>
30-
{% for log in object_list %}
31-
<li class="module-item">
32-
{% if log.action == AuditLog.AUTHN %}
33-
{% blocktrans trimmed with action=AuditLog.AUTHN%}
34-
<a href="?action={{ action }}">Authenticated</a>
35-
{% endblocktrans %}
36-
{% elif log.action == AuditLog.AUTHN_FAILURE %}
37-
{% blocktrans trimmed with action=AuditLog.AUTHN_FAILURE %}
38-
<a href="?action={{ action }}">Authentication attempt</a>
39-
{% endblocktrans %}
40-
{% endif %}
41-
42-
{% trans "from" %}
43-
44-
<a href="?ip={{ log.ip }}" title="{{ log.browser }}">
45-
<code>{{ log.ip }}</code>
46-
</a>
47-
48-
{# If the authentication was on a doc domain. #}
49-
{% if log.log_project_id %}
50-
{% trans "on" %}
51-
{% if log.project %}
52-
<a href="{% url 'projects_detail' log.log_project_slug %}">
53-
<code>{{ log.log_project_slug }}</code>
54-
</a>
55-
{% else %}
56-
{# The original project has been deleted, don't link to it. #}
57-
<code>{{ log.log_project_slug }}</code>
58-
{% endif %}
59-
{% endif %}
60-
61-
<span class="quiet right" title="{{ log.created|date:"DATETIME_FORMAT" }}">
62-
{% blocktrans trimmed with log.created|timesince as date %}
63-
{{ date }} ago
64-
{% endblocktrans %}
65-
</span>
66-
</li>
67-
{% empty %}
68-
<li class="module-item">
69-
<p class="quiet">
70-
{% trans 'No authentication attempts logged yet' %}
71-
</p>
72-
</li>
73-
{% endfor %}
74-
</ul>
75-
</div>
76-
</div>
27+
{% include "audit/list_logs.html" with omit_user=True %}
7728
</div>
7829
{% paginate %}
7930
{% endblock %}

0 commit comments

Comments
 (0)