Skip to content

Audit & Compliance

FreeSDN writes an append-only audit log for every privileged action in the platform. Each record is linked into an HMAC-SHA256 hash chain so that offline verification can detect retroactive tampering. A dedicated GET /api/v1/audit/validate endpoint walks the chain and reports the first broken link, if any.

This page covers:

  • What gets recorded and which fields each entry contains
  • The before/after diff mechanism and resource-scoped lookups
  • How the tamper-evidence chain works - and its documented limits
  • Retention, export, and security events
  • Honest compliance posture (no SOC 2, no PostgreSQL RLS, no certification)

Audit endpoints use role-based access, not fine-grained permissions:

RoleAccess
org_adminQuery and export logs for their own organisation only
super_adminPlatform-wide access; also the only role that can run chain validation
Any authenticated userReference-data enum endpoints (/audit/actions, /audit/resource-types, /audit/security-event-types)

All list/get paths are org-scoped via user.organization_id. Lookups by resource or user include cross-tenant ownership verification - a foreign resource_id or user_id returns 404, not an empty list, to avoid leaking existence.


Every AuditLogRecord stores the following fields:

FieldTypeDescription
idUUIDRecord primary key
organization_idUUIDOrg the action belongs to
site_idUUID | nullSite context when relevant
actor_idUUIDUser who performed the action
actionenum AuditActionVerb (see /audit/actions for full list)
resource_typeenum ResourceTypeObject class affected (see /audit/resource-types)
resource_idUUID | nullSpecific object affected
statusstringsuccess or failure
ip_addressstring | nullRequest IP
user_agentstring | nullRequest UA
changesJSONB | nullField-level delta: {field: {old: ..., new: ...}}
previous_stateJSONB | nullFull object state before the change
new_stateJSONB | nullFull object state after the change
metadataJSONBFreeform context dict (extra_metadata in Python) - not the diff store
prev_hashstring | nullHash of the previous record (chain link). null for rows written before the tamper-evidence migration.
row_hmacstring | nullHMAC-SHA256 of this record (tamper evidence). null for rows written before the tamper-evidence migration; the validate endpoint reports them as unchained rather than broken.
timestamptimestampWhen the event occurred

Mutation diffs are split across three dedicated columns: changes (field-level delta {field: {old: ..., new: ...}}), previous_state (full object state before the change), and new_state (full object state after). The metadata column (extra_metadata in Python) is a general-purpose JSONB context dict and does not carry the diffs. Coverage of all three diff columns varies per action type - they are written explicitly by the service layer, not via a DB trigger.


All endpoints are under the prefix /api/v1/audit. Admin access required except where noted.

MethodPathPurpose
GET/api/v1/audit/logsQuery audit logs with filters
GET/api/v1/audit/logs/resource/{resource_type}/{resource_id}All log entries for a specific resource
GET/api/v1/audit/logs/user/{user_id}Logs for a user (self, or admin for any in-org user)
GET/api/v1/audit/summary/activityActivity summary over a date range
GET/api/v1/audit/summary/securitySecurity summary (logins, lockouts, high-risk events)

GET /api/v1/audit/logs query parameters:

ParameterTypeNotes
start_dateISO 8601Optional date filter lower bound
end_dateISO 8601Optional date filter upper bound
actionstring (multi-value)One or more AuditAction values
resource_typestring (multi-value)One or more ResourceType values
resource_idUUIDFilter to a specific object
actor_idUUIDFilter to a specific user
statusstringsuccess or failure
searchstring ≤128 charsFree-text search across metadata
site_idUUIDFilter to a site
pageintPage number (1-based)
per_pageint ≤200Page size
POST /api/v1/audit/export

Body (AuditExportRequest) accepts five fields only - it does not mirror all query parameters:

FieldTypeNotes
start_dateISO 8601Optional lower bound
end_dateISO 8601Optional upper bound
actionslist[string]Plural - filter to specific AuditAction values
resource_typeslist[string]Plural - filter to specific ResourceType values
format"json" | "csv"Output format (default "json")

resource_id, actor_id, status, search, and site_id are not available as export filters. Extra fields sent in the body are silently discarded by Pydantic with no error returned. To filter on those fields, use GET /api/v1/audit/logs with page/per_page pagination instead.

Note also that the export body uses the plural forms actions and resource_types (lists), whereas the query endpoint’s documented params are the singular action and resource_type (scalar, with multi-value repeat support).

The export is capped at 10,000 rows. When the result is truncated the response envelope includes {"truncated": true, "total": N, "limit": 10000, "returned": 10000, "items": [...]} for JSON, and the HTTP headers X-Result-Truncated, X-Result-Total, and X-Result-Limit for CSV. If you need larger exports, paginate the query or query the database directly with appropriate access controls in place.

These three endpoints require only a valid session - no admin role:

MethodPathReturns
GET/api/v1/audit/actionsFull enum of AuditAction values
GET/api/v1/audit/resource-typesFull enum of ResourceType values
GET/api/v1/audit/security-event-typesFull enum of SecurityEventType values

Security events are stored separately from the main audit log in SecurityEventRecord. Key differences from AuditLogRecord:

  • No site_id column - security events are org-level, not site-scoped.
  • Both a severity text column and an integer risk_score column - SecurityEventRecord stores a severity String(20) column (non-nullable, default "info") alongside risk_score (Integer, 0-100). The severity column is set to its default on insert and is not automatically updated when risk_score changes. When the API accepts a severity query parameter (e.g. severity=high), it is translated to a risk_score range at query time (see bands below) rather than filtering on the stored severity column:
Severity labelrisk_score band
low0-19
medium20-49
high50-79
critical80-100

Any event with risk_score >= 70 triggers an alert automatically.

MethodPathPurpose
GET/api/v1/audit/securityQuery security events
GET/api/v1/audit/security-eventsAlias used by the Security page in the UI
GET/api/v1/audit/summary/securityAggregated security summary

Both /security and /security-events accept the same parameters: user_id, event_type, severity, search, date range, page, per_page.


Each AuditLogRecord carries two fields that form the chain:

  • prev_hash - the row_hmac value (HMAC-SHA256) of the immediately preceding record. null only for the genesis row.
  • row_hmac - HMAC-SHA256(key, prev_hash || canonical_json(this_record)). This ties the record content to its position in the chain.

The HMAC key is resolved from the AUDIT_HMAC_KEY environment variable. If that variable is not set, the platform derives a key from SECRET_KEY. To maximise the independence of the audit key from the application secret, set AUDIT_HMAC_KEY to a separate random value.

GET /api/v1/audit/validate

Required role: super_admin

Query parameterTypeNotes
start_idUUIDOptional - begin validation from a specific record
end_idUUIDOptional - stop at a specific record
limitint ≤100,000Number of records to walk (default: 10,000, max: 100,000). To validate the full chain, pass limit=100000 and paginate using start_id/end_id if your chain exceeds that count.

Response shape:

{
"valid": true,
"broken_at": null,
"broken_reason": null,
"checked": 4821,
"unchained": 0
}

When a break is detected, valid is false, broken_at is the UUID of the first broken record, and broken_reason describes the mismatch (wrong prev_hash or wrong row_hmac).

Run this on a schedule - weekly at minimum - and store the results externally if you are using the audit log as evidence for a compliance program.


Audit retention is controlled by the organisation tier quota field max_audit_retention_days:

TierAudit retention
FREE7 days
STARTER30 days
PROFESSIONAL90 days
ENTERPRISE365 days
UNLIMITEDUnlimited

Org quotas are opt-in (ENFORCE_ORG_QUOTAS=false by default). On a self-hosted installation without quota enforcement, retention is bounded only by your PostgreSQL storage. The tier is read from organization.settings["tier"] - it is not yet a first-class column - and defaults to FREE if absent or invalid.


Set the key in your environment file before starting the stack. Using a dedicated key rather than relying on the SECRET_KEY fallback means that rotating SECRET_KEY (for example after a credential leak) does not silently invalidate the audit chain.

Terminal window
# .env.pro / .env.max - recommended: separate, random, ≥32 chars
AUDIT_HMAC_KEY=<random-string-separate-from-SECRET_KEY>

If you rotate AUDIT_HMAC_KEY, old records will fail chain validation because their HMACs were computed with the previous key. Before rotating, export the current chain validation result and archive it. After rotation, treat the first new record as a new genesis point.


This release includes automated tests and internal review, but no third-party security audit or certification is claimed. Security regression tests under backend/tests/security/test_pentest_*.py cover tenant isolation, privilege escalation, secret redaction, SSRF, and authentication-flow edge cases.

What that means for compliance:

ClaimAccurate?
Tamper-evident audit log with HMAC chainYes
Chain validation endpoint for offline verificationYes
Before/after diffs on mutating actionsYes, where the service layer records them
Role-based access control on all audit endpointsYes
Automated security regression suite (backend/tests/security/)Yes
Third-party security audit or certificationNo - not claimed
SOC 2 Type I or Type II certificationNo
ISO 27001 certificationNo
HIPAA Business Associate AgreementNo
PCI-DSS certificationNo
PostgreSQL Row-Level SecurityNo - application-layer enforcement only
Automatic PostgreSQL failoverNo - manual promotion; Valkey failover is automatic

  1. Navigate to Logs in the sidebar (route /logs).
  2. Set status = failure and a date range covering the period of interest.
  3. Optionally narrow by action or resource_type using the filter dropdowns.
  4. Use Export (CSV or JSON) for offline analysis. Remember the 10,000-row cap.
  1. Log in as a super_admin user.
  2. Call the validate endpoint directly (no UI for this yet):
Terminal window
curl -s -H "Authorization: Bearer $TOKEN" \
"https://your-freesdn-host/api/v1/audit/validate?limit=100000" | jq .
  1. Verify "valid": true. Store the response and the timestamp externally.
  2. If "valid": false, record broken_at and broken_reason. Do not modify the database before preserving evidence. Investigate whether AUDIT_HMAC_KEY was rotated without archiving, or whether tampering occurred.
  1. Decide on a polling interval (hourly is typical).
  2. Use POST /api/v1/audit/export with format: json, date-bounded to the poll window.
  3. Check truncated in the response; if true, shrink the window or switch to paginated queries using GET /api/v1/audit/logs with page/per_page.
  4. Ship the JSON records to your SIEM. FreeSDN does not have a native push connector - this is a pull-only integration at this time.
  1. Navigate to Security in the sidebar (route /security).
  2. Filter by severity = critical or high to surface events with risk_score >= 50.
  3. Use the actor_id from a flagged event to pull that user’s full audit-log history via GET /api/v1/audit/logs/user/{user_id}.
  4. If the event warrants a response, use the Alert Rules engine or Incidents feature to track the investigation. See the Alerting & Notifications page.

VariableDefaultPurpose
AUDIT_HMAC_KEY(derived from SECRET_KEY)HMAC key for the audit hash chain. Set to a separate random value.
ENFORCE_ORG_QUOTASfalseWhen true, enforces tier-based retention limits. Off by default for self-hosted installs.