Skip to content

Commit d4a9135

Browse files
committed
Add logic for audit logging
Add audit logging mechanism. `AuditLoggingModelViewSet` is included to be able to log all the actions done through a viewset. Viewset calls a separate logging function which writes audit logs in a specified format into the DB. Failing to write audit log will fail the whole operation i.e. action fails if audit log cannot be written. Later logs will be sent from the DB to Elasticsearch. Management commands for sending logs to Elasticsearch and to clean sent logs are included. Management command can be called e.g. periodically from a cron. Implementation is influenced by earlier work done for appartment-application-service, yjdh, open-city-profile and youth-membership projects. Log format follows the audit log specification for the project: - https://helsinkisolutionoffice.atlassian.net/wiki/spaces/KAN/pages/6400704679/Audit+log+Asiointitietovaranto - https://helsinkisolutionoffice.atlassian.net/wiki/spaces/KAN/pages/416972828/Helsinki+profile+audit+logging - https://github.com/City-of-Helsinki/apartment-application-service/tree/develop/audit_log - https://github.com/City-of-Helsinki/yjdh/tree/develop/backend/shared/shared/audit_log - https://github.com/City-of-Helsinki/open-city-profile/blob/develop/profiles/audit_log.py - https://github.com/City-of-Helsinki/youth-membership/blob/master/common_utils/audit_logging.py Refs ATV-51
1 parent 5001f6f commit d4a9135

28 files changed

Lines changed: 1088 additions & 1 deletion

atv/settings.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@
4545
FIELD_ENCRYPTION_KEYS=(list, []),
4646
ENABLE_AUTOMATIC_ATTACHMENT_FILE_DELETION=(bool, True),
4747
ENABLE_SWAGGER_UI=(bool, True),
48+
ELASTICSEARCH_APP_AUDIT_LOG_INDEX=(str, "atv_audit_log"),
49+
ELASTICSEARCH_CLOUD_ID=(str, ""),
50+
ELASTICSEARCH_API_ID=(str, ""),
51+
ELASTICSEARCH_API_KEY=(str, ""),
52+
ENABLE_SEND_AUDIT_LOG=(bool, False),
53+
CLEAR_AUDIT_LOG_ENTRIES=(bool, False),
54+
USE_X_FORWARDED_FOR=(bool, False),
4855
)
4956
if os.path.exists(env_file):
5057
env.read_env(env_file)
@@ -124,6 +131,7 @@
124131
"users",
125132
"services",
126133
"documents",
134+
"audit_log",
127135
]
128136

129137
MIDDLEWARE = [
@@ -206,6 +214,18 @@
206214
"VERSION": "0.0.1",
207215
}
208216

