Files
training-software/tracking/migrations/0001_initial.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

205 lines
7.9 KiB
Python

import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("courses", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
# ── AuditEvent ────────────────────────────────────────────────────────
migrations.CreateModel(
name="AuditEvent",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("occurred_at", models.DateTimeField(db_index=True)),
("recorded_at", models.DateTimeField(auto_now_add=True)),
(
"event_class",
models.CharField(
choices=[
("auth", "Authentication"),
("progress", "Progression"),
("assessment", "Assessment"),
("trainer", "Trainer Decision"),
("certificate", "Certificate"),
("access", "Access Control"),
],
db_index=True,
max_length=32,
),
),
("event_type", models.CharField(db_index=True, max_length=64)),
("actor_id", models.UUIDField(blank=True, db_index=True, null=True)),
("actor_type", models.CharField(default="user", max_length=32)),
("actor_ip", models.GenericIPAddressField(blank=True, null=True)),
("object_type", models.CharField(blank=True, max_length=64, null=True)),
(
"object_id",
models.CharField(blank=True, db_index=True, max_length=128, null=True),
),
("metadata", models.JSONField(default=dict)),
("retention_class", models.CharField(db_index=True, max_length=32)),
],
options={
"db_table": "tracking_audit_event",
},
),
migrations.AddIndex(
model_name="auditevent",
index=models.Index(
fields=["event_class", "occurred_at"],
name="tracking_au_event_c_idx",
),
),
migrations.AddIndex(
model_name="auditevent",
index=models.Index(
fields=["actor_id", "occurred_at"],
name="tracking_au_actor_i_idx",
),
),
migrations.AddIndex(
model_name="auditevent",
index=models.Index(
fields=["object_type", "object_id"],
name="tracking_au_object__idx",
),
),
# ── Enrollment ───────────────────────────────────────────────────────
migrations.CreateModel(
name="Enrollment",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="enrollments",
to=settings.AUTH_USER_MODEL,
),
),
(
"course",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="enrollments",
to="courses.course",
),
),
("org_id", models.UUIDField(db_index=True)),
("enrolled_at", models.DateTimeField(auto_now_add=True)),
("completed_at", models.DateTimeField(blank=True, null=True)),
],
options={
"ordering": ["-enrolled_at"],
},
),
migrations.AddConstraint(
model_name="enrollment",
constraint=models.UniqueConstraint(
fields=("user", "course"), name="unique_user_course_enrollment"
),
),
# ── PageProgress ─────────────────────────────────────────────────────
migrations.CreateModel(
name="PageProgress",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"enrollment",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="page_progresses",
to="tracking.enrollment",
),
),
(
"page",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="progresses",
to="courses.page",
),
),
("accumulated_seconds", models.PositiveIntegerField(default=0)),
("is_complete", models.BooleanField(default=False)),
("last_seen_at", models.DateTimeField(blank=True, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"ordering": ["-updated_at"],
},
),
migrations.AddConstraint(
model_name="pageprogress",
constraint=models.UniqueConstraint(
fields=("enrollment", "page"), name="unique_enrollment_page_progress"
),
),
# ── DwellEvent ────────────────────────────────────────────────────────
migrations.CreateModel(
name="DwellEvent",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"page_progress",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="dwell_events",
to="tracking.pageprogress",
),
),
("started_at", models.DateTimeField()),
("ended_at", models.DateTimeField(blank=True, null=True)),
("raw_seconds", models.PositiveIntegerField(default=0)),
("is_valid", models.BooleanField(default=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
],
options={
"ordering": ["started_at"],
},
),
]