audit: create audit logs for model creation/updating/deletion

This commit is contained in:
Jens Langhammer 2020-09-21 20:16:14 +02:00
parent e2cc2843d8
commit dbcdab05ff
5 changed files with 109 additions and 23 deletions

View file

@ -0,0 +1,87 @@
"""Audit middleware"""
from functools import partial
from typing import Callable
from django.contrib.auth.models import User
from django.db.models import Model
from django.db.models.signals import post_save, pre_delete
from django.http import HttpRequest, HttpResponse
from passbook.audit.models import Event, EventAction, model_to_dict
from passbook.audit.signals import EventNewThread
from passbook.core.middleware import LOCAL
class AuditMiddleware:
"""Register handlers for duration of request-response that log creation/update/deletion
of models"""
get_response: Callable[[HttpRequest], HttpResponse]
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
self.get_response = get_response
def __call__(self, request: HttpRequest) -> HttpResponse:
# Connect signal for automatic logging
if hasattr(request, "user") and getattr(
request.user, "is_authenticated", False
):
post_save_handler = partial(
self.post_save_handler, user=request.user, request=request
)
pre_delete_handler = partial(
self.pre_delete_handler, user=request.user, request=request
)
post_save.connect(
post_save_handler,
dispatch_uid=LOCAL.passbook["request_id"],
weak=False,
)
pre_delete.connect(
pre_delete_handler,
dispatch_uid=LOCAL.passbook["request_id"],
weak=False,
)
response = self.get_response(request)
post_save.disconnect(dispatch_uid=LOCAL.passbook["request_id"])
pre_delete.disconnect(dispatch_uid=LOCAL.passbook["request_id"])
return response
# pylint: disable=unused-argument
def process_exception(self, request: HttpRequest, exception: Exception):
"""Unregister handlers in case of exception"""
post_save.disconnect(dispatch_uid=LOCAL.passbook["request_id"])
pre_delete.disconnect(dispatch_uid=LOCAL.passbook["request_id"])
@staticmethod
# pylint: disable=unused-argument
def post_save_handler(
user: User, request: HttpRequest, sender, instance: Model, created: bool, **_
):
"""Signal handler for all object's post_save"""
if isinstance(instance, Event):
return
action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED
EventNewThread(
action, request, user=user, kwargs={"model": model_to_dict(instance)}
).run()
@staticmethod
# pylint: disable=unused-argument
def pre_delete_handler(
user: User, request: HttpRequest, sender, instance: Model, **_
):
"""Signal handler for all object's pre_delete"""
if isinstance(instance, Event):
return
EventNewThread(
EventAction.MODEL_DELETED,
request,
user=user,
kwargs={"model": model_to_dict(instance)},
).run()

View file

@ -1,7 +1,6 @@
"""passbook audit models"""
from enum import Enum
from inspect import getmodule, stack
from typing import Any, Dict, Optional
from typing import Any, Dict, Optional, Union
from uuid import UUID, uuid4
from django.conf import settings
@ -22,7 +21,7 @@ from passbook.core.middleware import (
from passbook.core.models import User
from passbook.lib.utils.http import get_client_ip
LOGGER = get_logger()
LOGGER = get_logger("passbook.audit")
def cleanse_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
@ -90,28 +89,29 @@ def sanitize_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
return final_dict
class EventAction(Enum):
class EventAction(models.TextChoices):
"""All possible actions to save into the audit log"""
LOGIN = "login"
LOGIN_FAILED = "login_failed"
LOGOUT = "logout"
SIGN_UP = "sign_up"
AUTHORIZE_APPLICATION = "authorize_application"
SUSPICIOUS_REQUEST = "suspicious_request"
SIGN_UP = "sign_up"
PASSWORD_RESET = "password_reset" # noqa # nosec
PASSWORD_SET = "password_set" # noqa # nosec
INVITE_CREATED = "invitation_created"
INVITE_USED = "invitation_used"
IMPERSONATION_STARTED = "impersonation_started"
IMPERSONATION_ENDED = "impersonation_ended"
CUSTOM = "custom"
@staticmethod
def as_choices():
"""Generate choices of actions used for database"""
return tuple(
(x, y.value) for x, y in getattr(EventAction, "__members__").items()
)
MODEL_CREATED = "model_created"
MODEL_UPDATED = "model_updated"
MODEL_DELETED = "model_deleted"
CUSTOM_PREFIX = "custom_"
class Event(models.Model):
@ -119,7 +119,7 @@ class Event(models.Model):
event_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
user = models.JSONField(default=dict)
action = models.TextField(choices=EventAction.as_choices())
action = models.TextField(choices=EventAction.choices)
date = models.DateTimeField(auto_now_add=True)
app = models.TextField()
context = models.JSONField(default=dict, blank=True)
@ -134,20 +134,18 @@ class Event(models.Model):
@staticmethod
def new(
action: EventAction,
action: Union[str, EventAction],
app: Optional[str] = None,
_inspect_offset: int = 1,
**kwargs,
) -> "Event":
"""Create new Event instance from arguments. Instance is NOT saved."""
if not isinstance(action, EventAction):
raise ValueError(
f"action must be EventAction instance but was {type(action)}"
)
action = EventAction.CUSTOM_PREFIX + action
if not app:
app = getmodule(stack()[_inspect_offset][0]).__name__
cleaned_kwargs = cleanse_dict(sanitize_dict(kwargs))
event = Event(action=action.value, app=app, context=cleaned_kwargs)
event = Event(action=action, app=app, context=cleaned_kwargs)
return event
def from_http(

View file

@ -20,12 +20,12 @@ from passbook.stages.user_write.signals import user_write
class EventNewThread(Thread):
"""Create Event in background thread"""
action: EventAction
action: str
request: HttpRequest
kwargs: Dict[str, Any]
user: Optional[User] = None
def __init__(self, action: EventAction, request: HttpRequest, **kwargs):
def __init__(self, action: str, request: HttpRequest, **kwargs):
super().__init__()
self.action = action
self.request = request

View file

@ -13,7 +13,7 @@ class TestAuditEvent(TestCase):
def test_new_with_model(self):
"""Create a new Event passing a model as kwarg"""
event = Event.new(EventAction.CUSTOM, test={"model": get_anonymous_user()})
event = Event.new("unittest", test={"model": get_anonymous_user()})
event.save() # We save to ensure nothing is un-saveable
model_content_type = ContentType.objects.get_for_model(get_anonymous_user())
self.assertEqual(
@ -24,7 +24,7 @@ class TestAuditEvent(TestCase):
def test_new_with_uuid_model(self):
"""Create a new Event passing a model (with UUID PK) as kwarg"""
temp_model = DummyPolicy.objects.create(name="test", result=True)
event = Event.new(EventAction.CUSTOM, model=temp_model)
event = Event.new("unittest", model=temp_model)
event.save() # We save to ensure nothing is un-saveable
model_content_type = ContentType.objects.get_for_model(temp_model)
self.assertEqual(

View file

@ -177,6 +177,7 @@ MIDDLEWARE = [
"django.contrib.sessions.middleware.SessionMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"passbook.core.middleware.RequestIDMiddleware",
"passbook.audit.middleware.AuditMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",