Feature: Audit Trail (#4425)

Adds new feature for optionally enabling change tracking for possible audit purposes
---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
Co-authored-by: Trenton Holmes <797416+stumpylog@users.noreply.github.com>
This commit is contained in:
nanokatz 2023-10-30 17:23:22 +01:00 committed by GitHub
parent f695d4b9da
commit 38e035b95c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 169 additions and 5 deletions

View File

@ -52,6 +52,7 @@ bleach = "*"
zxing-cpp = {version = "*", platform_machine = "== 'x86_64'"} zxing-cpp = {version = "*", platform_machine = "== 'x86_64'"}
django-multiselectfield = "*" django-multiselectfield = "*"
gotenberg-client = "*" gotenberg-client = "*"
django-auditlog = "*"
[dev-packages] [dev-packages]
# Linting # Linting

19
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "505bd6b18d31ed64988ef307c12a5acb70f611cafd932a391e985a11bbbc8000" "sha256": "7b4272de2042a346f3252ae20e7bbeee60c375381f59526caa35511a706d4977"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": {}, "requires": {},
@ -429,12 +429,21 @@
}, },
"django": { "django": {
"hashes": [ "hashes": [
"sha256:5e5c1c9548ffb7796b4a8a4782e9a2e5a3df3615259fc1bfd3ebc73b646146c1", "sha256:08f41f468b63335aea0d904c5729e0250300f6a1907bf293a65499496cdbc68f",
"sha256:b6b2b5cae821077f137dc4dade696a1c2aa292f892eca28fa8d7bfdf2608ddd4" "sha256:a64d2487cdb00ad7461434320ccc38e60af9c404773a2f95ab0093b4453a3215"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==4.2.5" "version": "==4.2.6"
},
"django-auditlog": {
"hashes": [
"sha256:7bc2c87e4aff62dec9785d1b2359a2b27148f8c286f8a52b9114fc7876c5a9f7",
"sha256:b9d3acebb64f3f2785157efe3f2f802e0929aafc579d85bbfb9827db4adab532"
],
"index": "pypi",
"markers": "python_version >= '3.7'",
"version": "==2.3.0"
}, },
"django-celery-results": { "django-celery-results": {
"hashes": [ "hashes": [
@ -1817,7 +1826,7 @@
"sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0", "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0",
"sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef" "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef"
], ],
"markers": "python_version < '3.10'", "markers": "python_version < '3.11'",
"version": "==4.8.0" "version": "==4.8.0"
}, },
"tzdata": { "tzdata": {

View File

@ -1136,6 +1136,15 @@ combination with PAPERLESS_CONSUMER_BARCODE_UPSCALE bigger than 1.0.
Defaults to "300" Defaults to "300"
## Audit Trail
#### [`PAPERLESS_AUDIT_LOG_ENABLED=<bool>`](#PAPERLESS_AUDIT_LOG_ENABLED){#PAPERLESS_AUDIT_LOG_ENABLED}
: Enables an audit trail for documents, document types, correspondents, and tags. Log entries can be viewed in the Django backend only.
!!! warning
Once enabled cannot be disabled
## Collate Double-Sided Documents {#collate} ## Collate Double-Sided Documents {#collate}
#### [`PAPERLESS_CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED=<bool>`](#PAPERLESS_CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED) {#PAPERLESS_CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED} #### [`PAPERLESS_CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED=<bool>`](#PAPERLESS_CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED) {#PAPERLESS_CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED}

View File

@ -1,3 +1,4 @@
from django.conf import settings
from django.contrib import admin from django.contrib import admin
from guardian.admin import GuardedModelAdmin from guardian.admin import GuardedModelAdmin
@ -12,6 +13,10 @@ from documents.models import ShareLink
from documents.models import StoragePath from documents.models import StoragePath
from documents.models import Tag from documents.models import Tag
if settings.AUDIT_LOG_ENABLED:
from auditlog.admin import LogEntryAdmin
from auditlog.models import LogEntry
class CorrespondentAdmin(GuardedModelAdmin): class CorrespondentAdmin(GuardedModelAdmin):
list_display = ("name", "match", "matching_algorithm") list_display = ("name", "match", "matching_algorithm")
@ -148,3 +153,12 @@ admin.site.register(StoragePath, StoragePathAdmin)
admin.site.register(PaperlessTask, TaskAdmin) admin.site.register(PaperlessTask, TaskAdmin)
admin.site.register(Note, NotesAdmin) admin.site.register(Note, NotesAdmin)
admin.site.register(ShareLink, ShareLinksAdmin) admin.site.register(ShareLink, ShareLinksAdmin)
if settings.AUDIT_LOG_ENABLED:
class LogEntryAUDIT(LogEntryAdmin):
def has_delete_permission(self, request, obj=None):
return False
admin.site.unregister(LogEntry)
admin.site.register(LogEntry, LogEntryAUDIT)

View File

@ -20,6 +20,9 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from multiselectfield import MultiSelectField from multiselectfield import MultiSelectField
if settings.AUDIT_LOG_ENABLED:
from auditlog.registry import auditlog
from documents.data_models import DocumentSource from documents.data_models import DocumentSource
from documents.parsers import get_default_file_extension from documents.parsers import get_default_file_extension
@ -872,3 +875,11 @@ class ConsumptionTemplate(models.Model):
def __str__(self): def __str__(self):
return f"{self.name}" return f"{self.name}"
if settings.AUDIT_LOG_ENABLED:
auditlog.register(Document, m2m_fields={"tags"})
auditlog.register(Correspondent)
auditlog.register(Tag)
auditlog.register(DocumentType)
auditlog.register(Note)

View File

@ -37,6 +37,10 @@ from documents.parsers import DocumentParser
from documents.parsers import get_parser_class_for_mime_type from documents.parsers import get_parser_class_for_mime_type
from documents.sanity_checker import SanityCheckFailedException from documents.sanity_checker import SanityCheckFailedException
if settings.AUDIT_LOG_ENABLED:
import json
from auditlog.models import LogEntry
logger = logging.getLogger("paperless.tasks") logger = logging.getLogger("paperless.tasks")
@ -258,11 +262,37 @@ def update_document_archive_file(document_id):
document, document,
archive_filename=True, archive_filename=True,
) )
oldDocument = Document.objects.get(pk=document.pk)
Document.objects.filter(pk=document.pk).update( Document.objects.filter(pk=document.pk).update(
archive_checksum=checksum, archive_checksum=checksum,
content=parser.get_text(), content=parser.get_text(),
archive_filename=document.archive_filename, archive_filename=document.archive_filename,
) )
newDocument = Document.objects.get(pk=document.pk)
if settings.AUDIT_LOG_ENABLED:
LogEntry.objects.log_create(
instance=oldDocument,
changes=json.dumps(
{
"content": [oldDocument.content, newDocument.content],
"archive_checksum": [
oldDocument.archive_checksum,
newDocument.archive_checksum,
],
"archive_filename": [
oldDocument.archive_filename,
newDocument.archive_filename,
],
},
),
additional_data=json.dumps(
{
"reason": "Redo OCR called",
},
),
action=LogEntry.Action.UPDATE,
)
with FileLock(settings.MEDIA_LOCK): with FileLock(settings.MEDIA_LOCK):
create_source_path_directory(document.archive_path) create_source_path_directory(document.archive_path)
shutil.move(parser.get_archive_path(), document.archive_path) shutil.move(parser.get_archive_path(), document.archive_path)

View File

@ -115,6 +115,9 @@ from paperless import version
from paperless.db import GnuPG from paperless.db import GnuPG
from paperless.views import StandardPagination from paperless.views import StandardPagination
if settings.AUDIT_LOG_ENABLED:
from auditlog.models import LogEntry
logger = logging.getLogger("paperless.api") logger = logging.getLogger("paperless.api")
@ -521,6 +524,18 @@ class DocumentViewSet(
user=currentUser, user=currentUser,
) )
c.save() c.save()
# If audit log is enabled make an entry in the log
# about this note change
if settings.AUDIT_LOG_ENABLED:
LogEntry.objects.log_create(
instance=doc,
changes=json.dumps(
{
"Note Added": ["None", c.id],
},
),
action=LogEntry.Action.UPDATE,
)
doc.modified = timezone.now() doc.modified = timezone.now()
doc.save() doc.save()
@ -546,6 +561,17 @@ class DocumentViewSet(
return HttpResponseForbidden("Insufficient permissions to delete") return HttpResponseForbidden("Insufficient permissions to delete")
note = Note.objects.get(id=int(request.GET.get("id"))) note = Note.objects.get(id=int(request.GET.get("id")))
if settings.AUDIT_LOG_ENABLED:
LogEntry.objects.log_create(
instance=doc,
changes=json.dumps(
{
"Note Deleted": [note.id, "None"],
},
),
action=LogEntry.Action.UPDATE,
)
note.delete() note.delete()
doc.modified = timezone.now() doc.modified = timezone.now()

View File

@ -1,4 +1,5 @@
from paperless.celery import app as celery_app from paperless.celery import app as celery_app
from paperless.checks import audit_log_check
from paperless.checks import binaries_check from paperless.checks import binaries_check
from paperless.checks import paths_check from paperless.checks import paths_check
from paperless.checks import settings_values_check from paperless.checks import settings_values_check
@ -8,4 +9,5 @@ __all__ = [
"binaries_check", "binaries_check",
"paths_check", "paths_check",
"settings_values_check", "settings_values_check",
"audit_log_check",
] ]

View File

@ -5,9 +5,11 @@ import shutil
import stat import stat
from django.conf import settings from django.conf import settings
from django.core.checks import Critical
from django.core.checks import Error from django.core.checks import Error
from django.core.checks import Warning from django.core.checks import Warning
from django.core.checks import register from django.core.checks import register
from django.db import connections
exists_message = "{} is set but doesn't exist." exists_message = "{} is set but doesn't exist."
exists_hint = "Create a directory at {}" exists_hint = "Create a directory at {}"
@ -195,3 +197,19 @@ def settings_values_check(app_configs, **kwargs):
+ _barcode_scanner_validate() + _barcode_scanner_validate()
+ _email_certificate_validate() + _email_certificate_validate()
) )
@register()
def audit_log_check(app_configs, **kwargs):
db_conn = connections["default"]
all_tables = db_conn.introspection.table_names()
if ("auditlog_logentry" in all_tables) and not (settings.AUDIT_LOG_ENABLED):
return [
Critical(
(
"auditlog table was found but PAPERLESS_AUDIT_LOG_ENABLED"
" is not active. This setting cannot be disabled after enabling"
),
),
]