217+
# Audit logging
218+
219+
AUDIT_LOG_ORIGIN = "atv"
220+
ELASTICSEARCH_APP_AUDIT_LOG_INDEX = env("ELASTICSEARCH_APP_AUDIT_LOG_INDEX")
221+
ELASTICSEARCH_CLOUD_ID = env("ELASTICSEARCH_CLOUD_ID")
222+
ELASTICSEARCH_API_ID = env("ELASTICSEARCH_API_ID")
223+
ELASTICSEARCH_API_KEY = env("ELASTICSEARCH_API_KEY")
224+
ENABLE_SEND_AUDIT_LOG = env("ENABLE_SEND_AUDIT_LOG")
225+
CLEAR_AUDIT_LOG_ENTRIES = env("CLEAR_AUDIT_LOG_ENTRIES")
226+
227+
USE_X_FORWARDED_FOR = env("USE_X_FORWARDED_FOR")
228+
209229
LOGGING = {
210230
"version": 1,
211231
"disable_existing_loggers": False,

audit_log/__init__.py

Whitespace-only changes.

audit_log/admin.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from django.contrib import admin
2+
3+
from audit_log.models import AuditLogEntry
4+
5+
6+
@admin.register(AuditLogEntry)
7+
class AuditLogEntryAdmin(admin.ModelAdmin):
8+
fields = ("id", "created_at", "message")
9+
readonly_fields = ("id", "created_at", "message")
10+
11+
def has_delete_permission(self, request, obj=None):
12+
return False
13+
14+
def has_add_permission(self, request):
15+
return False
16+
17+
def has_change_permission(self, request, obj=None):
18+
return False

audit_log/apps.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from django.apps import AppConfig
2+
from django.utils.translation import gettext_lazy as _
3+
4+
5+
class AuditLogConfig(AppConfig):
6+
default_auto_field = "django.db.models.BigAutoField"
7+
name = "audit_log"
8+
verbose_name = _("audit log")

audit_log/audit_logging.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
from datetime import datetime, timezone
2+
from typing import Callable, Optional, Union
3+
4+
from django.conf import settings
5+
from django.contrib.auth import get_user_model
6+
from django.contrib.auth.models import AnonymousUser
7+
from django.db.models import Model
8+
from django.db.models.base import ModelBase
9+
10+
from audit_log.enums import Operation, Role, Status
11+
from audit_log.models import AuditLogEntry
12+
13+
User = get_user_model()
14+
15+
16+
def _now() -> datetime:
17+
"""Returns the current time in UTC timezone."""
18+
return datetime.now(tz=timezone.utc)
19+
20+
21+
def _iso8601_date(time: datetime) -> str:
22+
"""Formats the timestamp in ISO-8601 format, e.g. '2020-06-01T00:00:00.000Z'."""
23+
return f"{time.replace(tzinfo=None).isoformat(timespec='milliseconds')}Z"
24+
25+
26+
def log(
27+
actor: Optional[Union[User, AnonymousUser]],
28+
actor_backend: str,
29+
operation: Operation,
30+
target: Union[Model, ModelBase],
31+
status: Status = Status.SUCCESS,
32+
get_time: Callable[[], datetime] = _now,
33+
ip_address: str = "",
34+
additional_information: str = "",
35+
):
36+
"""
37+
Write an event to the audit log.
38+
39+
Each audit log event has an actor (or None for system events),
40+
an operation(e.g. READ or UPDATE), the target of the operation
41+
(a Django model instance), status (e.g. SUCCESS), and a timestamp.
42+
"""
43+
current_time = get_time()
44+
user_id_field_name = getattr(target, "audit_log_id_field", "pk")
45+
user_id = str(getattr(actor, user_id_field_name, ""))
46+
47+
if actor is None:
48+
role = Role.SYSTEM
49+
elif isinstance(actor, AnonymousUser):
50+
role = Role.ANONYMOUS
51+
elif actor.id == target.pk:
52+
role = Role.OWNER
53+
else:
54+
role = Role.USER
55+
56+
message = {
57+
"audit_event": {
58+
"origin": settings.AUDIT_LOG_ORIGIN,
59+
"status": status.value,
60+
"date_time_epoch": int(current_time.timestamp() * 1000),
61+
"date_time": _iso8601_date(current_time),
62+
"actor": {
63+
"role": role.value,
64+
"user_id": user_id,
65+
"provider": actor_backend if actor_backend else "",
66+
"ip_address": ip_address,
67+
},
68+
"operation": operation.value,
69+
"additional_information": additional_information,
70+
"target": {
71+
"id": _get_target_id(target),
72+
"type": _get_target_type(target),
73+
},
74+
},
75+
}
76+
AuditLogEntry.objects.create(message=message)
77+
78+
79+
def _get_target_id(target: Union[Model, ModelBase]) -> str:
80+
if isinstance(target, ModelBase):
81+
return ""
82+
field_name = getattr(target, "audit_log_id_field", "pk")
83+
audit_log_id = getattr(target, field_name, "")
84+
return str(audit_log_id)
85+
86+
87+
def _get_target_type(target: Union[Model, ModelBase]) -> str:
88+
return (
89+
str(target.__class__.__name__)
90+
if isinstance(target, Model)
91+
else str(target.__name__)
92+
)

audit_log/enums.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from django.db.models import TextChoices
2+
from django.utils.translation import gettext_lazy as _
3+
4+
5+
class Operation(TextChoices):
6+
CREATE = "CREATE", _("Create")
7+
READ = "READ", _("Read")
8+
UPDATE = "UPDATE", _("Update")
9+
DELETE = "DELETE", _("Delete")
10+
11+
12+
class Role(TextChoices):
13+
OWNER = "OWNER", _("Owner")
14+
USER = "USER", _("User")
15+
SYSTEM = "SYSTEM", _("System")
16+
ANONYMOUS = "ANONYMOUS", _("Anonymous")
17+
18+
19+
class Status(TextChoices):
20+
SUCCESS = "SUCCESS", _("Success")
21+
FORBIDDEN = "FORBIDDEN", _("Forbidden")

audit_log/management/__init__.py

Whitespace-only changes.

audit_log/management/commands/__init__.py

Whitespace-only changes.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from django.core.management.base import BaseCommand
2+
3+
from audit_log.tasks import clear_audit_log_entries
4+
5+
6+
class Command(BaseCommand):
7+
help = "Clear old sent audit log entries from database"
8+
9+
def handle(self, *args, **kwargs):
10+
clear_audit_log_entries()
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from django.core.management.base import BaseCommand
2+
3+
from audit_log.tasks import send_audit_log_entries
4+
5+
6+
class Command(BaseCommand):
7+
help = "Send unsent audit log entries to Elasticsearch"
8+
9+
def handle(self, *args, **kwargs):
10+
send_audit_log_entries()

0 commit comments

Comments
 (0)