Skip to content

Commit 5c51074

Browse files
authored
Audit: track user events (#8379)
1 parent 15a1896 commit 5c51074

File tree

15 files changed

+408
-4
lines changed

15 files changed

+408
-4
lines changed

readthedocs/acl/__init__.py

Whitespace-only changes.

readthedocs/acl/constants.py

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
BACKEND_REQUEST_KEY = '_auth_request_key'

readthedocs/acl/utils.py

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from django.contrib.auth import BACKEND_SESSION_KEY
2+
3+
from readthedocs.acl.constants import BACKEND_REQUEST_KEY
4+
5+
6+
def get_auth_backend(request):
7+
"""
8+
Get the current auth_backend used on this request.
9+
10+
By default the full qualified name of the backend class
11+
is stored on the request session, but our internal
12+
backends set this as an attribute on the request object.
13+
"""
14+
backend_auth = request.session.get(BACKEND_SESSION_KEY)
15+
backend_perm = getattr(request, BACKEND_REQUEST_KEY, None)
16+
return backend_auth or backend_perm

readthedocs/audit/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
default_app_config = 'readthedocs.audit.apps.AuditConfig' # noqa

readthedocs/audit/admin.py

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""Audit admin."""
2+
3+
from django.contrib import admin
4+
5+
from readthedocs.audit.models import AuditLog
6+
7+
8+
@admin.register(AuditLog)
9+
class AuditLogAdmin(admin.ModelAdmin):
10+
11+
raw_id_fields = ('user', 'project')
12+
search_fields = ('log_user_username', 'browser', 'log_project_slug')
13+
list_filter = ('log_user_username', 'ip', 'log_project_slug', 'action')
14+
list_display = (
15+
'action',
16+
'log_user_username',
17+
'log_project_slug',
18+
'ip',
19+
'browser',
20+
'resource',
21+
)

readthedocs/audit/apps.py

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""Audit module."""
2+
3+
import logging
4+
5+
from django.apps import AppConfig
6+
7+
log = logging.getLogger(__name__)
8+
9+
10+
class AuditConfig(AppConfig):
11+
name = 'readthedocs.audit'
12+
13+
def ready(self):
14+
log.info("Importing all Signals handlers")
15+
import readthedocs.audit.signals # noqa
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Generated by Django 2.2.24 on 2021-07-29 18:34
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
import django.db.models.deletion
6+
import django_extensions.db.fields
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
initial = True
12+
13+
dependencies = [
14+
('projects', '0080_historicalproject'),
15+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
16+
]
17+
18+
operations = [
19+
migrations.CreateModel(
20+
name='AuditLog',
21+
fields=[
22+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
23+
('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')),
24+
('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')),
25+
('log_user_id', models.IntegerField(blank=True, db_index=True, null=True, verbose_name='User ID')),
26+
('log_user_username', models.CharField(blank=True, db_index=True, max_length=150, null=True, verbose_name='Username')),
27+
('log_project_id', models.IntegerField(blank=True, db_index=True, null=True, verbose_name='Project ID')),
28+
('log_project_slug', models.CharField(blank=True, db_index=True, max_length=63, null=True, verbose_name='Project slug')),
29+
('action', models.CharField(choices=[('pageview', 'Page view'), ('authentication', 'Authentication'), ('authentication-failure', 'Authentication failure'), ('log-out', 'Log out')], max_length=150, verbose_name='Action')),
30+
('auth_backend', models.CharField(blank=True, max_length=250, null=True, verbose_name='Auth backend')),
31+
('ip', models.GenericIPAddressField(blank=True, null=True, verbose_name='IP address')),
32+
('browser', models.CharField(blank=True, max_length=250, null=True, verbose_name='Browser user-agent')),
33+
('resource', models.CharField(blank=True, max_length=5500, null=True, verbose_name='Resource')),
34+
('project', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='projects.Project', verbose_name='Project')),
35+
('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='User')),
36+
],
37+
options={
38+
'get_latest_by': 'modified',
39+
'abstract': False,
40+
},
41+
),
42+
]

readthedocs/audit/migrations/__init__.py

Whitespace-only changes.

