Olympus Docs
CookbookTools

Admin audit log viewer page

Search and filter security events

A page in Athena (or your own admin tool) for browsing the audit log. Search by user, event type, date, IP.

Schema

-- security_audit table (created by Athena init)
CREATE TABLE security_audit (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  identity_id UUID,          -- target user
  actor_id UUID,             -- who took the action (could be same as identity)
  actor_type TEXT,           -- "self" | "admin" | "system"
  event_type TEXT NOT NULL,  -- "login" | "password_changed" | "mfa_enrolled" ...
  outcome TEXT,              -- "success" | "failure"
  source_ip INET,
  user_agent TEXT,
  metadata JSONB,            -- event-specific
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX ix_audit_identity ON security_audit(identity_id, created_at DESC);
CREATE INDEX ix_audit_event ON security_audit(event_type, created_at DESC);
CREATE INDEX ix_audit_created ON security_audit(created_at DESC);

Page structure

// athena/app/admin/audit/page.tsx
export default async function Audit({ searchParams }) {
  const filters = parseFilters(searchParams);
  const events = await queryAudit(filters);
  return (
    <div>
      <FilterBar filters={filters} />
      <AuditTable events={events} />
      <Pagination filters={filters} />
    </div>
  );
}

Filters

function FilterBar({ filters }) {
  return (
    <form>
      <Select name="event_type" options={EVENT_TYPES} value={filters.event_type} />
      <Input name="identity_email" value={filters.identity_email} placeholder="user email" />
      <Input name="source_ip" value={filters.source_ip} placeholder="IP" />
      <DateRange name="created_at" value={filters.created_at} />
      <Select name="outcome" options={["", "success", "failure"]} />
      <button>Apply</button>
    </form>
  );
}

Query

async function queryAudit(filters) {
  let q = sql`SELECT a.*, i.traits->>'email' as user_email FROM security_audit a LEFT JOIN identities i ON a.identity_id = i.id WHERE 1=1`;
  
  if (filters.event_type) q = sql`${q} AND a.event_type = ${filters.event_type}`;
  if (filters.identity_email) q = sql`${q} AND i.traits->>'email' ILIKE ${`%${filters.identity_email}%`}`;
  if (filters.source_ip) q = sql`${q} AND a.source_ip = ${filters.source_ip}`;
  if (filters.outcome) q = sql`${q} AND a.outcome = ${filters.outcome}`;
  if (filters.created_at) {
    q = sql`${q} AND a.created_at BETWEEN ${filters.created_at.from} AND ${filters.created_at.to}`;
  }
  
  q = sql`${q} ORDER BY a.created_at DESC LIMIT ${filters.per_page} OFFSET ${(filters.page - 1) * filters.per_page}`;
  
  return await db.unsafe(q);
}

Display

function AuditTable({ events }) {
  return (
    <table>
      <thead>
        <tr>
          <th>Time</th>
          <th>Event</th>
          <th>User</th>
          <th>Actor</th>
          <th>IP</th>
          <th>Outcome</th>
          <th>Details</th>
        </tr>
      </thead>
      <tbody>
        {events.map(e => (
          <tr key={e.id} className={e.outcome === "failure" ? "bg-red-50" : ""}>
            <td>{formatTime(e.created_at)}</td>
            <td>{e.event_type}</td>
            <td><Link href={`/identities/${e.identity_id}`}>{e.user_email}</Link></td>
            <td>{e.actor_type === "admin" ? <span className="badge">admin</span> : "self"}</td>
            <td>{e.source_ip} <Geo ip={e.source_ip} /></td>
            <td>{e.outcome}</td>
            <td><pre>{JSON.stringify(e.metadata, null, 2)}</pre></td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

Event-type catalog

Pre-define event types so filters are useful:

const EVENT_TYPES = [
  // Auth flow
  "login_succeeded", "login_failed",
  "mfa_challenge_succeeded", "mfa_challenge_failed",
  "registration_completed", "verification_completed",
  
  // Settings
  "password_changed", "email_changed",
  "mfa_enrolled", "mfa_removed",
  
  // Admin actions
  "admin_session_revoked", "admin_password_reset", "admin_role_changed",
  "admin_impersonation_started", "admin_impersonation_ended",
  "admin_account_locked", "admin_account_deleted",
  
  // OAuth2
  "oauth_consent_granted", "oauth_consent_revoked",
  "oauth_token_issued", "oauth_token_revoked",
  
  // Security
  "suspicious_login_blocked", "brute_force_detected",
  "ip_blocked", "ip_unblocked",
];

Display friendly names:

const labels = {
  "login_succeeded": "Successful login",
  "login_failed": "Failed login",
  // ...
};

Exporting

For investigations / DSR:

<Button onClick={exportFiltered}>Export CSV</Button>
async function exportFiltered() {
  const events = await queryAudit(filters, { all: true });
  const csv = toCsv(events);
  download(csv, "audit-export.csv");
}

Limit to 10,000 rows to avoid memory issues. For larger: paginated download.

Saved queries

Useful named queries:

<Link href="/audit?event_type=login_failed&period=24h">
  Recent failed logins
</Link>
<Link href="/audit?event_type=password_changed&period=7d">
  Password changes this week
</Link>
<Link href="/audit?event_type=admin_role_changed">
  Role changes
</Link>

Power users come back to the same searches.

Real-time updates

For incident response, refresh every 30s:

useEffect(() => {
  const t = setInterval(() => router.refresh(), 30_000);
  return () => clearInterval(t);
}, []);

Or use SSE / WebSockets for true real-time.

A "session" of events should be visible:

User logs in (event 1), browses (events 2-N), changes password (event X), logs out (event Y).

Display chronologically as a story:

function UserTimeline({ userId, dateRange }) {
  const events = useQuery(...);
  return (
    <Timeline>
      {events.map(e => <TimelineEvent key={e.id} event={e} />)}
    </Timeline>
  );
}

Access control

Only admin / security roles can view. Critical:

// middleware
if (!user.roles.includes("audit:read")) {
  return Response.redirect("/forbidden");
}

Audit who viewed the audit log:

INSERT INTO security_audit (event_type, actor_id, metadata)
VALUES ('audit_log_viewed', $admin_id, $filters);

Yes, audit your audit viewer. Layers.

On this page