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:

Role Access
org_admin Query and export logs for their own organisation only
super_admin Platform-wide access; also the only role that can run chain validation
Any authenticated user Reference-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:

Field Type Description
id UUID Record primary key
organization_id UUID Org the action belongs to
site_id UUID | null Site context when relevant
actor_id UUID User who performed the action
action enum AuditAction Verb (see /audit/actions for full list)
resource_type enum ResourceType Object class affected (see /audit/resource-types)
resource_id UUID | null Specific object affected
status string success or failure
ip_address string | null Request IP
user_agent string | null Request UA
changes JSONB | null Field-level delta: {field: {old: ..., new: ...}}
previous_state JSONB | null Full object state before the change
new_state JSONB | null Full object state after the change
metadata JSONB Freeform context dict (extra_metadata in Python) - not the diff store
prev_hash string | null Hash of the previous record (chain link). null for rows written before the tamper-evidence migration.
row_hmac string | null HMAC-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.
timestamp timestamp When 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.

Method Path Purpose
GET /api/v1/audit/logs Query 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/activity Activity summary over a date range
GET /api/v1/audit/summary/security Security summary (logins, lockouts, high-risk events)

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

Parameter Type Notes
start_date ISO 8601 Optional date filter lower bound
end_date ISO 8601 Optional date filter upper bound
action string (multi-value) One or more AuditAction values
resource_type string (multi-value) One or more ResourceType values
resource_id UUID Filter to a specific object
actor_id UUID Filter to a specific user
status string success or failure
search string ≤128 chars Free-text search across metadata
site_id UUID Filter to a site
page int Page number (1-based)
per_page int ≤200 Page size
POST /api/v1/audit/export

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

Field Type Notes
start_date ISO 8601 Optional lower bound
end_date ISO 8601 Optional upper bound
actions list[string] Plural - filter to specific AuditAction values
resource_types list[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:

Method Path Returns
GET /api/v1/audit/actions Full enum of AuditAction values
GET /api/v1/audit/resource-types Full enum of ResourceType values
GET /api/v1/audit/security-event-types Full 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 label risk_score band
low 0-19
medium 20-49
high 50-79
critical 80-100

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

Method Path Purpose
GET /api/v1/audit/security Query security events
GET /api/v1/audit/security-events Alias used by the Security page in the UI
GET /api/v1/audit/summary/security Aggregated 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 parameter Type Notes
start_id UUID Optional - begin validation from a specific record
end_id UUID Optional - stop at a specific record
limit int ≤100,000 Number 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:

Tier Audit retention
FREE 7 days
STARTER 30 days
PROFESSIONAL 90 days
ENTERPRISE 365 days
UNLIMITED Unlimited

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:

Claim Accurate?
Tamper-evident audit log with HMAC chain Yes
Chain validation endpoint for offline verification Yes
Before/after diffs on mutating actions Yes, where the service layer records them
Role-based access control on all audit endpoints Yes
Automated security regression suite (backend/tests/security/) Yes
Third-party security audit or certification No - not claimed
SOC 2 Type I or Type II certification No
ISO 27001 certification No
HIPAA Business Associate Agreement No
PCI-DSS certification No
PostgreSQL Row-Level Security No - application-layer enforcement only
Automatic PostgreSQL failover No - 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.

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

All product names, logos, and brands are property of their respective owners. FreeSDN is an independent project and is not affiliated with or endorsed by the vendors it integrates with. See Trademarks.