View File

@ -933,6 +933,11 @@ TIKA_GOTENBERG_ENDPOINT = os.getenv(
if TIKA_ENABLED: if TIKA_ENABLED:
INSTALLED_APPS.append("paperless_tika.apps.PaperlessTikaConfig") INSTALLED_APPS.append("paperless_tika.apps.PaperlessTikaConfig")
AUDIT_LOG_ENABLED = __get_boolean("PAPERLESS_AUDIT_LOG_ENABLED", "NO")
if AUDIT_LOG_ENABLED:
INSTALLED_APPS.append("auditlog")
MIDDLEWARE.append("auditlog.middleware.AuditlogMiddleware")
def _parse_ignore_dates( def _parse_ignore_dates(
env_ignore: str, env_ignore: str,

View File

@ -1,11 +1,13 @@
import os import os
from pathlib import Path from pathlib import Path
from unittest import mock
from django.test import TestCase from django.test import TestCase
from django.test import override_settings from django.test import override_settings
from documents.tests.utils import DirectoriesMixin from documents.tests.utils import DirectoriesMixin
from documents.tests.utils import FileSystemAssertsMixin from documents.tests.utils import FileSystemAssertsMixin
from paperless.checks import audit_log_check
from paperless.checks import binaries_check from paperless.checks import binaries_check
from paperless.checks import debug_mode_check from paperless.checks import debug_mode_check
from paperless.checks import paths_check from paperless.checks import paths_check
@ -231,3 +233,35 @@ class TestEmailCertSettingsChecks(DirectoriesMixin, FileSystemAssertsMixin, Test
msg = msgs[0] msg = msgs[0]
self.assertIn("Email cert /tmp/not_actually_here.pem is not a file", msg.msg) self.assertIn("Email cert /tmp/not_actually_here.pem is not a file", msg.msg)
class TestAuditLogChecks(TestCase):
def test_was_enabled_once(self):
"""
GIVEN:
- Audit log is not enabled
WHEN:
- Database tables contain audit log entry
THEN:
- system check error reported for disabling audit log
"""
introspect_mock = mock.MagicMock()
introspect_mock.introspection.table_names.return_value = ["auditlog_logentry"]
with override_settings(AUDIT_LOG_ENABLED=False):
with mock.patch.dict(
"paperless.checks.connections",
{"default": introspect_mock},
):
msgs = audit_log_check(None)
self.assertEqual(len(msgs), 1)
msg = msgs[0]
self.assertIn(
(
"auditlog table was found but PAPERLESS_AUDIT_LOG_ENABLED"
" is not active."
),
msg.msg,
)

View File

@ -21,6 +21,11 @@ omit =
paperless/wsgi.py paperless/wsgi.py
paperless/auth.py paperless/auth.py
[coverage:report]
exclude_also =
if settings.AUDIT_LOG_ENABLED:
if AUDIT_LOG_ENABLED:
[mypy] [mypy]
plugins = mypy_django_plugin.main, mypy_drf_plugin.main, numpy.typing.mypy_plugin plugins = mypy_django_plugin.main, mypy_drf_plugin.main, numpy.typing.mypy_plugin
check_untyped_defs = true check_untyped_defs = true