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>
205 lines
7.9 KiB
Python
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"],
|
|
},
|
|
),
|
|
]
|