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 / skipped / pending / interrupted), structured details, and request context (request_id, IP, user agent). The request id, the IP and the user agent come from the context RequestCorrelationMiddleware binds; the row's user is the one its writer names, while RequestUserMiddleware binds the signed-in user's id to the same context after authentication so the log lines beside the row carry it. The IP is the address the deployment's own proxies vouch for, the X-Forwarded-For entry just inside the trusted suffix TRUSTED_PROXY_HOPS declares, parsed as an address, so a forged or malformed header can neither choose the value nor drop the row. A writer never names the address itself: log and log_critical read it from the context, or from the request handed to log_critical or log_from_request. AuditEntry.ip_address is ClientAddress, which parses the value once, so an entry the inet column would refuse cannot be built. Rows written inside a streamed chat turn - the assistant-turn row, and the artifact and connector rows the agent's tools write - carry all of them, because the middleware leaves the context bound for a streaming response and its body reads it. The writers take the actor and the outcome as their enums, AuditActorType and AuditOutcome, so a name outside either set is a type error rather than a row the console cannot parse. The category is not a writer argument at all: it is a property of the action (AuditAction.category), and the row's auditlog_category_matches_action constraint refuses a stored pair that disagrees. For agent events, user is the human the agent acted for and AuditActorType.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: an AuditEntry is enqueued on commit and written by a daemon thread, so the call 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>, and AuditAction in apps/main_app/models/audit.py is the exhaustive vocabulary - the table below names the families with representative members, not every name. audit_service takes that type, so a name outside it fails ty at the writer, and the column's check constraint refuses one that reached the database anyway.

Family Actions Tier Written from
Authentication login, logout, login_failed async signals/audit_signals.py
Roles role_sync, role_sync_removed_last_admin, role_assigned_manual, role_revoked_manual, role_renamed, role_mapping_created/updated/deleted async/critical apps/auth/signals.py, apps/auth/services/role_sync_service.py, workspace_users_api.py, workspace_roles_api.py
User lifecycle user_created, user_deactivated, user_reactivated async apps/auth/signals.py (post_save / pre_save on User) - these rows carry no actor: a signal has no request, so the person is named as the subject in resource_id and details.subject_username, which the console's text search reads
User admin edits user_admin_created, user_admin_updated, user_admin_deleted, user_admin_password_changed critical apps/auth/admin.UserAdmin - the operator is on user, the subject in resource_id, and no password material ever reaches details
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_created/updated (the settings update carries details.role_slugs and details.user_ids, ids only), domain_* critical the workspace *_api.py modules
Connectors connector_connection_created/deleted/tested, connector_credentials_accessed, connector_action_policy_updated (details.role_slugs and details.user_ids, ids only), connector_audience_updated (the replacement details.role_slugs and details.user_ids, slugs and ids only), connector_config_copied/link_broken/propagated, connector_config_sharing_updated, connector_config_created/updated/deleted (one row per config row a Settings edit writes, carrying details.connector_slug, details.config_key and details.is_secret, and details.changed never carries the value: config_value reads *** on both sides when it moved), connector_action_requested (plugin method in details.action, workflow status in details.status) 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, one row per turn that reached the agent however it ended (details.status, steps, tools used, skills used, cited source file ids, artifact ids; an errored turn adds details.error_kind and the user-safe details.error) async chat/turn_reporting.py, from the turn's finally
Artifacts artifact_created, artifact_updated (an assistant revision, or a promoted draft with details.source = person), artifact_restored, artifact_renamed, artifact_archived, artifact_exported (with details.format and the destination), artifact_downloaded (a stored export file served, with details.format and details.destination) - the kind and version number, never the content critical services/artifacts/service.py, services/artifacts/exports/service.py, artifacts_api.py for the download
Skills skill_created, skill_imported, skill_exported, skill_published, skill_draft_discarded, skill_enabled, skill_disabled, skill_updated (with the before-and-after of each changed field), skill_reset_to_default, skill_version_restored, skill_shipped_update_dismissed, skill_archived, skill_unarchived (the Settings page and the operator admin); skill_shipped_update_applied, skill_retired, skill_released_to_institution, skill_readopted_from_institution (the boot sync, actor system, and take-the-update on the page) critical services/skills/lifecycle.py, admin/skills.py, services/skills/registry_sync.py
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 (the triple per audited admin and per inline model on one, pinned in both directions by test_audit_action_registry), appconfig_admin_activated, user_admin_password_changed (its own admin action, not a save) critical services/audited_admin.AuditedModelAdminMixin, apps/auth/admin.UserAdmin for the password view
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
Extraction review extraction_signal_acknowledged/reopened/tagged_for_eval/reprocess_requested critical services/extraction_review.py
Operations admin_operation_run async services/admin_operations/runner.py

An inline row saved beside its parent is recorded under the inline model's own triple, not the parent's, and the admin registered for that model answers for it - which is how an inline config row inherits ConnectorConfigAdmin's masking and identity keys. A delete that cascades to child rows records one row per child first, because the cascade itself writes nothing; ConnectorAdmin.audit_delete is the worked example.

