Skip to content

Audit Logging

FreeSDN writes an immutable audit trail for every administrative action, authentication event, and resource mutation. Each entry carries full actor context (who, from where, using what credential), the resource that changed, a before/after diff, and an HMAC that chains the row to every row that came before it. This page explains what is recorded, how the chain works, how to query records, and what the limits of the system are.


Every audit entry maps to a row in audit.audit_logs. The table below lists the full database schema for that table. Only a subset of these fields is returned by the API - see the API response fields section below.

FieldTypeDescription
idUUIDUnique row identifier
timestampISO 8601 UTCWhen the action occurred
actionstringVerb (see action vocabulary below)
resource_typestringCategory of the affected object
resource_idUUID / stringIdentifier of the affected object
resource_namestringHuman-readable name at the time of the action
actor_idUUIDUser or system that performed the action
actor_typestringuser, system, or api_key
actor_namestringDisplay name of the actor
actor_emailstringEmail of the actor (stored in DB; not returned by the API)
organization_idUUIDOrganization that owns the record (DB only; scoping enforced server-side)
site_idUUIDSite context, if applicable (DB only)
ip_addressstringClient IP (up to 45 chars - supports IPv6)
user_agentstringUser-Agent header value (DB only)
session_idstringSession that was active when the action ran (DB only)
request_idstringX-Request-ID correlation token (DB only)
request_methodstringHTTP verb (GET, POST, etc.) (DB only)
request_pathstringAPI path that was called (DB only)
statusstringsuccess, failure, or error
response_codeintegerHTTP status code returned (DB only)
response_time_msfloatRequest duration in milliseconds (DB only)
error_messagestringError detail when status != success (DB only)
changesJSONBPer-field diff: {field: {old, new}}
previous_stateJSONBFull object state before the action (DB only)
new_stateJSONBFull object state after the action (DB only)
tagsstring[]Free-form categorization tags (DB only)
extra_metadataJSONBAdditional context passed by the action handler (DB only)

GET /api/v1/audit/logs (and the /logs/resource/… and /logs/user/… variants) serialise each entry through AuditLogResponse, which exposes only the following 12 fields:

FieldTypeDescription
idUUIDUnique row identifier
timestampISO 8601 UTCWhen the action occurred
actionstringVerb (see action vocabulary below)
resource_typestringCategory of the affected object
resource_idUUID / stringIdentifier of the affected object
resource_namestringHuman-readable name at the time of the action
actor_idUUIDUser or system that performed the action
actor_namestringDisplay name of the actor
actor_typestringuser, system, or api_key
statusstringsuccess, failure, or error
ip_addressstringClient IP (up to 45 chars - supports IPv6)
changesJSONBPer-field diff: {field: {old, new}}

All other database columns (including session_id, user_agent, request_id, request_method, request_path, response_code, response_time_ms, error_message, previous_state, new_state, tags, extra_metadata, prev_hash, row_hmac, organization_id, site_id, and actor_email) are stored in the database but are not currently included in the API response.

When a mutation succeeds, the changes field contains a map from field name to {old, new} values. For example, a role change on a user produces:

{
"changes": {
"role": { "old": "viewer", "new": "operator" }
}
}

previous_state and new_state capture the full serialized object when the caller explicitly includes them; changes is the lightweight per-field view.

The action field is one of the following string constants:

CategoryActions
CRUDcreate, read, update, delete
Authenticationlogin, logout, login_failed, password_change, password_reset
MFAmfa_enable, mfa_disable, mfa_verify, mfa_failed
Administrativeenable, disable, approve, reject, assign, unassign, invite
Device operationsadopt, provision, reboot, upgrade, locate
Backup / Restorebackup, restore
Dataexport, import, sync
Pluginsinstall, uninstall

resource_type is one of: user, role, permission, organization, site, device, controller, network, client, alert, alert_rule, automation, integration, webhook, api_key, session, config, firmware, backup, settings, camera, nvr, recording, camera_event, camera_view, camera_group, plugin.

Use GET /api/v1/audit/actions and GET /api/v1/audit/resource-types to retrieve the live enumerations from a running instance.


Security events are stored separately in audit.security_events and exposed at /api/v1/audit/security (alias: /api/v1/audit/security-events). They record authentication and authorization outcomes with a numerical risk score.

