Skip to content

Commit 26512b4

Browse files
committed
feat(auditlog): Add audit logs for file create/delete
1 parent 14a0afc commit 26512b4

6 files changed

Lines changed: 261 additions & 2 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright (C) 2026 CERN.
4+
#
5+
# Invenio-RDM-Records is free software; you can redistribute it and/or
6+
# modify it under the terms of the MIT License; see LICENSE file for more
7+
# details.
8+
9+
"""Audit log actions init."""
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright (C) 2026 CERN.
4+
#
5+
# Invenio-RDM-Records is free software; you can redistribute it and/or
6+
# modify it under the terms of the MIT License; see LICENSE file for more
7+
# details.
8+
9+
"""Action registration via entrypoint function."""
10+
11+
import marshmallow as ma
12+
from invenio_drafts_resources.auditlog.actions import BaseAuditLog
13+
from invenio_i18n import lazy_gettext as _
14+
15+
from .context import FileContext
16+
17+
18+
class FileCreateAuditLog(BaseAuditLog):
19+
"""Audit log for file create."""
20+
21+
resource_type = "draft"
22+
23+
context = BaseAuditLog.context + [
24+
FileContext(),
25+
]
26+
27+
id = "file.create"
28+
message_template = _(
29+
"User {user_id} created file {file_key} of {resource_type} {resource_id}."
30+
)
31+
32+
metadata_schema = {
33+
**BaseAuditLog.metadata_schema,
34+
"file_key": ma.fields.String(required=True),
35+
}
36+
37+
38+
class FileDeleteAuditLog(FileCreateAuditLog):
39+
"""Audit log for file delete."""
40+
41+
id = "file.delete"
42+
message_template = _(
43+
"User {user_id} deleted file {file_key} of {resource_type} {resource_id}."
44+
)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright (C) 2026 CERN.
4+
#
5+
# Invenio-RDM-Records is free software; you can redistribute it and/or
6+
# modify it under the terms of the MIT License; see LICENSE file for more
7+
# details.
8+
9+
"""Audit log context resolvers."""
10+
11+
from invenio_records.dictutils import dict_set
12+
13+
14+
class FileContext(object):
15+
"""Payload generator for setting file data."""
16+
17+
def __call__(self, data, **kwargs):
18+
"""Update data with file data."""
19+
file_key = kwargs.get("file_key", None)
20+
dict_set(data, "metadata.file_key", file_key)

invenio_rdm_records/services/access/service.py

Lines changed: 157 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from flask import current_app
1717
from flask_login import current_user
1818
from invenio_access.permissions import authenticated_user, system_identity
19+
from invenio_audit_logs.services.uow import AuditLogOp
1920
from invenio_base import invenio_url_for
2021
from invenio_drafts_resources.services.records import RecordService
2122
from invenio_drafts_resources.services.records.uow import ParentRecordCommitOp
@@ -30,6 +31,14 @@
3031
from marshmallow.exceptions import ValidationError
3132
from sqlalchemy.orm.exc import NoResultFound
3233

