auditlog with Django and DRF

Question:

I need to implement auditlog feature in one of my project which is using Django 1.8 and Django-Rest-Framework 3.2.2. I have extended BaseUserManager class to create user model since I had to use email as a username in my application ( if this information matters ).

Below is my db design which will hold logs :

**fields    type    desc**

id           pk      ( auto_increment)  
cust_id   FK  customer 
customer_name   FK  customer
user_id FK  user
user_name   FK  user
module  Varchar(100) sales,order,billing,etc
action  Varchar(10) Create/Update/Delete
previous_value  varchar(500)    
current_value   varchar(500)    
Datetime    Datetime    timestamp of change

I have tried https://pypi.python.org/pypi/django-audit-log but it has 2 issues as per my requirement-

  1. It does not capture data as per my requirement which I understand is my issue and so I modified it’s code and added my fields into it’s model.
  2. It is not capturing module information. Behaviour is random.

I am seeking advice to proceed with this feature. Which package would be best suitable for my task.

P.S I have also tried Django-reversion and I have no requirement of data versioning.

Thanks

Asked By: Amar

||

Answers:

First of all you can user package: https://github.com/jcugat/django-custom-user, to solve Email as Username field.
Then you can try to focus development with: http://django-reversion.readthedocs.io/en/stable/

Answered By: Marin

I achieved what I needed by modifying auditlog code –

  1. Added required field in LogEntry model of auditlog.
  2. Modified log_create,log_update,log_delete functions of receivers.py to save information in newly added fields.

Using this I am halfway done. Now only issue I am facing is that since model instance of 1 table contains information of other tables as well due to FKs used in the table.

To solve this I could come up with a solution which works well but I am not satisfied with it.
I added a function like include_in_model() in each model and modified auditlog’s registry.py register() function to get those fields and only use that to save information in LogEntry model.

This approach will require me to create this include_in_model() function in each of my model class and pass required fields for particular model. This way I am avoiding FK related information.

Answered By: Amar

Django Simple History is an excellent app that I’ve used in production projects in the past, it will give you per model Audits against your users.

Furthermore, you should create your own Authentication Class which will be responsible for logging requests. Let’s assume that a User uses a Token to authenticate with your API. It gets sent in the header of each HTTP Request to your API like so: Authorization: Bearer <My Token>. We should then log the User associated with the request, the time, the user’s IP and the body.

This is pretty easy:

settings.py

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'common.authentication.MyTokenAuthenticationClass'
    ),
    ...
}

common/authentication.py

from django.utils import timezone
from django.utils.translation import ugettext_lazy as _

from ipware.ip import get_real_ip
from rest_framework import authentication
from rest_framework import exceptions

from accounts.models import Token, AuditLog


class MyTokenAuthenticationClass(authentication.BaseAuthentication):

    def authenticate(self, request):

        # Grab the Athorization Header from the HTTP Request
        auth = authentication.get_authorization_header(request).split()

        if not auth or auth[0].lower() != b'bearer':
            return None

        # Check that Token header is properly formatted and present, raise errors if not
        if len(auth) == 1:
            msg = _('Invalid token header. No credentials provided.')
            raise exceptions.AuthenticationFailed(msg)
        elif len(auth) > 2:
            msg = _('Invalid token header. Credentials string should not contain spaces.')
            raise exceptions.AuthenticationFailed(msg)

        try:
            token = Token.objects.get(token=auth[1])
            # Using the `ipware.ip` module to get the real IP (if hosted on ElasticBeanstalk or Heroku)
            token.last_ip = get_real_ip(request)
            token.last_login = timezone.now()
            token.save()

            # Add the saved token instance to the request context
            request.token = token

        except Token.DoesNotExist:
            raise exceptions.AuthenticationFailed('Invalid token.')

        # At this point, insert the Log into your AuditLog table:
        AuditLog.objects.create(
            user_id=token.user,
            request_payload=request.body,
            # Additional fields
            ...
        )

        # Return the Authenticated User associated with the Token
        return (token.user, token)
