Skip to content

Audit Logging

CampusCore's audit trail answers "who did what, when, from where" for every security-relevant action, from the client-facing UI down to the tenant's AWS account. AuditLog (apps/main_app/models/audit.py) is the single system of record; everything else - Slack notifications, the settings tab, the S3 archive - derives from it.

The event model

Every event carries an actor (user FK + actor_type: user / admin / system / agent), an action verb, a category, a resource (resource_type + resource_id), an outcome (success / denied / error), structured details, and request context (request_id, IP, user agent) captured from the correlation middleware. For agent events, user is the human the agent acted for and actor_type="agent" marks the agency - there is no separate on-behalf-of column. Config mutations record what changed as details.changed = {field: {before, after}} via apps/main_app/services/audit_diff.py, which masks secret-named fields; write-only secrets are reported only as secrets_touched.

Two delivery tiers

apps/main_app/services/audit_service.py exposes both:

  • log_critical(...) writes synchronously in the caller's transaction and raises on failure - a critical action must not outlive its audit record. Callers wrap mutation and audit in one transaction.atomic so they commit or fail together. Used for auth-adjacent, config, credential, deletion, and role events.
  • log(...) is the async tier: enqueued on commit, written by a daemon thread, never blocks or fails the caller. Used for high-volume events (document views, agent turns, staff reads, credential use). The daemon recycles its connection and retries each entry once, so a database failover costs no audit records (run_with_db_recovery in campus_core/db.py owns the contract). The retry can duplicate but never lose: when the connection dies between the server's commit and the client's acknowledgement, the same event appears as two rows. A sustained outage still drops entries, each with an ERROR log carrying the entry.

Both tiers touch the ORM, so every call sits in sync code - inside the sync service an async endpoint delegates to, or wrapped in sync_to_async. Under pytest, async-tier writes are dropped unless AUDIT_LOG_IN_TESTS=1, which makes log() write synchronously in the test transaction so contract tests can assert rows deterministically.

Event taxonomy

Actions are <resource>_<verb>. The families and their writer locations:

Family Actions Tier Written from
Authentication login, logout, login_failed async signals/audit_signals.py
Roles role_sync, role_assigned_manual, role_revoked_manual async/critical apps/auth/signals.py, workspace_users_api.py
User lifecycle user_created, user_deactivated, user_reactivated async apps/auth/signals.py (post_save / pre_save on User)
Documents document_viewed, document_downloaded (incl. outcome=denied), document_uploaded, document_deleted async reads, critical writes documents_api.py, upload_service.py, knowledge_folder_service.py
Folders folder_created/updated/deleted, workspace_folder_created/rescoped/deleted critical knowledge_folder_service.py
Settings sso_provider_*, branding_updated, faq_*, account_updated, setup_saved, feature_state_updated, domain_* critical the workspace *_api.py modules
Connectors connector_connection_created/deleted/tested, connector_<action>, connector_credentials_accessed, connector_config_copied, connector_config_link_broken, connector_config_propagated critical lifecycle, async use connectors_api.py, connectors/views.py, connectors/services/audit.py, connectors/base.py, workspace_connectors_api.py
Scraping scrape_triggered/cancelled, scrape_run_deleted, scraper_config_saved/deleted, scraper_config_public_visibility critical admin_sm_trigger_apis.py, admin_sm_runs_apis.py, admin_sm_configs_apis.py (via admin_scraping_manager_apis.audit_scrape)
AI agent_turn (tools used + cited source file ids) async chat/turn_reporting.py at turn finalize
Vendor access staff_viewed_*, staff_downloaded_document async audit_decorators.audit_staff_read on the /cc_admin/ viewers
Django admin <model>_admin_created/updated/deleted, appconfig_admin_activated critical services/audited_admin.AuditedModelAdminMixin
Audit itself audit_log_viewed, audit_log_exported async view, critical export audit_log_api.py
Analytics analytics_viewed, analytics_exported async view, critical export analytics_api.py
Permissions permission_denied async campus_core/decorators.py
Operations admin_operation_run async services/admin_operations/runner.py

Denials are first-class: a document fetch that access control filtered writes outcome="denied", while a fetch of a nonexistent id writes nothing.

Slack is not an audit surface. The workflow channel carries only events a CampusCore operator must act on (scrape/pipeline lifecycle, index maintenance, domain provisioning, deploys); institution-admin activity such as folder visibility changes lives solely in AuditLog and the Audit Log page.

The client-facing surface

Settings > Audit Log (web/src/components/settings/AuditLogSettings.tsx) is offered to workspace admins only and deliberately carries no feature flag - audit visibility must not be toggleable off. It reads /api/workspace/audit-events (apps/main_app/apis/audit_log_api.py): keyset-cursor pagination on (timestamp, id), filters (free text, actor, category, action, outcome, date range), a detail drawer with the full details JSON, and a CSV export of the filtered view (/export, capped at 100k rows). Reading the log writes audit_log_viewed on first-page loads; exporting writes audit_log_exported.

category and outcome are the closed sets AuditCategory and AuditOutcome: a request naming a value outside either one is answered with a 422, so the console can only ever ask for a filter that exists. action is deliberately free text - the action vocabulary lives in the writers rather than in a registry, and the filter offers whatever available_actions reports is present in the rows.

Archive and retention

export_audit_logs (worker, 01:30 UTC daily) writes each complete UTC day as JSONL to s3://$AUDIT_ARCHIVE_BUCKET/audit-logs/YYYY/MM/DD.jsonl. The bucket has Object Lock in COMPLIANCE mode, so a written day is immutable - this is the tamper-evidence layer and the SIEM integration point (the client's tooling reads the bucket in their own account). The S3 key is the ledger: existing days are skipped, so re-runs are idempotent and a missed run heals on the next.

prune_audit_logs (02:00 UTC daily) keeps AUDIT_LOG_RETENTION_DAYS (default 400) hot in Postgres and refuses to delete any day whose archive object is missing. With no archive bucket configured (local dev), export is disabled and prune runs unguarded.

The AWS layer (per tenant)

See infrastructure/README.md "Audit Logging Stack": CloudTrail (multi-region, log-file validation, S3 data events), ALB access logs, S3 server access logs, RDS pgaudit + postgresql log export (365d), WAF logging, VPC flow logs, and the two archive buckets. Deploys are recorded as annotated deploy-<client>-<run> git tags plus a Slack message naming the actor and SHA.

Adding a new audited action

Pick the tier (would a lost record be acceptable? if not, log_critical inside the mutation's transaction), name the action <resource>_<verb>, reuse an existing category, and put the write in the service layer so every caller inherits it. The category must be a member of AuditCategory: every column with a closed set carries a check constraint, so a typo fails the write rather than landing a row the console cannot parse. Add a contract test asserting the row (use AUDIT_LOG_IN_TESTS=1 for async-tier events). The settings tab picks new actions up automatically via available_actions.