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)
Who can access audit data
Section titled “Who can access audit data”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.
Audit log record fields
Section titled “Audit log record fields”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.
API endpoints
Section titled “API endpoints”All endpoints are under the prefix /api/v1/audit. Admin access required except where noted.
Querying logs
Section titled “Querying logs”| 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 |
Exporting logs
Section titled “Exporting logs”POST /api/v1/audit/exportBody (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.
Reference data
Section titled “Reference data”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
Section titled “Security events”Security events are stored separately from the main audit log in SecurityEventRecord. Key
differences from AuditLogRecord:
- No
site_idcolumn - security events are org-level, not site-scoped. - Both a
severitytext column and an integerrisk_scorecolumn -SecurityEventRecordstores aseverityString(20) column (non-nullable, default"info") alongsiderisk_score(Integer, 0-100). Theseveritycolumn is set to its default on insert and is not automatically updated whenrisk_scorechanges. When the API accepts aseverityquery parameter (e.g.severity=high), it is translated to arisk_scorerange at query time (see bands below) rather than filtering on the storedseveritycolumn:
| 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.
Tamper-evidence hash chain
Section titled “Tamper-evidence hash chain”How it works
Section titled “How it works”Each AuditLogRecord carries two fields that form the chain:
prev_hash- therow_hmacvalue (HMAC-SHA256) of the immediately preceding record.nullonly 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.
Validating the chain
Section titled “Validating the chain”GET /api/v1/audit/validateRequired 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.
Documented limits of tamper evidence
Section titled “Documented limits of tamper evidence”Retention
Section titled “Retention”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.
Configuring the audit HMAC key
Section titled “Configuring the audit HMAC key”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.
# .env.pro / .env.max - recommended: separate, random, ≥32 charsAUDIT_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.
Honest compliance posture
Section titled “Honest compliance posture”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 |
Procedures
Section titled “Procedures”Query recent failed actions
Section titled “Query recent failed actions”- Navigate to Logs in the sidebar (route
/logs). - Set
status = failureand a date range covering the period of interest. - Optionally narrow by
actionorresource_typeusing the filter dropdowns. - Use
Export(CSV or JSON) for offline analysis. Remember the 10,000-row cap.
Run a chain validation check
Section titled “Run a chain validation check”- Log in as a
super_adminuser. - Call the validate endpoint directly (no UI for this yet):
curl -s -H "Authorization: Bearer $TOKEN" \ "https://your-freesdn-host/api/v1/audit/validate?limit=100000" | jq .- Verify
"valid": true. Store the response and the timestamp externally. - If
"valid": false, recordbroken_atandbroken_reason. Do not modify the database before preserving evidence. Investigate whetherAUDIT_HMAC_KEYwas rotated without archiving, or whether tampering occurred.
Export logs for an external SIEM
Section titled “Export logs for an external SIEM”- Decide on a polling interval (hourly is typical).
- Use
POST /api/v1/audit/exportwithformat: json, date-bounded to the poll window. - Check
truncatedin the response; iftrue, shrink the window or switch to paginated queries usingGET /api/v1/audit/logswithpage/per_page. - Ship the JSON records to your SIEM. FreeSDN does not have a native push connector - this is a pull-only integration at this time.
Investigate a high-risk security event
Section titled “Investigate a high-risk security event”- Navigate to Security in the sidebar (route
/security). - Filter by
severity = criticalorhighto surface events withrisk_score >= 50. - Use the
actor_idfrom a flagged event to pull that user’s full audit-log history viaGET /api/v1/audit/logs/user/{user_id}. - If the event warrants a response, use the Alert Rules engine or Incidents feature to track the investigation. See the Alerting & Notifications page.
Environment variables
Section titled “Environment variables”| 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. |
Next steps
Section titled “Next steps”- Alerting & Notifications - wire alerts to the audit security-event feed and configure notification channels.
- Multi-Tenancy & MSP - org hierarchy, per-user site grants, and the quota model.
- SLA Management - define and evaluate SLA policies with breach tracking and reports.
- Review the security documentation for the platform security model and remediation posture.