FieldDescription
event_typeOne of the security event type constants below
user_id / user_emailThe user the event concerns
ip_addressSource IP
successWhether the operation succeeded
risk_score0-100 integer; events ≥ 70 trigger a real-time security.alert event bus publish
detailsFree-form JSONB context

Security event types: login_success, login_failed, logout, password_change, password_reset, password_reset_request, mfa_enabled, mfa_disabled, mfa_failed, api_key_created, api_key_revoked, api_key_used, account_locked, account_unlocked, suspicious_activity, permission_escalation, unauthorized_access, session_created, session_revoked, brute_force_attempt.

Risk score severity buckets (used by the UI and the severity filter parameter):

Labelrisk_score range
critical80-100
high50-79
medium20-49
low0-19

Each new audit row is assigned a row_hmac before it is persisted. The HMAC covers two inputs:

  1. The prev_hash of the most-recently-written audit row (or the empty string for the first row).
  2. A canonical JSON serialization of all non-chain fields in the new row (sorted keys, compact separators, deterministic across runtimes).

The algorithm is HMAC-SHA256. The key bytes are always computed as SHA-256("freesdn.audit.v1::" + key_material) where key_material is AUDIT_HMAC_KEY if set, otherwise SECRET_KEY. The raw value of AUDIT_HMAC_KEY is never used directly as the HMAC key. New rows carry both prev_hash and row_hmac under normal conditions. If HMAC computation fails (for example, due to a missing key attribute or a transient DB error), row_hmac is set to NULL and the write still proceeds - the validator will report those rows as unchained, not broken. Rows written before the chain migration are similarly NULL in both columns; the validator makes no distinction between the two cases.

To prevent two concurrent writers from computing the same prev_hash and branching the chain, the insert acquires a SELECT ... FOR UPDATE lock on the latest existing row before writing.

GET /api/v1/audit/validate

This endpoint walks the chain forward in (timestamp, id) ascending order and recomputes the expected HMAC for each row. It is restricted to super_admin.

Query parameters:

ParameterTypeDefaultDescription
start_idUUID-Begin the walk at this row (inclusive)
end_idUUID-End the walk at this row (inclusive)
limitinteger10000Maximum rows to examine (1-100000)

Response:

{
"valid": true,
"checked": 4821,
"unchained": 3,
"broken_at": null,
"broken_reason": null
}
FieldMeaning
validtrue only if no broken links were found among chained rows
checkedTotal rows examined
unchainedRows with NULL row_hmac (pre-migration rows); these do not count as broken
broken_atUUID of the first row where the chain breaks; null when valid
broken_reasonHuman-readable explanation - one of "prev_hash mismatch: expected '...' got '...'" (includes the actual hash values) or "row_hmac mismatch (row body modified)"

Run this endpoint on a schedule - weekly for most deployments, daily if you need tighter evidence for compliance workflows - and alert on any response where valid is false.

Example: validate the last 1000 rows only

Terminal window
curl -s -H "Authorization: Bearer $TOKEN" \
"https://your-instance/api/v1/audit/validate?limit=1000"

Example: paginate through a large table

  1. Run the initial call with the default limit.
  2. Note broken_at or, if valid, record the last row ID from a preceding log query.
  3. Pass that ID as start_id in the next call.

MethodPathPurposeMin role
GET/api/v1/audit/logsPaginated log list with filtersorg_admin
GET/api/v1/audit/logs/resource/{resource_type}/{resource_id}Logs for a specific resourceorg_admin
GET/api/v1/audit/logs/user/{user_id}Logs for a specific userorg_admin (or self)
GET/api/v1/audit/securitySecurity eventsorg_admin
GET/api/v1/audit/security-eventsSecurity events (alias)org_admin
GET/api/v1/audit/summary/activityActivity breakdown by action / resource / statusorg_admin
GET/api/v1/audit/summary/securitySecurity event countersorg_admin
POST/api/v1/audit/exportBulk export as JSON or CSV (capped at 10,000 rows)org_admin
GET/api/v1/audit/validateWalk and verify the HMAC chainsuper_admin
GET/api/v1/audit/actionsEnumeration of valid action valuesauthenticated
GET/api/v1/audit/resource-typesEnumeration of resource typesauthenticated
GET/api/v1/audit/security-event-typesEnumeration of security event typesauthenticated

