-
-
Notifications
You must be signed in to change notification settings - Fork 3.6k
Audit: track user events #8379
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Audit: track user events #8379
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
2664f60
Audit: track user events
stsewd d033516
Track selected files only
stsewd d0bac57
Linter
stsewd fa760ef
Log username on auth failure
stsewd 08db200
Improvements
stsewd 82cec51
Tests
stsewd 364a037
More tests
stsewd d5a8f21
Docstring
stsewd d4272d3
str
stsewd File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
BACKEND_REQUEST_KEY = '_auth_request_key' |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
from django.contrib.auth import BACKEND_SESSION_KEY | ||
|
||
from readthedocs.acl.constants import BACKEND_REQUEST_KEY | ||
|
||
|
||
def get_auth_backend(request): | ||
""" | ||
Get the current auth_backend used on this request. | ||
|
||
By default the full qualified name of the backend class | ||
is stored on the request session, but our internal | ||
backends set this as an attribute on the request object. | ||
""" | ||
backend_auth = request.session.get(BACKEND_SESSION_KEY) | ||
backend_perm = getattr(request, BACKEND_REQUEST_KEY, None) | ||
return backend_auth or backend_perm |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
default_app_config = 'readthedocs.audit.apps.AuditConfig' # noqa |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
"""Audit admin.""" | ||
|
||
from django.contrib import admin | ||
|
||
from readthedocs.audit.models import AuditLog | ||
|
||
|
||
@admin.register(AuditLog) | ||
class AuditLogAdmin(admin.ModelAdmin): | ||
|
||
raw_id_fields = ('user', 'project') | ||
search_fields = ('log_user_username', 'browser', 'log_project_slug') | ||
list_filter = ('log_user_username', 'ip', 'log_project_slug', 'action') | ||
list_display = ( | ||
'action', | ||
'log_user_username', | ||
'log_project_slug', | ||
'ip', | ||
'browser', | ||
'resource', | ||
) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
"""Audit module.""" | ||
|
||
import logging | ||
|
||
from django.apps import AppConfig | ||
|
||
log = logging.getLogger(__name__) | ||
|
||
|
||
class AuditConfig(AppConfig): | ||
name = 'readthedocs.audit' | ||
|
||
def ready(self): | ||
log.info("Importing all Signals handlers") | ||
import readthedocs.audit.signals # noqa |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
# Generated by Django 2.2.24 on 2021-07-29 18:34 | ||
|
||
from django.conf import settings | ||
from django.db import migrations, models | ||
import django.db.models.deletion | ||
import django_extensions.db.fields | ||
|
||
|
||
class Migration(migrations.Migration): | ||
|
||
initial = True | ||
|
||
dependencies = [ | ||
('projects', '0080_historicalproject'), | ||
migrations.swappable_dependency(settings.AUTH_USER_MODEL), | ||
] | ||
|
||
operations = [ | ||
migrations.CreateModel( | ||
name='AuditLog', | ||
fields=[ | ||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||
('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), | ||
('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), | ||
('log_user_id', models.IntegerField(blank=True, db_index=True, null=True, verbose_name='User ID')), | ||
('log_user_username', models.CharField(blank=True, db_index=True, max_length=150, null=True, verbose_name='Username')), | ||
('log_project_id', models.IntegerField(blank=True, db_index=True, null=True, verbose_name='Project ID')), | ||
('log_project_slug', models.CharField(blank=True, db_index=True, max_length=63, null=True, verbose_name='Project slug')), | ||
('action', models.CharField(choices=[('pageview', 'Page view'), ('authentication', 'Authentication'), ('authentication-failure', 'Authentication failure'), ('log-out', 'Log out')], max_length=150, verbose_name='Action')), | ||
('auth_backend', models.CharField(blank=True, max_length=250, null=True, verbose_name='Auth backend')), | ||
('ip', models.GenericIPAddressField(blank=True, null=True, verbose_name='IP address')), | ||
('browser', models.CharField(blank=True, max_length=250, null=True, verbose_name='Browser user-agent')), | ||
('resource', models.CharField(blank=True, max_length=5500, null=True, verbose_name='Resource')), | ||
('project', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='projects.Project', verbose_name='Project')), | ||
('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='User')), | ||
], | ||
options={ | ||
'get_latest_by': 'modified', | ||
'abstract': False, | ||
}, | ||
), | ||
] |
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,155 @@ | ||
"""Audit models.""" | ||
|
||
from django.contrib.auth.models import User | ||
from django.db import models | ||
from django.utils.translation import ugettext_lazy as _ | ||
from django_extensions.db.models import TimeStampedModel | ||
|
||
from readthedocs.acl.utils import get_auth_backend | ||
|
||
|
||
class AuditLogManager(models.Manager): | ||
|
||
"""AuditLog manager.""" | ||
|
||
def new(self, action, user=None, request=None, **kwargs): | ||
""" | ||
Create an audit log for `action`. | ||
|
||
If user or request are given, | ||
other fields will be auto-populated from that information. | ||
""" | ||
|
||
actions_requiring_user = (AuditLog.PAGEVIEW, AuditLog.AUTHN, AuditLog.LOGOUT) | ||
if action in actions_requiring_user and (not user or not request): | ||
raise TypeError(f'A user and a request is required for the {action} action.') | ||
if action == AuditLog.PAGEVIEW and 'project' not in kwargs: | ||
raise TypeError(f'A project is required for the {action} action.') | ||
|
||
# Don't save anonymous users. | ||
if user and user.is_anonymous: | ||
user = None | ||
|
||
if request: | ||
kwargs['ip'] = request.META.get('REMOTE_ADDR') | ||
kwargs['browser'] = request.headers.get('User-Agent') | ||
kwargs.setdefault('resource', request.path_info) | ||
kwargs.setdefault('auth_backend', get_auth_backend(request)) | ||
|
||
return self.create( | ||
user=user, | ||
action=action, | ||
**kwargs, | ||
) | ||
|
||
|
||
class AuditLog(TimeStampedModel): | ||
|
||
""" | ||
Track user actions for audit purposes. | ||
|
||
A log can be attached to a user and/or project. | ||
If the user or project are deleted the log will be preserved, | ||
and the deleted user/project can be accessed via the ``log_*`` attributes. | ||
""" | ||
|
||
PAGEVIEW = 'pageview' | ||
AUTHN = 'authentication' | ||
AUTHN_FAILURE = 'authentication-failure' | ||
LOGOUT = 'log-out' | ||
|
||
CHOICES = ( | ||
(PAGEVIEW, 'Page view'), | ||
(AUTHN, 'Authentication'), | ||
(AUTHN_FAILURE, 'Authentication failure'), | ||
(LOGOUT, 'Log out'), | ||
) | ||
|
||
user = models.ForeignKey( | ||
User, | ||
verbose_name=_('User'), | ||
null=True, | ||
on_delete=models.SET_NULL, | ||
db_index=True, | ||
) | ||
# Extra information in case the user is deleted. | ||
log_user_id = models.IntegerField( | ||
_('User ID'), | ||
blank=True, | ||
null=True, | ||
db_index=True, | ||
) | ||
log_user_username = models.CharField( | ||
_('Username'), | ||
max_length=150, | ||
blank=True, | ||
null=True, | ||
db_index=True, | ||
) | ||
|
||
project = models.ForeignKey( | ||
'projects.Project', | ||
verbose_name=_('Project'), | ||
null=True, | ||
db_index=True, | ||
on_delete=models.SET_NULL, | ||
) | ||
# Extra information in case the project is deleted. | ||
log_project_id = models.IntegerField( | ||
_('Project ID'), | ||
blank=True, | ||
null=True, | ||
db_index=True, | ||
) | ||
log_project_slug = models.CharField( | ||
_('Project slug'), | ||
max_length=63, | ||
blank=True, | ||
null=True, | ||
db_index=True, | ||
) | ||
|
||
action = models.CharField( | ||
_('Action'), | ||
max_length=150, | ||
choices=CHOICES, | ||
) | ||
auth_backend = models.CharField( | ||
_('Auth backend'), | ||
max_length=250, | ||
blank=True, | ||
null=True, | ||
) | ||
ip = models.GenericIPAddressField( | ||
_('IP address'), | ||
blank=True, | ||
null=True, | ||
) | ||
browser = models.CharField( | ||
_('Browser user-agent'), | ||
max_length=250, | ||
blank=True, | ||
null=True, | ||
) | ||
# Resource can be a path, | ||
# set it slightly greater than ``HTMLFile.path``. | ||
resource = models.CharField( | ||
_('Resource'), | ||
max_length=5500, | ||
blank=True, | ||
null=True, | ||
) | ||
|
||
objects = AuditLogManager() | ||
|
||
def save(self, **kwargs): | ||
if self.user: | ||
self.log_user_id = self.user.id | ||
self.log_user_username = self.user.username | ||
if self.project: | ||
self.log_project_id = self.project.id | ||
self.log_project_slug = self.project.slug | ||
super().save(**kwargs) | ||
|
||
def __str__(self): | ||
return self.action |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
"""Audit signals.""" | ||
|
||
from django.contrib.auth.models import User | ||
from django.contrib.auth.signals import ( | ||
user_logged_in, | ||
user_logged_out, | ||
user_login_failed, | ||
) | ||
from django.dispatch import receiver | ||
from django.db.models import Q | ||
|
||
from readthedocs.audit.models import AuditLog | ||
|
||
|
||
@receiver(user_logged_in) | ||
def log_logged_in(sender, request, user, **kwargs): | ||
"""Log when a user has logged in.""" | ||
# pylint: disable=unused-argument | ||
AuditLog.objects.new( | ||
action=AuditLog.AUTHN, | ||
user=user, | ||
request=request, | ||
) | ||
|
||
|
||
@receiver(user_logged_out) | ||
def log_logged_out(sender, request, user, **kwargs): | ||
"""Log when a user has logged out.""" | ||
# pylint: disable=unused-argument | ||
# Only log if there is an user. | ||
if not user: | ||
return | ||
AuditLog.objects.new( | ||
action=AuditLog.LOGOUT, | ||
user=user, | ||
request=request, | ||
) | ||
|
||
|
||
@receiver(user_login_failed) | ||
def log_login_failed(sender, credentials, request, **kwargs): | ||
"""Log when a user has failed to logged in.""" | ||
# pylint: disable=unused-argument | ||
username = credentials.get('username') | ||
user = ( | ||
User.objects.filter(Q(username=username) | Q(email=username)) | ||
.first() | ||
) | ||
AuditLog.objects.new( | ||
action=AuditLog.AUTHN_FAILURE, | ||
user=user, | ||
log_user_username=username, | ||
request=request, | ||
) |
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
from django.contrib.auth.models import User | ||
from django.test import TestCase | ||
from django_dynamic_fixture import get | ||
|
||
from readthedocs.audit.models import AuditLog | ||
|
||
|
||
class TestSignals(TestCase): | ||
|
||
def setUp(self): | ||
self.user = get( | ||
User, | ||
username='test', | ||
) | ||
self.user.set_password('password') | ||
self.user.save() | ||
|
||
def test_log_logged_in(self): | ||
self.assertEqual(AuditLog.objects.all().count(), 0) | ||
self.assertTrue(self.client.login(username='test', password='password')) | ||
self.assertEqual(AuditLog.objects.all().count(), 1) | ||
log = AuditLog.objects.first() | ||
self.assertEqual(log.user, self.user) | ||
self.assertEqual(log.action, AuditLog.AUTHN) | ||
|
||
def test_log_logged_out(self): | ||
self.assertEqual(AuditLog.objects.all().count(), 0) | ||
self.assertTrue(self.client.login(username='test', password='password')) | ||
self.client.logout() | ||
self.assertEqual(AuditLog.objects.all().count(), 2) | ||
log = AuditLog.objects.last() | ||
self.assertEqual(log.user, self.user) | ||
self.assertEqual(log.action, AuditLog.LOGOUT) | ||
|
||
def test_log_login_failed(self): | ||
self.assertEqual(AuditLog.objects.all().count(), 0) | ||
self.assertFalse(self.client.login(username='test', password='incorrect')) | ||
self.assertEqual(AuditLog.objects.all().count(), 1) | ||
log = AuditLog.objects.first() | ||
self.assertEqual(log.user, self.user) | ||
self.assertEqual(log.action, AuditLog.AUTHN_FAILURE) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I feel like we already have this code on .com -- this is porting it right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yeah, I'm porting this piece from .com, so we have all code here.