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 6 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'
9 changes: 9 additions & 0 deletions readthedocs/acl/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
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.

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.
152 changes: 152 additions & 0 deletions readthedocs/audit/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"""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)
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)
29 changes: 29 additions & 0 deletions readthedocs/proxito/views/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from django.views.static import serve
from slugify import slugify as unicode_slugify

from readthedocs.audit.models import AuditLog
from readthedocs.builds.constants import EXTERNAL, INTERNAL
from readthedocs.core.resolver import resolve
from readthedocs.proxito.constants import (
Expand Down Expand Up @@ -53,6 +54,8 @@ def _serve_docs(
or "docs-celeryproject-org-kombu-en-stable.pdf")
"""

self._trak_pageview(final_project, path, request)

if settings.PYTHON_MEDIA:
return self._serve_docs_python(
request,
Expand All @@ -68,6 +71,32 @@ def _serve_docs(
download=download,
)

def _trak_pageview(self, project, path, request):
"""Create an audit log of the page view if audit is enabled."""
# Remove any query args (like the token access from AWS).
path_only = urlparse(path).path
track_file = any(
path_only.endswith(ext)
for ext in ['.html', '.pdf', '.epub', '.zip']
)

if track_file and self._is_audit_enabled(project):
AuditLog.objects.new(
action=AuditLog.PAGEVIEW,
user=request.user,
request=request,
project=project,
)

def _is_audit_enabled(self, project):
"""
Check if the project has the audit feature enabled to track individual page views.

This feature is different from page views analytics,
as it records every page view individually with more metadata like the user, IP, etc.
"""
return True

def _serve_docs_python(self, request, final_project, path):
"""
Serve docs from Python.
Expand Down
Loading