Audit Logs
EnterpriseReview the NetStacks Controller audit log: who did what, when, and from which IP. Filter by event type, resource, actor, and date range, and query the API.
Overview
The NetStacks Controller records security- and operations-relevant actions in an append-only audit_logs table in PostgreSQL. Every login (success and failure), logout, token refresh, user and role change, credential operation, credential-folder access grant, config deployment, device command execution, LLM provider change, session takeover, tunnel operation, and system backup/restore is written with full attribution — the event type, the acting user, the client IP address, a UTC timestamp, and a structured JSON payload describing what changed.
Audit logs serve three primary purposes:
- Security monitoring — spot failed logins, credential password reveals, and session takeovers across the platform.
- Compliance — provide a who/what/when record of administrative and operational actions for SOX, PCI-DSS, SOC 2, and HIPAA programs.
- Troubleshooting — reconstruct the sequence of events that led to a problem, such as identifying who deployed a config to a device.
Audit logging lives in the NetStacks Controller (the self-hosted, multi-user backend). The audit log is reached at Audit Logs in the admin sidebar (route /audit). The endpoints sit under /api/admin/audit and require a valid Controller session (JWT). In the admin UI the Audit Logs item is shown to users with administrative access; there is no separate admin.audit permission key.
The Controller does not currently ship a built-in CSV/JSON export button, a SIEM/syslog/CEF forwarder, or a configurable retention policy. The audit data is exposed through the read API documented below; use that API to feed an external SIEM or archival job. Earlier drafts of this page described those features — they are not part of the shipped product.
How It Works
Event Schema
Each entry returned by the audit API has the following fields. Internally the row stores org_id, user_id, event_type, event_data, ip_address, user_agent, and created_at; the API reshapes that into the response below.
| Field | Type | Description |
|---|---|---|
id | UUID | Unique event identifier |
event_type | string | Dotted action name, e.g. credential.password_revealed |
actor_id | UUID (nullable) | User who performed the action; null for system/unauthenticated events |
actor_email | string (nullable) | Email of the acting user, joined for display |
resource_type | string (nullable) | Derived from the part of event_type before the first dot (no dedicated column) |
resource_id | string (nullable) | Always null — not persisted as a separate column today |
details | JSON (nullable) | Per-event payload (the stored event_data); shape varies by event type |
ip_address | string (nullable) | Client IP of the actor, when available |
created_at | timestamp | When the event occurred (UTC) |
The handler hard-codes resource_id to null and computes resource_type from the event_type prefix (the text before the first dot). The specific resource identifier (a device ID, credential ID, user ID, and so on) lives inside the details JSON, not in a top-level resource_id field.
Event Types
Event types follow a resource.action (sometimes resource.action.outcome) convention. The authoritative list for any given deployment is whatever has actually been written — query GET /api/admin/audit/event-types rather than relying on a hardcoded set. The event types emitted by the Controller include:
| Event Type | Description |
|---|---|
auth.login.success | Successful login; details.method is local, ldap, or oidc |
auth.login.failed | Failed login attempt (includes the attempted username and method) |
auth.logout | User logout |
auth.refresh | Access token refreshed from a refresh token |
auth.password_change | User changed their password |
auth.session_revoked | A session/refresh token was revoked |
user.created | New user account created |
user.updated | User profile updated |
user.deleted | User account deleted |
user.role_assigned | A role was assigned to a user |
user.role_removed | A role was removed from a user |
user.roles_updated | A user's full role set was replaced |
role.created | New custom role created |
role.updated | Role name or permissions changed |
role.deleted | Custom role deleted |
credential.created | Credential added to the vault |
credential.updated | Credential edited |
credential.deleted | Credential removed |
credential.password_revealed | A user revealed a credential secret (reason captured) |
credential.internal_access | A credential was used internally by the platform |
credential_folder.created / .updated / .deleted | Credential folder lifecycle |
credential_folder.access_granted / .access_revoked | Folder access shared with or revoked from a user |
device.exec_command | A command was run against a device over SSH |
device.exec_command.failed | A device command execution failed |
session.share.created / .revoked | A terminal session share was created or revoked |
session.takeover | A shared session was taken over |
session.terminated | A session was terminated |
tunnel.created / .updated / .deleted | Tunnel definition lifecycle |
tunnel.started / .stopped / .reconnected | Tunnel runtime state changes |
tunnel.start_all / .stop_all | Bulk tunnel start/stop |
llm_provider.created / .updated / .deleted | AI/LLM provider configuration changes |
ai.ssh_execute / ai.ssh_execute.failed | AI-assisted SSH execution |
system.backup / system.restore | System backup created or restore performed |
The exact set of event types present depends on what has happened in your deployment. Call GET /api/admin/audit/event-types to get the distinct list actually recorded — that is what populates the Event Type filter dropdown in the UI, so it never falls out of sync.
Pagination and Filtering
GET /api/admin/audit supports server-side pagination and filtering. The query parameters are:
page— 1-based page number (defaults to 1).limit— page size, clamped to 1–100 (API default is 10; the admin UI requests 20).event_type— exact event-type match, e.g.auth.login.failed.resource_type— matches the prefix before the first dot (implemented asevent_type LIKE '<resource_type>.%').actor_id— the acting user's UUID.from_date/to_date— RFC 3339 timestamps boundingcreated_at.
The response includes total (matching rows), page, limit, and total_pages for navigation.
Event Type Discovery
GET /api/admin/audit/event-types returns the distinct event types that have been recorded, so a client can build a filter dropdown without hardcoding the list.
Step-by-Step Guide
Viewing Recent Events
- Open the admin sidebar and click Audit Logs (route
/audit). - The default view lists the most recent events, newest first, 20 per page.
- Each row shows the time (relative, with the full timestamp on hover), the event type, the actor (email, or System when there is no actor), the derived resource type, and the IP address.
- Click View on a row to open the detail dialog, which shows the actor ID, IP address, resource type, and the full
detailsJSON payload.
Filtering and Searching
- Click Filters to expand the filter bar.
- Event Type — select one event type (e.g.
auth.login.failedto investigate failed logins). The dropdown is populated from/api/admin/audit/event-types. - Resource Type — narrow to a resource family such as
user,role, orcredential(matches the event-type prefix). - Actor — the field is labeled Actor Email, but it sends whatever you type as the
actor_idquery parameter, which matches on the actor's UUID. Paste a user's UUID (from Users in the admin area) for the filter to match. - Date Range — set
from/todates to bound the time window. - Use Clear all filters to reset.
There is no export button in the UI today. To export or forward audit data — for example into Splunk, Elastic, or a syslog/SIEM pipeline — pull it through GET /api/admin/audit on a schedule and transform it yourself. The Code Examples below show a paginated pull you can adapt into a cron job or collector.
Code Examples
Audit endpoints live under /api/admin/audit and require a valid Controller bearer token. Replace the host with your Controller URL.
Query Audit Logs with Filters
curl "https://netstacks.dc1.example.net/api/admin/audit?event_type=auth.login.failed&from_date=2026-03-01T00:00:00Z&to_date=2026-03-15T23:59:59Z&limit=50&page=1" \
-H "Authorization: Bearer $TOKEN"Response:
{
"data": [
{
"id": "e1f2a3b4-c5d6-7890-abcd-ef1234567890",
"event_type": "auth.login.failed",
"actor_id": null,
"actor_email": null,
"resource_type": "auth",
"resource_id": null,
"details": {
"username": "admin",
"method": "local"
},
"ip_address": "10.0.1.50",
"created_at": "2026-03-15T14:30:22Z"
},
{
"id": "f2a3b4c5-d6e7-8901-bcde-f23456789012",
"event_type": "auth.login.failed",
"actor_id": null,
"actor_email": null,
"resource_type": "auth",
"resource_id": null,
"details": {
"username": "jsmith",
"method": "ldap"
},
"ip_address": "192.168.10.25",
"created_at": "2026-03-14T09:15:44Z"
}
],
"total": 23,
"page": 1,
"limit": 50,
"total_pages": 1
}Note that resource_type is just the auth prefix of the event type, and resource_id is always null — the attempted username lives in details.
List Available Event Types
curl https://netstacks.dc1.example.net/api/admin/audit/event-types \
-H "Authorization: Bearer $TOKEN"Response (a flat array of the distinct types recorded in your deployment):
[
"auth.login.success",
"auth.login.failed",
"auth.logout",
"auth.refresh",
"auth.password_change",
"credential.created",
"credential.password_revealed",
"device.exec_command",
"role.created",
"user.created",
"user.role_assigned",
"user.roles_updated"
]Filter by Actor (UUID)
# Find all actions by a specific user. Get the UUID from the admin Users page.
curl "https://netstacks.dc1.example.net/api/admin/audit?actor_id=a1b2c3d4-e5f6-7890-abcd-ef1234567890&limit=25" \
-H "Authorization: Bearer $TOKEN"Audit Entry: Credential Password Revealed
Revealing a stored secret is audit-gated and records the reason the user supplied:
{
"id": "a3b4c5d6-e7f8-9012-cdef-345678901234",
"event_type": "credential.password_revealed",
"actor_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"actor_email": "[email protected]",
"resource_type": "credential",
"resource_id": null,
"details": {
"credential_id": "c9d8e7f6-1234-5678-9abc-def012345678",
"credential_name": "core-rtr enable secret",
"reason": "emergency console access during P1"
},
"ip_address": "10.0.1.50",
"created_at": "2026-03-15T14:30:00Z"
}Audit Entry: Device Command Execution
{
"id": "b4c5d6e7-f890-1234-defa-456789012345",
"event_type": "device.exec_command",
"actor_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"actor_email": "[email protected]",
"resource_type": "device",
"resource_id": null,
"details": {
"device_id": "d1e2f3a4-5678-90ab-cdef-1234567890ab",
"device_name": "core-rtr-01",
"host": "10.0.0.1",
"command": "show running-config | include bgp",
"execution_time_ms": 842
},
"ip_address": "10.0.1.50",
"created_at": "2026-03-15T14:30:00Z"
}Paginated Pull for Archival / SIEM
Because there is no built-in exporter, fetch all pages and emit newline-delimited JSON that an external collector can ingest:
#!/usr/bin/env bash
# Pull every audit event since a cutoff and write NDJSON to stdout.
set -euo pipefail
HOST="https://netstacks.dc1.example.net"
FROM="2026-03-01T00:00:00Z"
PAGE=1
LIMIT=100
while :; do
RESP=$(curl -fsS "$HOST/api/admin/audit?from_date=$FROM&limit=$LIMIT&page=$PAGE" \
-H "Authorization: Bearer $TOKEN")
echo "$RESP" | jq -c '.data[]'
TOTAL_PAGES=$(echo "$RESP" | jq -r '.total_pages')
if [ "$PAGE" -ge "$TOTAL_PAGES" ]; then
break
fi
PAGE=$((PAGE + 1))
donePython: Investigate Failed Logins
import os
import requests
HOST = "https://netstacks.dc1.example.net"
TOKEN = os.environ["TOKEN"]
resp = requests.get(
f"{HOST}/api/admin/audit",
params={
"event_type": "auth.login.failed",
"from_date": "2026-03-01T00:00:00Z",
"limit": 100,
"page": 1,
},
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=30,
)
resp.raise_for_status()
body = resp.json()
print(f"{body['total']} failed logins")
for event in body["data"]:
details = event.get("details") or {}
print(
event["created_at"],
event.get("ip_address", "-"),
details.get("username", "?"),
details.get("method", "?"),
)Questions & Answers
- What events are captured in the audit log?
- Authentication events (login success/failure, logout, token refresh, password change, session revoke), user and role management, credential operations (create/update/delete, password reveal, internal access), credential-folder lifecycle and access grants, device command execution, terminal session share/takeover/termination, tunnel lifecycle and runtime changes, LLM provider changes, AI-assisted SSH execution, and system backup/restore. Each event records the actor (when authenticated), the IP address, a UTC timestamp, and a structured
detailspayload. The exact set in your deployment is discoverable viaGET /api/admin/audit/event-types. - Is there an
admin.auditpermission? - No. The audit endpoints are protected by the standard authentication middleware (a valid Controller JWT) and are not gated by a dedicated
admin.auditpermission key. In the admin UI the Audit Logs sidebar item is presented to users with administrative access. The administrative permission keys that exist areusers.manage,credentials.manage, andsystem.manage— see Roles & Permissions. - Can I export audit logs or forward them to a SIEM?
- There is no built-in export button or SIEM/syslog/CEF forwarder in the current release. To get audit data out, page through
GET /api/admin/auditand transform it yourself — the “Paginated Pull for Archival / SIEM” script above produces NDJSON suitable for a Splunk HEC uploader, an Elastic bulk import, or a custom syslog shipper. - How long are audit logs retained?
- The Controller does not ship a configurable retention policy. Rows remain in the
audit_logstable until you remove them. If you have a compliance retention requirement, manage it at the database layer (for example a scheduled delete or PostgreSQL partition drop) and/or archive events out through the API first. - Why is
resource_idalways null? - The audit response intentionally returns
resource_id: null— the Controller does not store a dedicated resource-ID column. The specific identifier (device ID, credential ID, target user ID, role ID, and so on) is inside thedetailsJSON. Likewiseresource_typeis derived from the event-type prefix, not stored separately. - How do I find who changed something on a device?
- Filter by
event_type=device.exec_command(ordevice.exec_command.failed) and read thedetailspayload, which carriesdevice_id,device_name,host, thecommand, andexecution_time_ms. Theactor_emailfield tells you who ran it. For configuration deployments, correlate with the deployment records described in Activity Monitor. - Can I see credential access history?
- Yes. When a user reveals a stored secret, a
credential.password_revealedevent is written including thecredential_id,credential_name, and thereasonthe user provided. Internal platform use of a credential is logged ascredential.internal_access. Filter by these event types to audit who accessed which credentials and why. - How do I filter by user?
- Use the
actor_idquery parameter with the user's UUID. The UI's actor field (labeled Actor Email) sends the sameactor_idparameter, so paste the UUID there. Look up a user's UUID on the admin Users page.
Troubleshooting
Audit Logs Growing Too Large
- There is no UI retention control, so manage growth at the PostgreSQL layer — for example a scheduled
DELETE FROM audit_logs WHERE created_at < now() - interval '1 year'after you have archived what you need. - Use the paginated API pull (above) to offload events to external log storage (Splunk, Elastic, Loki) before deleting.
- For very large tables, consider PostgreSQL range partitioning by
created_atso old partitions can be dropped cheaply.
Missing Audit Events
- Confirm you are authenticated — the endpoint returns 401 without a valid token, and no events are visible.
- Check the date range filter; a narrow
from_date/to_datewindow can exclude the events you want. - Check the event type filter and clear it to see the full log. Remember the modern names use dotted outcomes, e.g.
auth.login.failed(notauth.login_failed). - Audit writes are non-blocking: a failed write logs a warning server-side rather than aborting the request, so in rare failure cases an event can be absent. Check the Controller logs for “audit log write failed” warnings.
Search Returns No Results
- Widen the date range so you are not filtering out events by time.
- Clear all filters and verify events exist at all.
- Verify the event type spelling against
GET /api/admin/audit/event-types— only types actually recorded appear there. - For actor searches, use the user's UUID (
actor_id), not their email or username.
Resource Column Shows a Dash
- The Resource column derives
resource_typefrom the event-type prefix and shows the (always null)resource_id. A dash is expected — open the row detail and read thedetailsJSON for the actual identifier.
Related Features
- User Management — user create/update/delete and role assignment actions are written as
user.*audit events; look up actor UUIDs here. - Roles & Permissions — role changes generate
role.*events; this is where the real permission keys (users.manage,system.manage) are defined. - Authentication (LDAP/OIDC) — login, logout, refresh, and failure events are core
auth.*audit entries. - Activity Monitor — complements the audit log with live session and job activity.
- Credential Vault — credential reveals and changes are recorded as
credential.*events. - System Settings — backup/restore actions appear as
system.backup/system.restoreevents.