34+
from invenio_rdm_records.auditlog.actions import (
35+
RDMDraftAccessSettingsAuditLog,
36+
RDMDraftGrantAuditLog,
37+
RDMDraftSecretLinkAuditLog,
38+
RDMRecordAccessSettingsAuditLog,
39+
RDMRecordGrantAuditLog,
40+
RDMRecordSecretLinkAuditLog,
41+
)
3342
from invenio_rdm_records.notifications.builders import (
3443
GrantUserAccessNotificationBuilder,
3544
GuestAccessRequestTokenCreateNotificationBuilder,
@@ -222,6 +231,22 @@ def create_secret_link(self, identity, id_, data, links_config=None, uow=None):
222231
uow.register(ParentRecordCommitOp(parent, indexer_context=dict(service=self)))
223232
self._update_parent_request(parent, uow)
224233

234+
audit_log_builder = (
235+
RDMRecordSecretLinkAuditLog
236+
if isinstance(record, self.record_cls)
237+
else RDMDraftSecretLinkAuditLog
238+
)
239+
uow.register(
240+
AuditLogOp(
241+
audit_log_builder.build(
242+
identity,
243+
parent.pid.pid_value,
244+
before={},
245+
after=link.to_dict(),
246+
triggered_by=record,
247+
)
248+
)
249+
)
225250
return self.link_result_item(
226251
self,
227252
identity,
@@ -301,6 +326,7 @@ def update_secret_link(
301326

302327
link_idx = link_ids.index(link_id)
303328
link = parent.access.links[link_idx].resolve()
329+
old_link = link.to_dict()
304330

305331
# Validation
306332
data, __ = self.schema_secret_link.load(
@@ -323,6 +349,22 @@ def update_secret_link(
323349
uow.register(ParentRecordCommitOp(parent, indexer_context=dict(service=self)))
324350
self._update_parent_request(parent, uow)
325351

352+
audit_log_builder = (
353+
RDMRecordSecretLinkAuditLog
354+
if isinstance(record, self.record_cls)
355+
else RDMDraftSecretLinkAuditLog
356+
)
357+
uow.register(
358+
AuditLogOp(
359+
audit_log_builder.build(
360+
identity,
361+
parent.pid.pid_value,
362+
before=old_link,
363+
after=link.to_dict(),
364+
triggered_by=record,
365+
)
366+
)
367+
)
326368
return self.link_result_item(
327369
self,
328370
identity,
@@ -353,6 +395,22 @@ def delete_secret_link(self, identity, id_, link_id, links_config=None, uow=None
353395
uow.register(ParentRecordCommitOp(parent, indexer_context=dict(service=self)))
354396
self._update_parent_request(parent, uow)
355397

398+
audit_log_builder = (
399+
RDMRecordSecretLinkAuditLog
400+
if isinstance(record, self.record_cls)
401+
else RDMDraftSecretLinkAuditLog
402+
)
403+
uow.register(
404+
AuditLogOp(
405+
audit_log_builder.build(
406+
identity,
407+
parent.pid.pid_value,
408+
before=link.to_dict(),
409+
after={},
410+
triggered_by=record,
411+
)
412+
)
413+
)
356414
return True
357415

358416
#
@@ -447,6 +505,22 @@ def bulk_create_grants(self, identity, id_, data, expand=False, uow=None):
447505
uow.register(ParentRecordCommitOp(parent, indexer_context=dict(service=self)))
448506
self._update_parent_request(parent, uow)
449507

508+
audit_log_builder = (
509+
RDMRecordGrantAuditLog
510+
if isinstance(record, self.record_cls)
511+
else RDMDraftGrantAuditLog
512+
)
513+
uow.register(
514+
AuditLogOp(
515+
audit_log_builder.build(
516+
identity,
517+
parent.pid.pid_value,
518+
before=[],
519+
after=[g.to_dict() for g in new_grants],
520+
triggered_by=record,
521+
)
522+
)
523+
)
450524
return self.grants_result_list(
451525
self,
452526
identity,
@@ -542,6 +616,22 @@ def update_grant(
542616
uow.register(ParentRecordCommitOp(parent, indexer_context=dict(service=self)))
543617
self._update_parent_request(parent, uow)
544618

619+
audit_log_builder = (
620+
RDMRecordGrantAuditLog
621+
if isinstance(record, self.record_cls)
622+
else RDMDraftGrantAuditLog
623+
)
624+
uow.register(
625+
AuditLogOp(
626+
audit_log_builder.build(
627+
identity,
628+
parent.pid.pid_value,
629+
before=[old_grant.to_dict()],
630+
after=[new_grant.to_dict()],
631+
triggered_by=record,
632+
)
633+
)
634+
)
545635
return self.grant_result_item(
546636
self,
547637
identity,
@@ -596,11 +686,27 @@ def delete_grant(self, identity, id_, grant_id, uow=None):
596686
raise PermissionDeniedError()
597687

598688
# Deletion
599-
parent.access.grants.pop(grant_id)
689+
deleted_grant = parent.access.grants.pop(grant_id)
600690

601691
uow.register(ParentRecordCommitOp(parent, indexer_context=dict(service=self)))
602692
self._update_parent_request(parent, uow)
603693

694+
audit_log_builder = (
695+
RDMRecordGrantAuditLog
696+
if isinstance(record, self.record_cls)
697+
else RDMDraftGrantAuditLog
698+
)
699+
uow.register(
700+
AuditLogOp(
701+
audit_log_builder.build(
702+
identity,
703+
parent.pid.pid_value,
704+
before=[deleted_grant.to_dict()],
705+
after=[],
706+
triggered_by=record,
707+
)
708+
)
709+
)
604710
return True
605711

606712
def _exists(self, created_by, record_id, request_type):
@@ -844,10 +950,28 @@ def update_access_settings(
844950
)
845951

846952
# Update
953+
old_settings = parent.access.settings.dump()
847954
setattr(parent.access, "settings", data)
848955

849956
uow.register(ParentRecordCommitOp(parent, indexer_context=dict(service=self)))
850957

958+
audit_log_builder = (
959+
RDMRecordAccessSettingsAuditLog
960+
if isinstance(record, self.record_cls)
961+
else RDMDraftAccessSettingsAuditLog
962+
)
963+
uow.register(
964+
AuditLogOp(
965+
audit_log_builder.build(
966+
identity,
967+
parent.pid.pid_value,
968+
before=old_settings,
969+
after=parent.access.settings.dump(),
970+
triggered_by=record,
971+
)
972+
)
973+
)
974+
851975
return self.result_item(
852976
self,
853977
identity,
@@ -964,6 +1088,22 @@ def update_grant_by_subject(
9641088
uow.register(ParentRecordCommitOp(parent, indexer_context=dict(service=self)))
9651089
self._update_parent_request(parent, uow)
9661090

1091+
audit_log_builder = (
1092+
RDMRecordGrantAuditLog
1093+
if isinstance(record, self.record_cls)
1094+
else RDMDraftGrantAuditLog
1095+
)
1096+
uow.register(
1097+
AuditLogOp(
1098+
audit_log_builder.build(
1099+
identity,
1100+
parent.pid.pid_value,
1101+
before=[old_grant.to_dict()],
1102+
after=[new_grant.to_dict()],
1103+
triggered_by=record,
1104+
)
1105+
)
1106+
)
9671107
return self.grant_result_item(
9681108
self,
9691109
identity,
@@ -995,4 +1135,20 @@ def delete_grant_by_subject(
9951135
uow.register(ParentRecordCommitOp(parent, indexer_context=dict(service=self)))
9961136
self._update_parent_request(parent, uow)
9971137

1138+
audit_log_builder = (
1139+
RDMRecordGrantAuditLog
1140+
if isinstance(record, self.record_cls)
1141+
else RDMDraftGrantAuditLog
1142+
)
1143+
uow.register(
1144+
AuditLogOp(
1145+
audit_log_builder.build(
1146+
identity,
1147+
parent.pid.pid_value,
1148+
before=[result.to_dict()],
1149+
after=[],
1150+
triggered_by=record,
1151+
)
1152+
)
1153+
)
9981154
return True

invenio_rdm_records/services/files/service.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,14 @@
77

88
"""File Service API."""
99

10+
from invenio_audit_logs.services.uow import AuditLogOp
1011
from invenio_records_resources.services import FileService
12+
from invenio_records_resources.services.uow import unit_of_work
1113

14+
from invenio_rdm_records.auditlog.actions import (
15+
FileCreateAuditLog,
16+
FileDeleteAuditLog,
17+
)
1218
from invenio_rdm_records.services.errors import RecordDeletedException
1319

1420

@@ -32,3 +38,23 @@ def _get_record(self, id_, identity, action, file_key=None):
3238
self._check_record_deleted_permissions(record, identity)
3339

3440
return record
41+
42+
@unit_of_work()
43+
def commit_file(self, identity, id_, file_key, uow=None):
44+
"""Commit a file upload."""
45+
result = super().commit_file(identity, id_, file_key, uow=uow)
46+
47+
uow.register(
48+
AuditLogOp(FileCreateAuditLog.build(identity, id_, file_key=file_key))
49+
) # Added here as audit logs can't be added to invenio-records-resources
50+
return result
51+
52+
@unit_of_work()
53+
def delete_file(self, identity, id_, file_key, uow=None):
54+
"""Delete a file."""
55+
result = super().delete_file(identity, id_, file_key, uow=uow)
56+
57+
uow.register(
58+
AuditLogOp(FileDeleteAuditLog.build(identity, id_, file_key=file_key))
59+
) # Added here as audit logs can't be added to invenio-records-resources
60+
return result

setup.cfg

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,15 @@ install_requires =
4141
invenio-base>=2.3.0,<3.0.0
4242
invenio-checks>=7.0.0,<8.0.0
4343
invenio-communities>=25.0.0,<26.0.0
44-
invenio-drafts-resources>=8.0.0,<9.0.0
44+
invenio-drafts-resources>=9.0.0,<10.0.0
4545
invenio-records-resources>=9.0.0,<10.0.0
4646
invenio-i18n>=3.0.0,<4.0.0
4747
invenio-jobs>=8.0.0,<9.0.0
4848
invenio-oaiserver>=4.0.0,<5.0.0
4949
invenio-oauth2server>=4.0.0,<5.0.0
5050
invenio-stats>=6.0.0,<7.0.0
5151
invenio-vocabularies>=11.0.0,<12.0.0
52+
invenio-audit-logs>=2.0.0,<3.0.0
5253
nameparser>=1.1.1
5354
pycountry>=22.3.5
5455
pydash>=6.0.0,<7.0.0
@@ -147,6 +148,9 @@ invenio_users_resources.moderation.actions =
147148
approve = invenio_rdm_records.requests.user_moderation.actions:on_approve
148149
invenio_jobs.jobs =
149150
update_expired_embargos = invenio_rdm_records.jobs.jobs:update_expired_embargos_cls
151+
invenio_audit_logs.actions =
152+
file.create = invenio_rdm_records.auditlog.actions:FileCreateAuditLog
153+
file.delete = invenio_rdm_records.auditlog.actions:FileDeleteAuditLog
150154

151155
[build_sphinx]
152156
source-dir = docs/

0 commit comments

Comments
 (0)