readthedocs/audit/models.py

+155
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
"""Audit models."""
2+
3+
from django.contrib.auth.models import User
4+
from django.db import models
5+
from django.utils.translation import ugettext_lazy as _
6+
from django_extensions.db.models import TimeStampedModel
7+
8+
from readthedocs.acl.utils import get_auth_backend
9+
10+
11+
class AuditLogManager(models.Manager):
12+
13+
"""AuditLog manager."""
14+
15+
def new(self, action, user=None, request=None, **kwargs):
16+
"""
17+
Create an audit log for `action`.
18+
19+
If user or request are given,
20+
other fields will be auto-populated from that information.
21+
"""
22+
23+
actions_requiring_user = (AuditLog.PAGEVIEW, AuditLog.AUTHN, AuditLog.LOGOUT)
24+
if action in actions_requiring_user and (not user or not request):
25+
raise TypeError(f'A user and a request is required for the {action} action.')
26+
if action == AuditLog.PAGEVIEW and 'project' not in kwargs:
27+
raise TypeError(f'A project is required for the {action} action.')
28+
29+
# Don't save anonymous users.
30+
if user and user.is_anonymous:
31+
user = None
32+
33+
if request:
34+
kwargs['ip'] = request.META.get('REMOTE_ADDR')
35+
kwargs['browser'] = request.headers.get('User-Agent')
36+
kwargs.setdefault('resource', request.path_info)
37+
kwargs.setdefault('auth_backend', get_auth_backend(request))
38+
39+
return self.create(
40+
user=user,
41+
action=action,
42+
**kwargs,
43+
)
44+
45+
46+
class AuditLog(TimeStampedModel):
47+
48+
"""
49+
Track user actions for audit purposes.
50+
51+
A log can be attached to a user and/or project.
52+
If the user or project are deleted the log will be preserved,
53+
and the deleted user/project can be accessed via the ``log_*`` attributes.
54+
"""
55+
56+
PAGEVIEW = 'pageview'
57+
AUTHN = 'authentication'
58+
AUTHN_FAILURE = 'authentication-failure'
59+
LOGOUT = 'log-out'
60+
61+
CHOICES = (
62+
(PAGEVIEW, 'Page view'),
63+
(AUTHN, 'Authentication'),
64+
(AUTHN_FAILURE, 'Authentication failure'),
65+
(LOGOUT, 'Log out'),
66+
)
67+
68+
user = models.ForeignKey(
69+
User,
70+
verbose_name=_('User'),
71+
null=True,
72+
on_delete=models.SET_NULL,
73+
db_index=True,
74+
)
75+
# Extra information in case the user is deleted.
76+
log_user_id = models.IntegerField(
77+
_('User ID'),
78+
blank=True,
79+
null=True,
80+
db_index=True,
81+
)
82+
log_user_username = models.CharField(
83+
_('Username'),
84+
max_length=150,
85+
blank=True,
86+
null=True,
87+
db_index=True,
88+
)
89+
90+
project = models.ForeignKey(
91+
'projects.Project',
92+
verbose_name=_('Project'),
93+
null=True,
94+
db_index=True,
95+
on_delete=models.SET_NULL,
96+
)
97+
# Extra information in case the project is deleted.
98+
log_project_id = models.IntegerField(
99+
_('Project ID'),
100+
blank=True,
101+
null=True,
102+
db_index=True,
103+
)
104+
log_project_slug = models.CharField(
105+
_('Project slug'),
106+
max_length=63,
107+
blank=True,
108+
null=True,
109+
db_index=True,
110+
)
111+
112+
action = models.CharField(
113+
_('Action'),
114+
max_length=150,
115+
choices=CHOICES,
116+
)
117+
auth_backend = models.CharField(
118+
_('Auth backend'),
119+
max_length=250,
120+
blank=True,
121+
null=True,
122+
)
123+
ip = models.GenericIPAddressField(
124+
_('IP address'),
125+
blank=True,
126+
null=True,
127+
)
128+
browser = models.CharField(
129+
_('Browser user-agent'),
130+
max_length=250,
131+
blank=True,
132+
null=True,
133+
)
134+
# Resource can be a path,
135+
# set it slightly greater than ``HTMLFile.path``.
136+
resource = models.CharField(
137+
_('Resource'),
138+
max_length=5500,
139+
blank=True,
140+
null=True,
141+
)
142+
143+
objects = AuditLogManager()
144+
145+
def save(self, **kwargs):
146+
if self.user:
147+
self.log_user_id = self.user.id
148+
self.log_user_username = self.user.username
149+
if self.project:
150+
self.log_project_id = self.project.id
151+
self.log_project_slug = self.project.slug
152+
super().save(**kwargs)
153+
154+
def __str__(self):
155+
return self.action