Answered By: Daniel van Flymen

Another solution would be to use django auditlog and use a custom middleware which does not capture the ‘request.user’ directly but at the moment when it is needed, by this time DRF will have set the correct ‘request.user’ so that it is no longer missing the username in the audit logs.

Create a file named (for example) auditlog_middleware.py and include it in the MIDDLEWARE in your settings.py instead of the default auditlog middleware.

from __future__ import unicode_literals

import threading
import time

from django.conf import settings
from django.db.models.signals import pre_save
from django.utils.functional import curry
from django.apps import apps
from auditlog.models import LogEntry
from auditlog.compat import is_authenticated

# Use MiddlewareMixin when present (Django >= 1.10)
try:
    from django.utils.deprecation import MiddlewareMixin
except ImportError:
    MiddlewareMixin = object


threadlocal = threading.local()


class AuditlogMiddleware(MiddlewareMixin):
    """
    Middleware to couple the request's user to log items. This is accomplished by currying the signal receiver with the
    user from the request (or None if the user is not authenticated).
    """

    def process_request(self, request):
        """
        Gets the current user from the request and prepares and connects a signal receiver with the user already
        attached to it.
        """
        # Initialize thread local storage
        threadlocal.auditlog = {
            'signal_duid': (self.__class__, time.time()),
            'remote_addr': request.META.get('REMOTE_ADDR'),
        }

        # In case of proxy, set 'original' address
        if request.META.get('HTTP_X_FORWARDED_FOR'):
            threadlocal.auditlog['remote_addr'] = request.META.get('HTTP_X_FORWARDED_FOR').split(',')[0]

        # Connect signal for automatic logging
        set_actor = curry(self.set_actor, request=request, signal_duid=threadlocal.auditlog['signal_duid'])
        pre_save.connect(set_actor, sender=LogEntry, dispatch_uid=threadlocal.auditlog['signal_duid'], weak=False)

    def process_response(self, request, response):
        """
        Disconnects the signal receiver to prevent it from staying active.
        """
        if hasattr(threadlocal, 'auditlog'):
            pre_save.disconnect(sender=LogEntry, dispatch_uid=threadlocal.auditlog['signal_duid'])

        return response

    def process_exception(self, request, exception):
        """
        Disconnects the signal receiver to prevent it from staying active in case of an exception.
        """
        if hasattr(threadlocal, 'auditlog'):
            pre_save.disconnect(sender=LogEntry, dispatch_uid=threadlocal.auditlog['signal_duid'])

        return None

    @staticmethod
    def set_actor(request, sender, instance, signal_duid, **kwargs):
        """
        Signal receiver with an extra, required 'user' kwarg. This method becomes a real (valid) signal receiver when
        it is curried with the actor.
        """
        if hasattr(threadlocal, 'auditlog'):
            if not hasattr(request, 'user') or not is_authenticated(request.user):
                return
            if signal_duid != threadlocal.auditlog['signal_duid']:
                return
            try:
                app_label, model_name = settings.AUTH_USER_MODEL.split('.')
                auth_user_model = apps.get_model(app_label, model_name)
            except ValueError:
                auth_user_model = apps.get_model('auth', 'user')
            if sender == LogEntry and isinstance(request.user, auth_user_model) and instance.actor is None:
                instance.actor = request.user

            instance.remote_addr = threadlocal.auditlog['remote_addr']
Answered By: Bart Machielsen

I know that this answer is coming very late, but here it goes

Because DRF authenticates on the View level NOT on the Middleware level, the user is not yet attached to the request when AuditlogMiddleware runs, resulting in AnonymousUser

You can attach the logic from AuditlogMiddleware after your authentication
This logic connects some signals

This solution befits:

  1. You don’t have to decorate every View with it

  2. it doesn’t assume anything about AuditlogMiddleware or audit_log implementation in general. so if the code changes, this should still work

  3. It doesn’t force or duplicate DRF authentication.