All read endpoints are scoped to the caller’s organization. An org_admin cannot read another org’s logs by supplying a foreign UUID - the query is filtered server-side by organization_id. A super_admin sees platform-wide records.

Users can read their own log history via GET /api/v1/audit/logs/user/{user_id} without needing org_admin. Cross-org user UUID probes return 404 (not 403) to avoid leaking UUID existence.

GET /api/v1/audit/logs accepts the following query parameters:

ParameterTypeDescription
start_date / end_dateISO 8601 datetimeTime window
actionstringSingle action (legacy, back-compat)
actionsrepeated stringMultiple actions in one request (?actions=login&actions=logout)
resource_typestringSingle resource type (legacy)
resource_typesrepeated stringMultiple resource types
resource_idUUIDSpecific resource
actor_idUUIDSpecific actor
site_idUUIDSite filter
statusstringsuccess, failure, or error
searchstringCase-insensitive substring across action, resource_type, resource_name, actor_name, actor_email, ip_address (max 128 chars)
page / per_pageintegerPagination; per_page 1-200, default 50

Example: fetch all failed logins in the last 24 hours

Terminal window
START=$(date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%SZ)
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
curl -s -H "Authorization: Bearer $TOKEN" \
"https://your-instance/api/v1/audit/logs?actions=login_failed&start_date=${START}&end_date=${NOW}&per_page=200"

Example: fetch all changes to a specific device

Terminal window
DEVICE_UUID="..."
curl -s -H "Authorization: Bearer $TOKEN" \
"https://your-instance/api/v1/audit/logs/resource/device/${DEVICE_UUID}"

POST /api/v1/audit/export streams a JSON or CSV file. Exports are capped at 10,000 rows. When the matching row count exceeds this limit:

  • The response includes X-Result-Truncated: true, X-Result-Total, and X-Result-Limit headers.
  • For JSON exports, the payload is wrapped in an envelope with truncated, total, limit, returned, and items keys so programmatic consumers can detect partial results without parsing headers.
  • CSV exports retain their raw shape (no injected envelope rows) and rely on the headers only.
Terminal window
curl -s -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"start_date":"2026-01-01T00:00:00Z","end_date":"2026-06-01T00:00:00Z","format":"csv"}' \
"https://your-instance/api/v1/audit/export" \
-o audit.csv

The default retention period is 90 days. The Celery scheduled task cleanup_audit_data (registered as security.cleanup_audit_data) deletes rows from both audit.audit_logs and audit.security_events where timestamp < now() - retention_days.

There is no per-organization retention override in the current release. If your compliance requirements mandate longer retention, configure a PostgreSQL dump or replicate rows to external storage before the cleanup task runs.


Audit entries share the caller’s database session, so on success they commit together with the action that produced them. The audit write runs inside its own nested savepoint (SAVEPOINT), so if the audit write fails the savepoint is rolled back without affecting the outer transaction - the user-facing action is preserved. If the audit write itself fails (schema drift, transient DB error, constraint violation), the failure is:

  • logged at ERROR level with CRITICAL: audit log write failed - audit trail incomplete
  • incremented in the audit_write_failures_total Prometheus metric
  • not propagated to the caller - the user-facing action already executed and the correct trade-off is to keep the action alive rather than roll it back because an audit row failed

Monitor audit_write_failures_total in your alerting stack. A non-zero counter means the audit trail has gaps.


ActionRequired role
Read own audit-log historyany authenticated user
Read any user’s audit-log historyorg_admin or higher
Read resource/entity audit-log historyorg_admin or higher
Read security eventsorg_admin or higher
Export logsorg_admin or higher
Validate HMAC chainsuper_admin only

Set AUDIT_HMAC_KEY in your environment to a strong random string:

Terminal window
# Generate a suitable key
python -c "import secrets; print(secrets.token_hex(32))"

Add to your .env file:

AUDIT_HMAC_KEY=<64-char-hex>

If AUDIT_HMAC_KEY is absent, the system falls back to a domain-separated derivative of SECRET_KEY. The chain will work but any rotation of SECRET_KEY will invalidate all previously computed HMACs - the validator will report row_hmac mismatch for every row written under the old key. Set AUDIT_HMAC_KEY separately so you can rotate SECRET_KEY independently.