What We’re Testing
Every handler that calls logAudit() (from services/loki.ts) writes an audit entry when a specific action completes. This chapter triggers five categories of real actions and verifies that the correct audit entries appear in the log.
The logAudit() signature is:
logAudit(org_id, action, resource_type, resource_id?, user_id?, details?, ip_address?)
The Loki push payload stores action, resource_type, resource_id, user_id, details, and ip_address as a JSON line inside the stream labelled job="audit", org_id, action_prefix, resource_type.
Key action strings verified in this chapter:
| Handler file | Action string | Trigger |
|---|---|---|
handlers/auth.ts | auth.login | Email/password login |
handlers/client-setup.ts | machine.self_service_setup | Machine registered via ztna up |
handlers/cleanup-machines.ts | machine.marked_offline | Heartbeat timeout (machine goes offline) |
handlers/api-key-auth.ts | auth_key.created | New auth key created |
handlers/api-key-auth.ts | auth_key.revoked | Auth key revoked |
handlers/db-crud.ts | acl_rules.created | ACL rule inserted |
handlers/db-crud.ts | acl_rules.deleted | ACL rule deleted |
Your Test Setup
| Machine | Role |
|---|---|
| ⊞ Win-A | Browser (dashboard) + PowerShell API calls |
| 🐧 Linux-C | CLI machine registration (ztna up) to generate machine audit events |
Prerequisites:
- Both machines authenticated;
ztna upworks on 🐧 Linux-C - Admin-level user JWT available on ⊞ Win-A
ST1 — Verify auth.login Entry After Login
What it verifies: A fresh login creates an auth.login entry in Loki with the correct resource_type: "user" and details containing the user’s email and MFA status.
Steps:
-
On ⊞ Win-A , log out of the dashboard by clicking your user avatar and selecting Sign Out (or navigate to
/authdirectly). -
Log back in with your email and password.
-
After the dashboard loads, navigate to
/audit-log. -
The most recent entry (top row) should be
auth.login. -
Verify via API that the entry has the correct shape:
$body = @{
action = "search_audit_logs"
org_id = $ORG_ID
search = "auth.login"
page = 0
page_size = 5
} | ConvertTo-Json
$resp = Invoke-RestMethod `
-Uri "https://login.quickztna.com/api/governance" `
-Method POST `
-Headers @{ Authorization = "Bearer $TOKEN"; "Content-Type" = "application/json" } `
-Body $body
$latest = $resp.data.logs[0]
Write-Host "Action: $($latest.action)"
Write-Host "Resource type: $($latest.resource_type)"
Write-Host "User ID: $($latest.user_id)"
Write-Host "Details: $($latest.details | ConvertTo-Json)"
Expected:
Action: auth.login
Resource type: user
User ID: <your-user-uuid>
Details: { "email": "you@example.com", "mfa": false }
-
Confirm
details.emailmatches the account you just logged in with. -
Confirm
ip_addressis non-null (it should be your client’s IP as seen by the server):
Write-Host "IP address: $($latest.ip_address)"
Pass: action is auth.login, resource_type is user, details.email matches the logged-in user, ip_address is non-null.
Fail / Common issues:
- Most recent entry is not
auth.login— the 30-second auto-refresh may not have run yet. Wait 35 seconds, or manually search forauth.loginin the search box. ip_addressis null — the request arrived withoutX-Forwarded-FororX-Real-IPheaders. This happens in local development but not in production behind Caddy.
ST2 — Verify machine.self_service_setup Entry
What it verifies: Running ztna up on 🐧 Linux-C (which triggers handleClientSetup) creates a machine.self_service_setup audit entry with the machine name, tailnet IP, and peer count.
Steps:
- On 🐧 Linux-C , bring the VPN tunnel up:
ztna up
Wait for the status line “Connected.” to appear.
-
On ⊞ Win-A , navigate to
/audit-logand search formachine.self_service_setup. -
Verify the entry via API:
$body = @{
action = "search_audit_logs"
org_id = $ORG_ID
search = "machine.self_service_setup"
page = 0
page_size = 5
} | ConvertTo-Json
$resp = Invoke-RestMethod `
-Uri "https://login.quickztna.com/api/governance" `
-Method POST `
-Headers @{ Authorization = "Bearer $TOKEN"; "Content-Type" = "application/json" } `
-Body $body
$e = $resp.data.logs[0]
Write-Host "Action: $($e.action)"
Write-Host "Resource ID: $($e.resource_id)"
Write-Host "Details:"
$e.details | ConvertTo-Json
Expected details object:
{
"name": "linux-c",
"os": "linux",
"tailnet_ip": "100.x.x.x",
"peer_count": 1,
"reactivated": false
}
-
Confirm
resource_typeis"machine"andresource_idis the machine UUID. -
Verify the machine UUID matches the one in the Machines page:
$machineId = $e.resource_id
$m = Invoke-RestMethod -Uri "https://login.quickztna.com/api/db/machines?org_id=eq.$ORG_ID&id=eq.$machineId" `
-Headers @{ Authorization = "Bearer $TOKEN" }
Write-Host "Machine name: $($m.data[0].name)"
Pass: action is machine.self_service_setup, resource_type is machine, details.os is linux, details.tailnet_ip is populated.
Fail / Common issues:
- Entry does not appear —
ztna upmay have used the registration path (handleRegisterMachine) instead of the client setup path if the machine was registered with an auth key (not browser login). The registration handler logsmachine.registered(from thedb-crud.tsgeneric handler), notmachine.self_service_setup. Search formachine.registeredinstead. details.peer_countis 0 — the machine connected but no other machines were online at the time. This is correct.
ST3 — Verify auth_key.created and auth_key.revoked Entries
What it verifies: Creating and revoking an auth key in the dashboard each produce a corresponding Loki entry.
Steps:
-
On ⊞ Win-A , navigate to
/settingsand find the Auth Keys section (or navigate to/auth-logand use the Settings sidebar link). -
Click “Generate Key” or “Create Auth Key”. Accept the defaults. Note the new key’s prefix (e.g.,
tskey-auth-xxx). -
Navigate to
/audit-logand search forauth_key.created. -
Verify the entry:
$body = @{
action = "search_audit_logs"
org_id = $ORG_ID
search = "auth_key.created"
page = 0
page_size = 5
} | ConvertTo-Json
$resp = Invoke-RestMethod `
-Uri "https://login.quickztna.com/api/governance" `
-Method POST `
-Headers @{ Authorization = "Bearer $TOKEN"; "Content-Type" = "application/json" } `
-Body $body
$e = $resp.data.logs[0]
$e.details | ConvertTo-Json
Expected details:
{
"name": "My Key",
"reusable": false,
"ephemeral": false,
"expiry_days": 90,
"allowed_tags": null,
"allowed_cidrs": null
}
-
Now revoke the key you just created. On the Settings page, click Revoke next to the key.
-
Wait 5 seconds and reload the Audit Log page. Search for
auth_key.revoked. -
Verify the revocation entry:
$body = @{
action = "search_audit_logs"
org_id = $ORG_ID
search = "auth_key.revoked"
page = 0
page_size = 5
} | ConvertTo-Json
$resp = Invoke-RestMethod `
-Uri "https://login.quickztna.com/api/governance" `
-Method POST `
-Headers @{ Authorization = "Bearer $TOKEN"; "Content-Type" = "application/json" } `
-Body $body
$e = $resp.data.logs[0]
Write-Host "machines_quarantined: $($e.details.machines_quarantined)"
Expected: action is auth_key.revoked, details.machines_quarantined is 0 (or a positive integer if machines were using the key).
Pass: Both auth_key.created and auth_key.revoked entries exist; creation details include the key name and expiry; revocation details include machines_quarantined.
Fail / Common issues:
auth_key.creatednot found — the key was created via the CRUD API (POST /api/db/auth_keys), which logsauth_keys.created(table name, plural). Search forauth_keys.createdinstead.machines_quarantinedis null instead of 0 — the revoking code path may have omitted the details. This is a minor discrepancy in the handler; the entry still exists.
ST4 — Verify acl_rules.created and acl_rules.deleted Entries
What it verifies: Creating and deleting an ACL rule via the CRUD endpoint each produce a acl_rules.created / acl_rules.deleted entry logged by handlers/db-crud.ts.
Steps:
- On ⊞ Win-A , create a test ACL rule via the API:
$rule = @{
org_id = $ORG_ID
src = "tag:test"
dst = "tag:target"
proto = "tcp"
ports = "8080"
action = "allow"
description = "audit-log test rule"
} | ConvertTo-Json
$created = Invoke-RestMethod `
-Uri "https://login.quickztna.com/api/db/acl_rules" `
-Method POST `
-Headers @{ Authorization = "Bearer $TOKEN"; "Content-Type" = "application/json" } `
-Body $rule
$ruleId = $created.data.id
Write-Host "Created rule ID: $ruleId"
- Wait 5 seconds, then search for
acl_rules.createdin the Audit Log:
$body = @{
action = "search_audit_logs"
org_id = $ORG_ID
search = "acl_rules.created"
page = 0
page_size = 5
} | ConvertTo-Json
$resp = Invoke-RestMethod `
-Uri "https://login.quickztna.com/api/governance" `
-Method POST `
-Headers @{ Authorization = "Bearer $TOKEN"; "Content-Type" = "application/json" } `
-Body $body
$resp.data.logs[0] | Select-Object action, resource_type, resource_id
Expected: action: "acl_rules.created", resource_type: "acl_rules", resource_id is the new rule’s UUID.
- Now delete the rule:
Invoke-RestMethod `
-Uri "https://login.quickztna.com/api/db/acl_rules?id=eq.$ruleId&org_id=eq.$ORG_ID" `
-Method DELETE `
-Headers @{ Authorization = "Bearer $TOKEN"; "Content-Type" = "application/json" } `
-Body "{}"
- Search for
acl_rules.deleted:
$body = @{
action = "search_audit_logs"
org_id = $ORG_ID
search = "acl_rules.deleted"
page = 0
page_size = 5
} | ConvertTo-Json
$resp = Invoke-RestMethod `
-Uri "https://login.quickztna.com/api/governance" `
-Method POST `
-Headers @{ Authorization = "Bearer $TOKEN"; "Content-Type" = "application/json" } `
-Body $body
$resp.data.logs[0].details | ConvertTo-Json
Expected details for deletion:
{ "filters": ["id=eq.<rule-uuid>", "org_id=eq.<org-uuid>"] }
Pass: Both acl_rules.created (with resource_id = the new rule’s UUID) and acl_rules.deleted (with filters array) entries exist in Loki.
Fail / Common issues:
resource_idis null for the created entry — thedb-crud.tshandler only setsresource_idtoinsertedRows[0]?.id. If the insert returned no rows, the ID may be null. Verify the rule was actually created by checking$created.data.id.acl_rules.deletedentry showsfilters: []— the DELETE request was sent without_filtersin the body or without URL query parameters. The CRUD handler logsbody._filters || []. Ensure the DELETE body is{}(not empty) and theidfilter is in the URL.
ST5 — Verify machine.marked_offline Entry After Tunnel Down
What it verifies: Stopping the VPN tunnel on 🐧 Linux-C and waiting for the heartbeat timeout causes the cleanup cron to log machine.marked_offline.
Steps:
- On 🐧 Linux-C , stop the tunnel:
ztna down
-
The cleanup cron (
handlers/cleanup-machines.ts) runs periodically and marks machines offline when their last heartbeat is stale (typically within 90 seconds of the heartbeat timeout). Wait up to 3 minutes. -
On ⊞ Win-A , search the Audit Log for
machine.marked_offline:
$body = @{
action = "search_audit_logs"
org_id = $ORG_ID
search = "machine.marked_offline"
page = 0
page_size = 5
} | ConvertTo-Json
$resp = Invoke-RestMethod `
-Uri "https://login.quickztna.com/api/governance" `
-Method POST `
-Headers @{ Authorization = "Bearer $TOKEN"; "Content-Type" = "application/json" } `
-Body $body
$e = $resp.data.logs[0]
Write-Host "Action: $($e.action)"
Write-Host "Resource: $($e.resource_type) / $($e.resource_id)"
Write-Host "Details: $($e.details | ConvertTo-Json)"
Expected:
Action: machine.marked_offline
Resource: machine / <linux-c-machine-uuid>
Details: { "name": "linux-c", "reason": "heartbeat_timeout" }
- Confirm
user_idon this entry is null (the cleanup cron runs without a user context):
Write-Host "User ID: $($e.user_id)"
Expected: user_id is null or empty.
- Cross-check by verifying the machine status changed to
offlinein the database:
$m = Invoke-RestMethod -Uri "https://login.quickztna.com/api/db/machines?org_id=eq.$ORG_ID&name=eq.linux-c" `
-Headers @{ Authorization = "Bearer $TOKEN" }
Write-Host "Status: $($m.data[0].status)"
Expected: status: "offline".
Pass: machine.marked_offline entry exists with correct machine UUID in resource_id; details.reason is heartbeat_timeout; user_id is null; machine status is offline in the database.
Fail / Common issues:
- Entry does not appear after 3 minutes — the cleanup cron may not have fired. The cron interval is configured in
cron.ts. Check the API container logs:docker logs quickztna-api-1 --tail 50 | grep cleanup. user_idis not null — this would be unexpected for a cron-driven event. ThelogAuditcall incleanup-machines.tspassesnullforuser_id.- Machine status still shows
online— the cleanup cron may be running on a longer interval. Wait an additional 2 minutes and re-check.
Summary
| Sub-test | What it proves | Key assertion |
|---|---|---|
| ST1 | Login creates auth.login entry with email and IP | details.email matches logged-in user; ip_address non-null |
| ST2 | ztna up on Linux-C creates machine.self_service_setup | resource_type: machine; details.tailnet_ip populated |
| ST3 | Auth key create/revoke produces paired audit entries | auth_key.created with name/expiry; auth_key.revoked with machines_quarantined |
| ST4 | ACL rule create/delete produces CRUD audit entries | acl_rules.created with rule UUID; acl_rules.deleted with filters array |
| ST5 | Tunnel down leads to machine.marked_offline from cron | details.reason: heartbeat_timeout; user_id is null; DB status is offline |