#token_authentication_wrapper.py
from auditlog.middleware import AuditlogMiddleware
from rest_framework.authentication import TokenAuthentication


class TokenAuthenticationWrapper(TokenAuthentication):
    def authenticate(self, request):
        user, token = super().authenticate(request)
        request.user = user # necessary for preventing recursion
        AuditlogMiddleware().process_request(request)
        return user, token

inherit from your favorite Authentication service e.g. BasicAuthentication SessionAuthentication, TokenAuthentication, etc…

and in setting.py

    'DEFAULT_AUTHENTICATION_CLASSES': [
        'path.to.file.token_authentication_wrapper.TokenAuthenticationWrapper',
    ]
Answered By: Hassaan AlAnsary

The answer by @hassaan-alansary would have been ideal, but unfortunately the Auditlog devs made significant changes since he posted his answer, and I couldn’t figure out how to reconcile their changes with Hassaan’s answer.

The solution I ended up finding is based on what was shared here. Instead of writing a new DRF authentication method which invokes the middleware to do the logging, it creates a mixin which needs to be added to each of the DRF views you want added to the audit log. The solution below is the modified version of the one I ended up using from the link above.

# mixins.py

import threading
import time
from functools import partial

from django.db.models.signals import pre_save

from auditlog.models import LogEntry


threadlocal = threading.local()


class DRFDjangoAuditModelMixin:
    """
    Mixin to integrate django-auditlog with Django Rest Framework.

    This is needed because DRF does not perform the authentication at middleware layer
    instead it performs the authentication at View layer.

    This mixin adds behavior to connect/disconnect the signals needed by django-auditlog to auto
    log changes on models.
    It assumes that AuditlogMiddleware is activated in settings.MIDDLEWARE_CLASSES
    """

    @staticmethod
    def _set_actor(user, sender, instance, signal_duid, **kwargs):
        # This is a reimplementation of auditlog.context._set_actor.
        # Unfortunately the original logic cannot be used, because
        # there is a type mismatch between user and auth_user_model.
        if signal_duid != threadlocal.auditlog["signal_duid"]:
            return
        if (
            sender == LogEntry
            #and isinstance(user, auth_user_model)
            and instance.actor is None
        ):
            instance.actor = user

        instance.remote_addr = threadlocal.auditlog["remote_addr"]

    def initial(self, request, *args, **kwargs):
        """Overwritten to use django-auditlog if needed."""

        super().initial(request, *args, **kwargs)

        remote_addr = AuditlogMiddleware._get_remote_addr(request)
        actor = request.user
        set_actor = partial(
            self._set_actor,
            user=actor,
            signal_duid=threadlocal.auditlog["signal_duid"],
        )
        pre_save.connect(
            set_actor,
            sender=LogEntry,
            dispatch_uid=threadlocal.auditlog["signal_duid"],
            weak=False,
        )

    def finalize_response(self, request, response, *args, **kwargs):
        """Overwritten to cleanup django-auditlog if needed."""

        response = super().finalize_response(request, response, *args, **kwargs)

        if hasattr(threadlocal, 'auditlog'):
            pre_save.disconnect(sender=LogEntry, dispatch_uid=threadlocal.auditlog['signal_duid'])
        del threadlocal.auditlog

        return response

You then need to add this mixin to each of your views:

# views.py

...

class CustomerViewSet(DRFDjangoAuditModelMixin, ModelViewSet):
    queryset = Client.objects.all()
    serializer = ClientSerializer

....

The down side of this implementation is that it isn’t DRY on a couple of levels. Not only do you need to add the mixin to each DRF view, but it copies code from nearly all the logging behaviour of auditlog, particularly private methods. I therefore expect this solution to either need adjustment in the future, or for it to also become obsolete.

The solution above is based on this revision of auditlog.

Answered By: phantom-99w