readthedocs/audit/signals.py

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Audit signals."""
2+
3+
from django.contrib.auth.models import User
4+
from django.contrib.auth.signals import (
5+
user_logged_in,
6+
user_logged_out,
7+
user_login_failed,
8+
)
9+
from django.dispatch import receiver
10+
from django.db.models import Q
11+
12+
from readthedocs.audit.models import AuditLog
13+
14+
15+
@receiver(user_logged_in)
16+
def log_logged_in(sender, request, user, **kwargs):
17+
"""Log when a user has logged in."""
18+
# pylint: disable=unused-argument
19+
AuditLog.objects.new(
20+
action=AuditLog.AUTHN,
21+
user=user,
22+
request=request,
23+
)
24+
25+
26+
@receiver(user_logged_out)
27+
def log_logged_out(sender, request, user, **kwargs):
28+
"""Log when a user has logged out."""
29+
# pylint: disable=unused-argument
30+
# Only log if there is an user.
31+
if not user:
32+
return
33+
AuditLog.objects.new(
34+
action=AuditLog.LOGOUT,
35+
user=user,
36+
request=request,
37+
)
38+
39+
40+
@receiver(user_login_failed)
41+
def log_login_failed(sender, credentials, request, **kwargs):
42+
"""Log when a user has failed to logged in."""
43+
# pylint: disable=unused-argument
44+
username = credentials.get('username')
45+
user = (
46+
User.objects.filter(Q(username=username) | Q(email=username))
47+
.first()
48+
)
49+
AuditLog.objects.new(
50+
action=AuditLog.AUTHN_FAILURE,
51+
user=user,
52+
log_user_username=username,
53+
request=request,
54+
)

readthedocs/audit/tests/__init__.py

Whitespace-only changes.
+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from django.contrib.auth.models import User
2+
from django.test import TestCase
3+
from django_dynamic_fixture import get
4+
5+
from readthedocs.audit.models import AuditLog
6+
7+
8+
class TestSignals(TestCase):
9+
10+
def setUp(self):
11+
self.user = get(
12+
User,
13+
username='test',
14+
)
15+
self.user.set_password('password')
16+
self.user.save()
17+
18+
def test_log_logged_in(self):
19+
self.assertEqual(AuditLog.objects.all().count(), 0)
20+
self.assertTrue(self.client.login(username='test', password='password'))
21+
self.assertEqual(AuditLog.objects.all().count(), 1)
22+
log = AuditLog.objects.first()
23+
self.assertEqual(log.user, self.user)
24+
self.assertEqual(log.action, AuditLog.AUTHN)
25+
26+
def test_log_logged_out(self):
27+
self.assertEqual(AuditLog.objects.all().count(), 0)
28+
self.assertTrue(self.client.login(username='test', password='password'))
29+
self.client.logout()
30+
self.assertEqual(AuditLog.objects.all().count(), 2)
31+
log = AuditLog.objects.last()
32+
self.assertEqual(log.user, self.user)
33+
self.assertEqual(log.action, AuditLog.LOGOUT)
34+
35+
def test_log_login_failed(self):
36+
self.assertEqual(AuditLog.objects.all().count(), 0)
37+
self.assertFalse(self.client.login(username='test', password='incorrect'))
38+
self.assertEqual(AuditLog.objects.all().count(), 1)
39+
log = AuditLog.objects.first()
40+
self.assertEqual(log.user, self.user)
41+
self.assertEqual(log.action, AuditLog.AUTHN_FAILURE)

0 commit comments

Comments
 (0)