Skip to content

Commit 10c2cd0

Browse files
authored
Merge pull request #18 from City-of-Helsinki/ATV-51-add-audit-logging
ATV-51 | Add audit logging
2 parents 5001f6f + d4a9135 commit 10c2cd0

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)