Skip to content

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
merged 9 commits into from
Aug 4, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added readthedocs/acl/__init__.py
Empty file.
1 change: 1 addition & 0 deletions readthedocs/acl/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
BACKEND_REQUEST_KEY = '_auth_request_key'
16 changes: 16 additions & 0 deletions readthedocs/acl/utils.py
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):
Copy link
Member

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?

Copy link
Member Author

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.

"""
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
1 change: 1 addition & 0 deletions readthedocs/audit/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
default_app_config = 'readthedocs.audit.apps.AuditConfig' # noqa
21 changes: 21 additions & 0 deletions readthedocs/audit/admin.py
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',
)
15 changes: 15 additions & 0 deletions readthedocs/audit/apps.py
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
42 changes: 42 additions & 0 deletions readthedocs/audit/migrations/0001_initial.py
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.
155 changes: 155 additions & 0 deletions readthedocs/audit/models.py
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
54 changes: 54 additions & 0 deletions readthedocs/audit/signals.py
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.
41 changes: 41 additions & 0 deletions readthedocs/audit/tests/test_signals.py
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)
Loading