Skip to content

Commit cc80e3e

Browse files
authored
feat: Add applet IAM audit event logging (#2057)
* feat: Add applet IAM audit event logging Add audit event logging to applet-related endpoints: - Applet create, delete, encryption update - Transfer ownership initiate, accept, decline - Invitation send (respondent, reviewer, manager, subject, shell account) - Invitation accept (regular and private), decline - Remove team member, change team member permissions - Data retention and report configuration changes * feat: add audit logging to delete_subject endpoint * chore: fixed lint issues * fix: Add null checks for invitation lookup in audit logging * fix: logging subject_id for delete_subject audit event log * chore: refactor access audit events * fix: add user_target_email to shell account audit event * chore: format invitations router to pass ruff check * chore: add explanatory comments to silenced exceptions These were suggestions made by github-security-bot Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * fix: log granted roles on APPLET_ACCESS_GRANT action * chore: resolve merge conflicts with dev
1 parent 5aaca19 commit cc80e3e

13 files changed

Lines changed: 1208 additions & 214 deletions

File tree

src/apps/applets/api/applets.py

Lines changed: 134 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from fastapi import Body, Depends
66
from firebase_admin.exceptions import FirebaseError
7+
from starlette.requests import Request
78
from starlette.responses import Response as HTTPResponse
89

910
from apps.activities.crud import ActivitiesCRUD
@@ -35,9 +36,10 @@
3536
from apps.applets.filters import AppletQueryParams, FlowItemHistoryExportQueryParams
3637
from apps.applets.service import AppletHistoryService, AppletService
3738
from apps.applets.service.applet_history import retrieve_applet_by_version, retrieve_versions
39+
from apps.audit import AuditEvent, EventAction, http_audit_fields, log
3840
from apps.authentication.deps import get_current_user
3941
from apps.shared.domain.response import Response, ResponseMulti
40-
from apps.shared.exception import NotFoundError
42+
from apps.shared.exception import BaseError, NotFoundError
4143
from apps.shared.link import convert_link_key
4244
from apps.shared.query_params import QueryParams, parse_query_params
4345
from apps.subjects.services import SubjectsService
@@ -124,17 +126,37 @@ async def applet_retrieve_by_key(
124126

125127
async def applet_create(
126128
owner_id: uuid.UUID,
129+
request: Request,
127130
user: User = Depends(get_current_user),
128131
schema: AppletCreate = Body(...),
129132
session=Depends(get_session),
130133
) -> Response[public_detail.Applet]:
131-
async with atomic(session):
132-
await CheckAccessService(session, user.id).check_applet_create_access(owner_id)
133-
has_editor = await UserAppletAccessCRUD(session).check_access_by_user_and_owner(
134-
user_id=user.id, owner_id=owner_id, roles=[Role.EDITOR]
134+
try:
135+
async with atomic(session):
136+
await CheckAccessService(session, user.id).check_applet_create_access(owner_id)
137+
has_editor = await UserAppletAccessCRUD(session).check_access_by_user_and_owner(
138+
user_id=user.id, owner_id=owner_id, roles=[Role.EDITOR]
139+
)
140+
manager_role = Role.EDITOR if has_editor else None
141+
applet = await AppletService(session, owner_id).create(schema, user.id, manager_role)
142+
except BaseError as e:
143+
await log(
144+
AuditEvent(
145+
event_action=EventAction.APPLET_CREATE,
146+
user_id=user.id,
147+
**http_audit_fields(request, e),
148+
)
149+
)
150+
raise
151+
152+
await log(
153+
AuditEvent(
154+
event_action=EventAction.APPLET_CREATE,
155+
user_id=user.id,
156+
curious_applet_id=[applet.id],
157+
**http_audit_fields(request),
135158
)
136-
manager_role = Role.EDITOR if has_editor else None
137-
applet = await AppletService(session, owner_id).create(schema, user.id, manager_role)
159+
)
138160
return Response(result=public_detail.Applet.model_validate(applet))
139161

140162

@@ -165,15 +187,36 @@ async def applet_update(
165187

166188
async def applet_encryption_update(
167189
applet_id: uuid.UUID,
190+
request: Request,
168191
user: User = Depends(get_current_user),
169192
schema: Encryption = Body(...),
170193
session=Depends(get_session),
171194
) -> Response[public_detail.Encryption]:
172-
async with atomic(session):
173-
service = AppletService(session, user.id)
174-
await service.exist_by_id(applet_id)
175-
await CheckAccessService(session, user.id).check_applet_edit_access(applet_id)
176-
await service.update_encryption(applet_id, schema)
195+
try:
196+
async with atomic(session):
197+
service = AppletService(session, user.id)
198+
await service.exist_by_id(applet_id)
199+
await CheckAccessService(session, user.id).check_applet_edit_access(applet_id)
200+
await service.update_encryption(applet_id, schema)
201+
except BaseError as e:
202+
await log(
203+
AuditEvent(
204+
event_action=EventAction.APPLET_ENCRYPTION_UPDATE,
205+
user_id=user.id,
206+
curious_applet_id=[applet_id],
207+
**http_audit_fields(request, e),
208+
)
209+
)
210+
raise
211+
212+
await log(
213+
AuditEvent(
214+
event_action=EventAction.APPLET_ENCRYPTION_UPDATE,
215+
user_id=user.id,
216+
curious_applet_id=[applet_id],
217+
**http_audit_fields(request),
218+
)
219+
)
177220
return Response(result=public_detail.Encryption.model_validate(schema))
178221

179222

@@ -197,15 +240,36 @@ async def applet_duplicate(
197240

198241
async def applet_set_report_configuration(
199242
applet_id: uuid.UUID,
243+
request: Request,
200244
user: User = Depends(get_current_user),
201245
schema: AppletReportConfiguration = Body(...),
202246
session=Depends(get_session),
203247
):
204-
async with atomic(session):
205-
service = AppletService(session, user.id)
206-
await service.exist_by_id(applet_id)
207-
await CheckAccessService(session, user.id).check_applet_edit_access(applet_id)
208-
await service.set_report_configuration(applet_id, schema)
248+
try:
249+
async with atomic(session):
250+
service = AppletService(session, user.id)
251+
await service.exist_by_id(applet_id)
252+
await CheckAccessService(session, user.id).check_applet_edit_access(applet_id)
253+
await service.set_report_configuration(applet_id, schema)
254+
except BaseError as e:
255+
await log(
256+
AuditEvent(
257+
event_action=EventAction.APPLET_REPORT_UPDATE,
258+
user_id=user.id,
259+
curious_applet_id=[applet_id],
260+
**http_audit_fields(request, e),
261+
)
262+
)
263+
raise
264+
265+
await log(
266+
AuditEvent(
267+
event_action=EventAction.APPLET_REPORT_UPDATE,
268+
user_id=user.id,
269+
curious_applet_id=[applet_id],
270+
**http_audit_fields(request),
271+
)
272+
)
209273

210274

211275
async def flow_report_config_update(
@@ -331,15 +395,36 @@ async def applet_version_changes_retrieve(
331395

332396
async def applet_delete(
333397
applet_id: uuid.UUID,
398+
request: Request,
334399
user: User = Depends(get_current_user),
335400
session=Depends(get_session),
336401
):
337-
async with atomic(session):
338-
service = AppletService(session, user.id)
339-
await service.exist_by_id(applet_id)
340-
await CheckAccessService(session, user.id).check_applet_delete_access(applet_id)
341-
respondents_device_ids = await AppletsCRUD(session).get_respondents_device_ids(applet_id)
342-
await service.delete_applet_by_id(applet_id)
402+
try:
403+
async with atomic(session):
404+
service = AppletService(session, user.id)
405+
await service.exist_by_id(applet_id)
406+
await CheckAccessService(session, user.id).check_applet_delete_access(applet_id)
407+
respondents_device_ids = await AppletsCRUD(session).get_respondents_device_ids(applet_id)
408+
await service.delete_applet_by_id(applet_id)
409+
except BaseError as e:
410+
await log(
411+
AuditEvent(
412+
event_action=EventAction.APPLET_DELETE,
413+
user_id=user.id,
414+
curious_applet_id=[applet_id],
415+
**http_audit_fields(request, e),
416+
)
417+
)
418+
raise
419+
420+
await log(
421+
AuditEvent(
422+
event_action=EventAction.APPLET_DELETE,
423+
user_id=user.id,
424+
curious_applet_id=[applet_id],
425+
**http_audit_fields(request),
426+
)
427+
)
343428
try:
344429
await service.send_notification_to_applet_respondents(
345430
applet_id,
@@ -415,15 +500,36 @@ async def applet_link_delete(
415500

416501
async def applet_set_data_retention(
417502
applet_id: uuid.UUID,
503+
request: Request,
418504
schema: AppletDataRetention,
419505
user: User = Depends(get_current_user),
420506
session=Depends(get_session),
421507
):
422-
async with atomic(session):
423-
service = AppletService(session, user.id)
424-
await service.exist_by_id(applet_id)
425-
await CheckAccessService(session, user.id).check_applet_retention_access(applet_id)
426-
await service.set_data_retention(applet_id, schema)
508+
try:
509+
async with atomic(session):
510+
service = AppletService(session, user.id)
511+
await service.exist_by_id(applet_id)
512+
await CheckAccessService(session, user.id).check_applet_retention_access(applet_id)
513+
await service.set_data_retention(applet_id, schema)
514+
except BaseError as e:
515+
await log(
516+
AuditEvent(
517+
event_action=EventAction.APPLET_RETENTION_UPDATE,
518+
user_id=user.id,
519+
curious_applet_id=[applet_id],
520+
**http_audit_fields(request, e),
521+
)
522+
)
523+
raise
524+
525+
await log(
526+
AuditEvent(
527+
event_action=EventAction.APPLET_RETENTION_UPDATE,
528+
user_id=user.id,
529+
curious_applet_id=[applet_id],
530+
**http_audit_fields(request),
531+
)
532+
)
427533

428534

429535
async def applet_retrieve_base_info(

src/apps/applets/tests/test_applet.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from apps.applets.domain.base import AppletReportConfigurationBase, Encryption
2929
from apps.applets.errors import AppletAlreadyExist, AppletVersionNotFoundError
3030
from apps.applets.service.applet import AppletService
31+
from apps.audit.enums import EventAction, EventOutcome
3132
from apps.shared.enums import Language
3233
from apps.shared.exception import NotFoundError
3334
from apps.shared.test.client import TestClient
@@ -1366,3 +1367,150 @@ async def test_editor_can_update_applet(
13661367
assert response.status_code == http.HTTPStatus.OK, response.json()
13671368
data = response.json()["result"]
13681369
assert data["displayName"] == "Updated Name"
1370+
1371+
# ── Audit event tests ──
1372+
1373+
async def test_create_applet_audit_event(
1374+
self, client: TestClient, tom: User, applet_minimal_data: AppletCreate, mocker: MockerFixture
1375+
):
1376+
audit_log = mocker.patch("apps.applets.api.applets.log")
1377+
client.login(tom)
1378+
response = await client.post(
1379+
self.applet_create_url.format(owner_id=tom.id),
1380+
data=applet_minimal_data,
1381+
)
1382+
assert response.status_code == http.HTTPStatus.CREATED
1383+
audit_log.assert_awaited_once()
1384+
event = audit_log.call_args[0][0]
1385+
assert event.event_action == EventAction.APPLET_CREATE
1386+
assert event.event_outcome == EventOutcome.SUCCESS
1387+
assert event.user_id == tom.id
1388+
assert event.curious_applet_id == [uuid.UUID(response.json()["result"]["id"])]
1389+
1390+
async def test_create_applet_audit_event_failure(
1391+
self, client: TestClient, lucy: User, bob: User, applet_minimal_data: AppletCreate, mocker: MockerFixture
1392+
):
1393+
audit_log = mocker.patch("apps.applets.api.applets.log")
1394+
client.login(lucy)
1395+
response = await client.post(
1396+
self.applet_create_url.format(owner_id=bob.id),
1397+
data=applet_minimal_data,
1398+
)
1399+
assert response.status_code == http.HTTPStatus.FORBIDDEN
1400+
audit_log.assert_awaited_once()
1401+
event = audit_log.call_args[0][0]
1402+
assert event.event_action == EventAction.APPLET_CREATE
1403+
assert event.event_outcome == EventOutcome.FAILURE
1404+
assert event.user_id == lucy.id
1405+
1406+
async def test_delete_applet_audit_event(
1407+
self, client: TestClient, tom: User, applet_one: AppletFull, mocker: MockerFixture
1408+
):
1409+
audit_log = mocker.patch("apps.applets.api.applets.log")
1410+
client.login(tom)
1411+
response = await client.delete(
1412+
self.applet_detail_url.format(pk=applet_one.id),
1413+
)
1414+
assert response.status_code == http.HTTPStatus.NO_CONTENT
1415+
audit_log.assert_awaited_once()
1416+
event = audit_log.call_args[0][0]
1417+
assert event.event_action == EventAction.APPLET_DELETE
1418+
assert event.event_outcome == EventOutcome.SUCCESS
1419+
assert event.user_id == tom.id
1420+
assert event.curious_applet_id == [applet_one.id]
1421+
1422+
async def test_delete_applet_audit_event_failure(
1423+
self, client: TestClient, tom: User, uuid_zero: uuid.UUID, mocker: MockerFixture
1424+
):
1425+
audit_log = mocker.patch("apps.applets.api.applets.log")
1426+
client.login(tom)
1427+
response = await client.delete(
1428+
self.applet_detail_url.format(pk=uuid_zero),
1429+
)
1430+
assert response.status_code == http.HTTPStatus.NOT_FOUND
1431+
audit_log.assert_awaited_once()
1432+
event = audit_log.call_args[0][0]
1433+
assert event.event_action == EventAction.APPLET_DELETE
1434+
assert event.event_outcome == EventOutcome.FAILURE
1435+
assert event.user_id == tom.id
1436+
1437+
async def test_encryption_update_audit_event(
1438+
self,
1439+
client: TestClient,
1440+
tom: User,
1441+
applet_one_no_encryption: AppletFull,
1442+
encryption: Encryption,
1443+
mocker: MockerFixture,
1444+
):
1445+
audit_log = mocker.patch("apps.applets.api.applets.log")
1446+
client.login(tom)
1447+
response = await client.post(
1448+
self.applet_set_encryption_url.format(pk=applet_one_no_encryption.id),
1449+
data=encryption,
1450+
)
1451+
assert response.status_code == http.HTTPStatus.OK
1452+
audit_log.assert_awaited_once()
1453+
event = audit_log.call_args[0][0]
1454+
assert event.event_action == EventAction.APPLET_ENCRYPTION_UPDATE
1455+
assert event.event_outcome == EventOutcome.SUCCESS
1456+
assert event.user_id == tom.id
1457+
assert event.curious_applet_id == [applet_one_no_encryption.id]
1458+
1459+
async def test_encryption_update_audit_event_failure(
1460+
self, client: TestClient, tom: User, applet_one: AppletFull, encryption: Encryption, mocker: MockerFixture
1461+
):
1462+
audit_log = mocker.patch("apps.applets.api.applets.log")
1463+
client.login(tom)
1464+
response = await client.post(
1465+
self.applet_set_encryption_url.format(pk=applet_one.id),
1466+
data=encryption,
1467+
)
1468+
assert response.status_code == http.HTTPStatus.FORBIDDEN
1469+
audit_log.assert_awaited_once()
1470+
event = audit_log.call_args[0][0]
1471+
assert event.event_action == EventAction.APPLET_ENCRYPTION_UPDATE
1472+
assert event.event_outcome == EventOutcome.FAILURE
1473+
assert event.user_id == tom.id
1474+
1475+
async def test_report_configuration_audit_event(
1476+
self, client: TestClient, tom: User, applet_one: AppletFull, mocker: MockerFixture
1477+
):
1478+
audit_log = mocker.patch("apps.applets.api.applets.log")
1479+
client.login(tom)
1480+
report_configuration = dict(
1481+
report_server_ip="ipaddress",
1482+
report_public_key="public key",
1483+
report_recipients=["recipient1"],
1484+
report_include_user_id=True,
1485+
report_include_case_id=True,
1486+
report_email_body="body",
1487+
)
1488+
response = await client.post(
1489+
self.applet_report_config_url.format(pk=applet_one.id),
1490+
report_configuration,
1491+
)
1492+
assert response.status_code == http.HTTPStatus.OK
1493+
audit_log.assert_awaited_once()
1494+
event = audit_log.call_args[0][0]
1495+
assert event.event_action == EventAction.APPLET_REPORT_UPDATE
1496+
assert event.event_outcome == EventOutcome.SUCCESS
1497+
assert event.user_id == tom.id
1498+
assert event.curious_applet_id == [applet_one.id]
1499+
1500+
async def test_data_retention_audit_event(
1501+
self, client: TestClient, tom: User, applet_one: AppletFull, mocker: MockerFixture
1502+
):
1503+
audit_log = mocker.patch("apps.applets.api.applets.log")
1504+
client.login(tom)
1505+
data_retention = dict(period=30, retention="days")
1506+
response = await client.post(
1507+
f"{self.applet_list_url}/{applet_one.id}/retentions",
1508+
data_retention,
1509+
)
1510+
assert response.status_code == http.HTTPStatus.OK
1511+
audit_log.assert_awaited_once()
1512+
event = audit_log.call_args[0][0]
1513+
assert event.event_action == EventAction.APPLET_RETENTION_UPDATE
1514+
assert event.event_outcome == EventOutcome.SUCCESS
1515+
assert event.user_id == tom.id
1516+
assert event.curious_applet_id == [applet_one.id]

0 commit comments

Comments
 (0)