diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ebe605d --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.py[cod] +.DS_Store +.pytest_cache/ +.coverage +htmlcov/ diff --git a/management/__init__.py b/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/management/__init__.py @@ -0,0 +1 @@ + diff --git a/management/commands/__init__.py b/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/management/commands/backfill_cmmc_audit.py b/management/commands/backfill_cmmc_audit.py new file mode 100644 index 0000000..310e3dc --- /dev/null +++ b/management/commands/backfill_cmmc_audit.py @@ -0,0 +1,96 @@ +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction +from django.utils import timezone + +from coldfront.plugins.cmmc_audit.backfill import RUN_NAME, run_backfill +from coldfront.plugins.cmmc_audit.models import BackfillRun + + +class Command(BaseCommand): + help = "Reconstruct best-effort historical CMMC audit events from existing records." + + def add_arguments(self, parser): + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument( + "--dry-run", + action="store_true", + help="Report events that would be reconstructed without writing rows.", + ) + mode.add_argument( + "--commit", + action="store_true", + help="Create reconstructed audit rows.", + ) + parser.add_argument( + "--force", + action="store_true", + help="Rerun after a completed backfill. Duplicate source/action checks still apply.", + ) + + def handle(self, *args, **options): + dry_run = options["dry_run"] + commit = options["commit"] + force = options["force"] + + if force and dry_run: + raise CommandError("--force is only valid with --commit") + + existing_run = BackfillRun.objects.filter(name=RUN_NAME).first() + if commit and existing_run and existing_run.completed and not force: + self.stdout.write( + self.style.WARNING( + f"Backfill '{RUN_NAME}' completed at {existing_run.completed}; use --force to rerun." + ) + ) + return + + if dry_run: + report = run_backfill(dry_run=True) + self._print_report(report, dry_run=True) + return + + with transaction.atomic(): + if existing_run is None: + backfill_run = BackfillRun.objects.create(name=RUN_NAME, dry_run=False) + else: + backfill_run = existing_run + backfill_run.completed = None + backfill_run.created_events = 0 + backfill_run.dry_run = False + backfill_run.notes = "Forced rerun." if force else "" + backfill_run.save(update_fields=["completed", "created_events", "dry_run", "notes"]) + + report = run_backfill(dry_run=False) + backfill_run.completed = timezone.now() + backfill_run.created_events = report.created_events + backfill_run.notes = self._summary_notes(report, forced=force) + backfill_run.save(update_fields=["completed", "created_events", "notes"]) + + self._print_report(report, dry_run=False) + + def _summary_notes(self, report, *, forced): + prefix = "Forced rerun. " if forced else "" + return ( + f"{prefix}Created {report.created_events} events; " + f"duplicates skipped {report.duplicates}; ambiguous skipped {report.ambiguous}." + ) + + def _print_report(self, report, *, dry_run): + self.stdout.write("CMMC audit historical reconstruction report") + self.stdout.write(f"Mode: {'dry-run' if dry_run else 'commit'}") + self.stdout.write("Records examined by source:") + for source, count in sorted(report.examined.items()): + self.stdout.write(f" {source}: {count}") + + self.stdout.write(f"Events that would be created: {report.would_create_events}") + self.stdout.write(f"Events created: {report.created_events}") + self.stdout.write(f"Duplicates skipped: {report.duplicates}") + self.stdout.write(f"Ambiguous records skipped: {report.ambiguous}") + + self.stdout.write("Counts by action:") + for action, count in sorted(report.by_action.items(), key=lambda item: str(item[0])): + self.stdout.write(f" {action}: {count}") + + self.stdout.write("Counts by evidence category:") + for category, count in sorted(report.by_evidence.items()): + self.stdout.write(f" {category}: {count}") diff --git a/migrations/0001_initial.py b/migrations/0001_initial.py new file mode 100644 index 0000000..ffe7c68 --- /dev/null +++ b/migrations/0001_initial.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.14 on 2026-05-19 20:29 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='AuditEvent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('action', models.CharField(choices=[('admin_addition', 'Admin addition'), ('admin_change', 'Admin change'), ('admin_deletion', 'Admin deletion')], max_length=64)), + ('target_type', models.CharField(blank=True, max_length=128)), + ('target_id', models.CharField(blank=True, max_length=128)), + ('target_repr', models.CharField(blank=True, max_length=255)), + ('old_values', models.JSONField(blank=True, default=dict)), + ('new_values', models.JSONField(blank=True, default=dict)), + ('message', models.TextField(blank=True)), + ('request_path', models.CharField(blank=True, max_length=255)), + ('ip_address', models.GenericIPAddressField(blank=True, null=True)), + ('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='carc_audit_events', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-timestamp'], + 'indexes': [models.Index(fields=['timestamp'], name='carc_audit__timesta_8969da_idx'), models.Index(fields=['action'], name='carc_audit__action_80997f_idx'), models.Index(fields=['actor'], name='carc_audit__actor_i_6096a0_idx'), models.Index(fields=['target_type', 'target_id'], name='carc_audit__target__4e5508_idx')], + }, + ), + ] diff --git a/migrations/0002_alter_auditevent_action.py b/migrations/0002_alter_auditevent_action.py new file mode 100644 index 0000000..9c86365 --- /dev/null +++ b/migrations/0002_alter_auditevent_action.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.14 on 2026-05-19 20:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cmmc_audit', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='auditevent', + name='action', + field=models.CharField(choices=[('admin_addition', 'Admin addition'), ('admin_change', 'Admin change'), ('admin_deletion', 'Admin deletion'), ('user_pi_upgraded', 'User upgraded to PI'), ('pi_status_revoked', 'PI status revoked'), ('allocation_requested', 'Allocation created/requested'), ('allocation_status_changed', 'Allocation status changed'), ('allocation_renewed', 'Allocation renewed'), ('project_user_added', 'Project user added'), ('project_user_removed', 'Project user removed'), ('project_user_role_changed', 'Project user role changed')], max_length=64), + ), + ] diff --git a/migrations/0003_alter_auditevent_action.py b/migrations/0003_alter_auditevent_action.py new file mode 100644 index 0000000..34d00f6 --- /dev/null +++ b/migrations/0003_alter_auditevent_action.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.14 on 2026-05-19 20:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cmmc_audit', '0002_alter_auditevent_action'), + ] + + operations = [ + migrations.AlterField( + model_name='auditevent', + name='action', + field=models.CharField(choices=[('admin_addition', 'Admin addition'), ('admin_change', 'Admin change'), ('admin_deletion', 'Admin deletion'), ('user_pi_upgraded', 'User upgraded to PI'), ('pi_status_revoked', 'PI status revoked'), ('user_admin_privileges_changed', 'User admin privileges changed'), ('allocation_requested', 'Allocation created/requested'), ('allocation_change_requested', 'Allocation change requested'), ('allocation_status_changed', 'Allocation status changed'), ('renewal_requested', 'Renewal requested'), ('renewal_approved', 'Renewal approved'), ('allocation_renewed', 'Allocation renewed'), ('project_user_added', 'Project user added'), ('project_user_removed', 'Project user removed'), ('project_user_role_changed', 'Project user role changed'), ('resource_created', 'Resource created'), ('resource_changed', 'Resource changed'), ('resource_deleted', 'Resource deleted')], max_length=64), + ), + ] diff --git a/migrations/0004_alter_auditevent_action.py b/migrations/0004_alter_auditevent_action.py new file mode 100644 index 0000000..9777dca --- /dev/null +++ b/migrations/0004_alter_auditevent_action.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.14 on 2026-05-20 02:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cmmc_audit', '0003_alter_auditevent_action'), + ] + + operations = [ + migrations.AlterField( + model_name='auditevent', + name='action', + field=models.CharField(choices=[('admin_addition', 'Admin addition'), ('admin_change', 'Admin change'), ('admin_deletion', 'Admin deletion'), ('user_pi_upgraded', 'User upgraded to PI'), ('pi_status_revoked', 'PI status revoked'), ('user_admin_privileges_changed', 'User admin privileges changed'), ('allocation_requested', 'Allocation created/requested'), ('allocation_change_requested', 'Allocation change requested'), ('allocation_status_changed', 'Allocation status changed'), ('allocation_disabled', 'Allocation disabled'), ('renewal_requested', 'Renewal requested'), ('renewal_approved', 'Renewal approved'), ('allocation_renewed', 'Allocation renewed'), ('project_user_added', 'Project user added'), ('project_user_removed', 'Project user removed'), ('project_user_role_changed', 'Project user role changed'), ('resource_created', 'Resource created'), ('resource_changed', 'Resource changed'), ('resource_deleted', 'Resource deleted')], max_length=64), + ), + ] diff --git a/migrations/0005_alter_auditevent_action.py b/migrations/0005_alter_auditevent_action.py new file mode 100644 index 0000000..d00c0e3 --- /dev/null +++ b/migrations/0005_alter_auditevent_action.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.14 on 2026-05-20 04:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cmmc_audit', '0004_alter_auditevent_action'), + ] + + operations = [ + migrations.AlterField( + model_name='auditevent', + name='action', + field=models.CharField(choices=[('admin_addition', 'Admin addition'), ('admin_change', 'Admin change'), ('admin_deletion', 'Admin deletion'), ('user_pi_upgraded', 'User upgraded to PI'), ('pi_status_revoked', 'PI status revoked'), ('user_admin_privileges_changed', 'User admin privileges changed'), ('allocation_requested', 'Allocation created/requested'), ('allocation_change_requested', 'Allocation change requested'), ('allocation_status_changed', 'Allocation status changed'), ('allocation_disabled', 'Allocation disabled'), ('renewal_requested', 'Renewal requested'), ('renewal_approved', 'Renewal approved'), ('allocation_renewed', 'Allocation renewed'), ('project_user_added', 'Project user added'), ('project_user_removed', 'Project user removed'), ('project_user_role_changed', 'Project user role changed'), ('project_review_forced', 'Project review forced'), ('project_review_submitted', 'Project review submitted'), ('project_review_completed', 'Project review completed'), ('project_review_status_changed', 'Project review status changed'), ('resource_created', 'Resource created'), ('resource_changed', 'Resource changed'), ('resource_deleted', 'Resource deleted')], max_length=64), + ), + ] diff --git a/migrations/0006_add_cmmc_evidence_fields.py b/migrations/0006_add_cmmc_evidence_fields.py new file mode 100644 index 0000000..835b5ce --- /dev/null +++ b/migrations/0006_add_cmmc_evidence_fields.py @@ -0,0 +1,108 @@ +# Generated by Django 5.2.14 on 2026-05-20 22:17 + +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +ACTION_EVIDENCE_MAP = { + "admin_addition": ("AU-Audit-Accountability", "AU", "django_admin"), + "admin_change": ("AU-Audit-Accountability", "AU", "django_admin"), + "admin_deletion": ("AU-Audit-Accountability", "AU", "django_admin"), + "user_pi_upgraded": ("AC-Access-Control", "AC", "runtime"), + "pi_status_revoked": ("AC-Access-Control", "AC", "runtime"), + "user_admin_privileges_changed": ("AC-Access-Control", "AC", "runtime"), + "allocation_requested": ("AC-Access-Control; AU-Audit-Accountability", "AC,AU", "runtime"), + "allocation_change_requested": ("AC-Access-Control; AU-Audit-Accountability", "AC,AU", "runtime"), + "allocation_status_changed": ("AC-Access-Control; AU-Audit-Accountability", "AC,AU", "runtime"), + "allocation_disabled": ("AC-Access-Control; AU-Audit-Accountability", "AC,AU", "runtime"), + "renewal_requested": ("AC-Access-Control; AU-Audit-Accountability", "AC,AU", "runtime"), + "renewal_approved": ("AC-Access-Control; AU-Audit-Accountability", "AC,AU", "runtime"), + "allocation_renewed": ("AC-Access-Control; AU-Audit-Accountability", "AC,AU", "runtime"), + "project_user_added": ("AC-Access-Control", "AC", "runtime"), + "project_user_removed": ("AC-Access-Control", "AC", "runtime"), + "project_user_role_changed": ("AC-Access-Control", "AC", "runtime"), + "project_review_forced": ("AU-Audit-Accountability", "AU", "runtime"), + "project_review_submitted": ("AU-Audit-Accountability", "AU", "runtime"), + "project_review_completed": ("AU-Audit-Accountability", "AU", "runtime"), + "project_review_status_changed": ("AU-Audit-Accountability", "AU", "runtime"), + "resource_created": ("CM-Configuration-Management", "CM", "runtime"), + "resource_changed": ("CM-Configuration-Management", "CM", "runtime"), + "resource_deleted": ("CM-Configuration-Management", "CM", "runtime"), +} + + +def backfill_cmmc_fields(apps, schema_editor): + AuditEvent = apps.get_model("cmmc_audit", "AuditEvent") + for event in AuditEvent.objects.all().iterator(): + evidence_category, control_family, source = ACTION_EVIDENCE_MAP.get(event.action, ("", "", "runtime")) + event.event_time = event.timestamp + event.evidence_category = evidence_category + event.control_family = control_family + event.source = source + event.save( + update_fields=[ + "event_time", + "evidence_category", + "control_family", + "source", + ] + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('cmmc_audit', '0005_alter_auditevent_action'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveIndex( + model_name='auditevent', + name='carc_audit__timesta_8969da_idx', + ), + migrations.AddField( + model_name='auditevent', + name='control_family', + field=models.CharField(blank=True, max_length=16), + ), + migrations.AddField( + model_name='auditevent', + name='event_time', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + migrations.AddField( + model_name='auditevent', + name='evidence_category', + field=models.CharField(blank=True, choices=[('AC-Access-Control', 'AC - Access Control'), ('AU-Audit-Accountability', 'AU - Audit and Accountability'), ('AC-Access-Control; AU-Audit-Accountability', 'AC/AU - Access and Audit'), ('CM-Configuration-Management', 'CM - Configuration Management'), ('IA-Identification-Authentication', 'IA - Identification and Authentication')], max_length=128), + ), + migrations.AddField( + model_name='auditevent', + name='is_reconstructed', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='auditevent', + name='source', + field=models.CharField(choices=[('runtime', 'Runtime'), ('django_admin', 'Django admin'), ('coldfront_signal', 'ColdFront signal'), ('coldfront_workflow', 'ColdFront workflow'), ('reconstructed_django_admin_log', 'Reconstructed Django admin log')], default='runtime', max_length=64), + ), + migrations.AddField( + model_name='auditevent', + name='source_id', + field=models.CharField(blank=True, max_length=128), + ), + migrations.RunPython(backfill_cmmc_fields, migrations.RunPython.noop), + migrations.AddIndex( + model_name='auditevent', + index=models.Index(fields=['event_time'], name='carc_audit__event_t_752462_idx'), + ), + migrations.AddIndex( + model_name='auditevent', + index=models.Index(fields=['evidence_category'], name='carc_audit__evidenc_404a75_idx'), + ), + migrations.AddIndex( + model_name='auditevent', + index=models.Index(fields=['source'], name='carc_audit__source_21deb0_idx'), + ), + ] diff --git a/migrations/0007_backfillrun_alter_auditevent_source.py b/migrations/0007_backfillrun_alter_auditevent_source.py new file mode 100644 index 0000000..96c4252 --- /dev/null +++ b/migrations/0007_backfillrun_alter_auditevent_source.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.14 on 2026-05-20 23:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cmmc_audit', '0006_add_cmmc_evidence_fields'), + ] + + operations = [ + migrations.CreateModel( + name='BackfillRun', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=128, unique=True)), + ('started', models.DateTimeField(auto_now_add=True)), + ('completed', models.DateTimeField(blank=True, null=True)), + ('created_events', models.PositiveIntegerField(default=0)), + ('dry_run', models.BooleanField(default=False)), + ('notes', models.TextField(blank=True)), + ], + options={ + 'ordering': ['-started'], + }, + ), + migrations.AlterField( + model_name='auditevent', + name='source', + field=models.CharField(choices=[('runtime', 'Runtime'), ('django_admin', 'Django admin'), ('coldfront_signal', 'ColdFront signal'), ('coldfront_workflow', 'ColdFront workflow'), ('reconstructed_django_admin_log', 'Reconstructed Django admin log'), ('django_admin_log', 'Django admin log'), ('coldfront_history', 'ColdFront history')], default='runtime', max_length=64), + ), + ] diff --git a/migrations/0008_alter_auditevent_action.py b/migrations/0008_alter_auditevent_action.py new file mode 100644 index 0000000..566554b --- /dev/null +++ b/migrations/0008_alter_auditevent_action.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.14 on 2026-05-21 02:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cmmc_audit', '0007_backfillrun_alter_auditevent_source'), + ] + + operations = [ + migrations.AlterField( + model_name='auditevent', + name='action', + field=models.CharField(choices=[('admin_addition', 'Admin addition'), ('admin_change', 'Admin change'), ('admin_deletion', 'Admin deletion'), ('user_pi_upgraded', 'User upgraded to PI'), ('pi_status_revoked', 'PI status revoked'), ('user_admin_privileges_changed', 'User admin privileges changed'), ('allocation_requested', 'Allocation created/requested'), ('allocation_change_requested', 'Allocation change requested'), ('allocation_status_changed', 'Allocation status changed'), ('allocation_disabled', 'Allocation disabled'), ('renewal_requested', 'Renewal requested'), ('renewal_approved', 'Renewal approved'), ('allocation_renewed', 'Allocation renewed'), ('project_user_added', 'Project user added'), ('project_user_removed', 'Project user removed'), ('project_user_role_changed', 'Project user role changed'), ('project_created', 'Project created'), ('project_archived', 'Project archived'), ('project_deleted', 'Project deleted'), ('project_status_changed', 'Project status changed'), ('project_pi_changed', 'Project PI changed'), ('project_review_forced', 'Project review forced'), ('project_review_submitted', 'Project review submitted'), ('project_review_completed', 'Project review completed'), ('project_review_status_changed', 'Project review status changed'), ('resource_created', 'Resource created'), ('resource_changed', 'Resource changed'), ('resource_deleted', 'Resource deleted')], max_length=64), + ), + ] diff --git a/migrations/__init__.py b/migrations/__init__.py new file mode 100644 index 0000000..e69de29