Skip to content

Audit log

If you’re a security lead investigating a BLOCKED install — or an org admin asking “who invited that contractor?” — this is the page you open. It lives at /admin/ui/orgs/<slug>/audit and lists every row Upwarden writes to audit_log for your org: install decisions emitted by the proxy, membership and role changes, policy override edits, OAuth and passkey logins, domain-claim verifications, and CSV exports of the audit log itself.

The page is server-rendered. Filters submit as a GET form, so the URL is shareable — paste it into a ticket and your coworker lands on the same view. Pagination is cursor-based via ?cursor=<ISO>, so paging through a hot table doesn’t grind.

A tier pill at the top names your tier (free, team, org, enterprise) and the window your tier permits — 7 days, 30 days, 1 year, or unbounded. If your tier doesn’t include CSV export, an inline banner reads: “Upgrade to team for 30-day retention, or org to export the audit log as CSV.” The banner disappears once you’re on Org or Enterprise.

Below that, a filter card. Below the filter card, the rows table. Below the table, a “Load more” link if there’s another cursor page.

Every filter is a GET form field — the form submits to the same URL with the values in the query string. The filters are:

  • Since / Until (datetime-local inputs). Browsers visually clamp Since at the tier-allowed min so you can’t pick a date that would 400 the request. Server-side, the same check runs and is load-bearing — a direct URL hit with a too-old since returns 400 since predates the tier's allowed audit window and a JSON body carrying {tier, maxWindowDays}.
  • Event (dropdown of known envelope events: member_invited, role_changed, policy_override_loosened, passkey_registered, audit_exported, and the rest of the KNOWN_AUDIT_EVENTS list). This is a substring match against the "event":"<name>" token inside the row’s decisionReason JSON.
  • Actor (free-text). Substring match against the envelope’s by_user_id field — paste either a full principal id or a fragment like stytch: to narrow.
  • Subject (free-text). Substring match against the row’s package_name column. Envelope rows use the package_name slot to carry the subject id — member:<id>, policy:<slug>, blocklist:<slug>, audit:<slug>. Scan-decision rows carry the actual package name.
  • Decision (SAFE / BLOCKED / QUARANTINED / Any).
  • Exclude package decisions (checkbox). Hides every row with a non-null client_family — i.e. all the proxy-traffic rows. Tick this when you’re asking “who edited which policy?” and don’t want the table buried in install decisions.
  • Actor identity, CI repository, CI ref, CI event name — multi-select dropdowns populated from the distinct values your org has actually emitted. Empty dropdowns render as a disabled “no data” summary; you’ll see them fill in once a row with that column shows up.
  • Team — multi-select. Renders only when your org has at least one team. The no-team sentinel matches rows whose actor doesn’t bridge to any team via the member → team chain.
  • CI PR number — free-text, numeric. Must be a non-negative 32-bit integer; 42abc or 1e5 both reject with ci_pr_number must be a non-negative integer.

A single Search button submits the form. A “Clear” link next to it strips every filter.

Seven columns: When, Event, Subject, Actor, CI, Outcome, and a detail toggle. 50 rows per page.

The Actor cell does two things. Display: it resolves stytch:member-live-… strings to the bridged member email (via the tenant_members join) so you don’t read raw Stytch IDs in the common case. Click: it pivots the page to filter on the row’s raw actor_identity value — the JSON API filters on the column, not the resolved email, so the pivot lands on the row’s actual data. Team chips render next to the actor name when the actor bridges to one or more teams.

The CI cell is a <details> chip. Closed, it shows repository · ref · PR #N. Opened, it lists the row’s repo, ref, PR number, event name, commit (truncated to 12 chars), workflow, and run ID. Each value is a click-to-pivot link that filters the page to that value. Rows with no CI columns set show nothing in this cell — graceful degrade, no empty box.

The Outcome pill renders the row’s verdict: SAFE, BLOCKED, or QUARANTINED.

Hitting Details on a row expands a panel that pretty-prints the envelope.

For known events, the panel renders labelled rows for each envelope field (member_invited shows email, role, invite_id; role_changed shows target_member_id, previous role, new role; policy_override_loosened shows policy_name, default_value, override_value, acknowledgement_text; and so on). Below that, a meta block always carries the row’s actor_tenant_id, client_ip, verdict_source, actor_identity, actor_teams, and any populated CI fields. If the envelope carries fields the dashboard doesn’t yet know about, they fall into a collapsible Extra fields section as raw JSON — so a pod parsing an envelope shape from a newer engine still surfaces the wire data.

For unknown events (free-text reasons that predate the envelope contract, or a brand-new event name the dashboard hasn’t been updated to label yet), the panel falls back to a JSON pretty-print of the full envelope, with the meta block above it.

If the row is BLOCKED and the verdict came from an OSV or GHSA blocklist match, the detail panel includes an Advisory subpanel showing the advisory source (osv, ghsa), the advisory ID, the severity, and the human-readable summary. The engine attaches this via the join to blocklist; the dashboard doesn’t re-derive, so what you see is the same advisory shape the 403 returned to the install request. Cooldown blocks, scanner-driven blocks, and manual operator entries don’t carry an advisory and don’t render this subpanel.

If the row is a BLOCKED install for a known transitive of a vulnerable build tool, the detail panel also includes a Fingerprint subpanel showing the tool name, the affected version range, the remediation text the engine attached to the 403, and any cross-referenced advisory IDs. Same data the install request received — so you can hand it to a developer asking “what do I do about this?” without rebuilding it.

If your tier includes audit:export (Org or Enterprise), a second form below the filter card carries every active filter as hidden inputs and POSTs to /admin/ui/orgs/<slug>/audit/export.csv. The download mirrors what’s on screen — same filter narrowing, same window.

If you blow past the tier window on the export form (e.g. picked a since outside the cap), the export bounces back to the audit page with ?flash=window_exceeded rather than returning a partial CSV. Re-pick a valid window and re-submit.

On Free or Team tiers, the form doesn’t render. The upgrade banner names the threshold.

If you script the JSON API directly and pick a since that predates your tier’s cap, you’ll see:

{ "error": "since predates the tier's allowed audit window", "tier": "team", "maxWindowDays": 30 }

The dashboard’s date picker’s min attribute clamps since to the same boundary, but the server is the load-bearing check — a hand-crafted URL doesn’t escape it.

When you set ?actor_identity=, the page renders a status banner above the table — “Showing audit for ” — and, when a row in the result set matches, the team chips for that actor. If the result is empty (you filtered on someone with no recent audit rows), the banner still renders so you can see what you filtered on without an extra query.

  • A developer reports [UPWARDEN] BLOCKED — filter by subject (package name) and decision=BLOCKED, open the detail panel for the row, read the advisory / fingerprint subpanels and the verdict source.
  • A new admin asks “did Alice accept her invite?” — filter event=member_accepted, search actor for Alice’s email.
  • An incident review needs every policy override change in the last 30 days — event=policy_override_loosened, set the Since to 30 days ago, export to CSV (Org+).
  • A CI pipeline is producing surprising decisions — paste the repo into CI repository, narrow with CI ref or CI event name, click any row’s CI chip to pivot further.
  • Querying the audit log — JSON-API query patterns that mirror this dashboard surface, including the decisionReason envelope vocabulary.
  • Verdicts & the scanner — the model behind the SAFE / BLOCKED / QUARANTINED outcomes the audit log records.
  • Troubleshooting — when an audit row points at a misconfiguration rather than a deliberate block.