Files
training-software/tracking/serializers.py
Paperclip CTO 686acf259a feat(TRA-246): implement audit logging and compliance controls (M5)
Delivers the append-only audit trail system for all compliance-critical
actions per the TRA-246 acceptance criteria.

- tracking/models.py: AuditEvent model with ORM-level immutability guard
  (save raises on update, delete raises on direct call)
- tracking/audit.py: single record() call point; never raises in production
- tracking/admin.py: read-only Django admin for AuditEvent inspection
- tracking/migrations/0001_initial.py: DB schema with composite indexes
- tracking/serializers.py: PII metadata gating (oidc_sub stripped for
  non-admin callers)
- tracking/views.py: read-only AuditEventViewSet (IsPrivileged + 60/min
  throttle)
- tracking/urls.py: registers audit/events/ router
- tracking/management/commands/prune_audit_log.py: retention enforcement
  command with --dry-run and --class filter; writes access.admin_action
  event on real prune runs
- config/settings/base.py: AUDIT_RETENTION_DAYS per event class + audit
  throttle rate
- api/exceptions.py: wires access.permission_denied audit event on every
  PermissionDenied exception (M1 integration point)
- tests/test_audit.py: 26-event taxonomy coverage, immutability, retention,
  API permission, PII gating, and service helper unit tests

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-07 09:11:23 +02:00

49 lines
1.5 KiB
Python

from rest_framework import serializers
from .models import AuditEvent
# Fields that may contain PII and are gated behind the audit:read_pii capability.
_PII_METADATA_KEYS = {"oidc_sub", "oidc_sub_attempt", "email"}
class AuditEventSerializer(serializers.ModelSerializer):
class Meta:
model = AuditEvent
fields = [
"id",
"occurred_at",
"recorded_at",
"event_class",
"event_type",
"actor_id",
"actor_type",
"actor_ip",
"object_type",
"object_id",
"metadata",
"retention_class",
]
read_only_fields = fields
def to_representation(self, instance):
data = super().to_representation(instance)
request = self.context.get("request")
if not _has_pii_capability(request):
data["metadata"] = {
k: v for k, v in data.get("metadata", {}).items()
if k not in _PII_METADATA_KEYS
}
return data
def _has_pii_capability(request) -> bool:
"""True when the requesting user holds the audit:read_pii capability."""
if request is None:
return False
user = getattr(request, "user", None)
if user is None or not user.is_authenticated:
return False
# Capability is held by admins. Extend this check when a formal capability
# registry is introduced in the accounts domain.
from api.permissions import ROLE_ADMIN, _user_roles
return ROLE_ADMIN in _user_roles(user)