A Django-admin row's details.changed names each changed field with its before and after value. A many-to-many field carries member names on both sides, sorted, so a groups or roles edit reads as what was added and removed, and a side with no members is an empty list. Sides longer than 100 members are capped with a "+N more" entry, after the comparison that decided the field changed and with the members the other side does not carry listed first, so a capped pair still names what moved.

Denials are first-class: a document fetch that access control filtered writes outcome="denied", while a fetch of a nonexistent id writes nothing. Every connector refusal writes outcome="denied" too, so the console's outcome filter finds them all in one place: the institution's policy refusing the agent's live call, the same policy refusing at approval time or at either cloud-import door, the connector's audience refusing a person it no longer names, a connector turned off before a held proposal was approved, an admin_approve action refused from chat, the user rejecting the confirmation card, and a card that lapses unanswered. details.source names the gate, and its vocabulary is the ConnectorActionSource Literal in apps/connectors/services/audit.py: policy, permission_tier, user_confirmation, expiry, connector_disabled, audience, agent, cloud_import and artifact_export - the last three on rows for an action that ran, was skipped or is held for confirmation rather than refused, naming the surface that proposed it: the agent, a cloud import the person started from a folder page, or an artifact being sent to a connector destination. A refusal also carries the surface it was asked from in details.surface, because the gate that refused and the door it was asked at are two different questions. Every writer passes the value through log_action(..., source=...), which requires it, so a new door cannot invent a spelling the console does not know or leave the key out. A lapsed card carries details.status = "expired".

skipped and pending are the two outcomes for an action that did not run. A cloud-import file skipped for an unsupported extension is skipped, and the moment a write is held for the user's confirmation is recorded as pending. The rows are append-only, so a pending row stays pending: deciding the action writes a second row with its own outcome, and filtering on pending lists every proposal ever made rather than the ones still awaiting an answer. Only connector_action_requested can carry either, which the auditlog_pending_or_skipped_is_a_connector_action constraint enforces.

Every chat turn that reached the agent writes one agent_turn row, and its outcome says how the turn ended. The turn's done event decides alone: a CompletedDoneEvent is success, whether its status is completed or max_iterations, and an ErroredDoneEvent is error. A completed done event is recorded once its frame is on the wire, so a turn whose answer reached the person stays success even when saving it afterwards failed, and a Stop landing on that frame leaves the turn interrupted. An errored done event is recorded as soon as it is known, so a Stop landing on the error frames still leaves an error row. When the chat service catches a failure before the agent sent its own done event, such as the turn timeout, it sends an ErroredDoneEvent of its own, so that turn is error too. A turn with no done event is interrupted, which is a Stop or a client disconnect. An errored row names its error_kind from the errored done event - rate_limit, timeout or unexpected, classified by describe_error in services/error_handling/sanitize.py - beside that kind's sentence from user_safe_message, which is the sentence the person saw, and never the technical detail, which goes to the log. Only agent_turn can carry interrupted, which the auditlog_interrupted_is_an_agent_turn constraint enforces.

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, and a workspace losing its last administrator to an SSO login); 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.

Every AuditAction member carries a human label, in sentence case with the resource first ("SSO provider created", "Assistant turn"), and every AuditCategory member has one too. The page shows each action as its label with the slug beneath it in small mono, the action and category filters list labels sorted by label while filtering by the slug, and the drawer is titled with the label and shows the slug beside the timestamp. Each row carries action_label and category_label beside the slugs, and the free-text search matches either the slug or a word of the label. The CSV export keeps the slug, because scripts and SIEM imports match on the stored name.

action, category and outcome are the closed sets AuditAction, AuditCategory and AuditOutcome: a request naming a value outside any of them is answered with a 422, so the console can only ever ask for a filter that exists. The response is one of two page shapes, told apart by kind: the opening page carries total and the filter vocabularies, a page fetched through a cursor carries only its rows and the next cursor. available_actions reports which members are present in the rows, which is what the filter dropdown offers. The free-text search also matches details.action, so typing a connector plugin's method name still finds the rows that ran it.

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

Add the member to AuditAction first, named <resource>_<verb>; ty then guides every writer, because a string literal is rejected at each audit_service entry point. Then pick the tier (would a lost record be acceptable? if not, log_critical inside the mutation's transaction) and put the write in the service layer so every caller inherits it. The category is a property of the action, not an argument: name the member's family in _ACTIONS_BY_CATEGORY (apps/main_app/models/audit.py), which is what AuditAction.category reads, and the database refuses any row whose action and category disagree. Renaming a member needs a data migration: rows are immutable, and the constraint refuses the old name once it leaves the enum. Regenerate the API contract with scripts/sync_openapi_types.sh: AuditAction is a named component of openapi.json and a union in the generated TypeScript, and --check fails CI on the drift. 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.

An audited ModelAdmin can answer two hooks of AuditedModelAdminMixin. audit_secret_fields(obj, form) names the fields whose values never enter a row, which covers one a row marks secret by a flag rather than by its name. audit_details(obj) names the identity keys merged into every row, for a row whose primary key means nothing to a reader. An admin answers both for its own model, and any other model - which is what an inline row is - is handed to that model's own admin.