feat(console): SOC2 audit log + account activity alerts#1288
Conversation
- Add Audit Log settings page (owner-only) with filter + cursor pagination - Log auth events (NextAuth login/logout, Firebase login/logout, OIDC login/logout) - Log membership events (invite, accept, role-change, remove) - New "account" event type in notification channels - Immediate email dispatcher for security-severity events (no cron) - Extend AuditLog with severity column + (workspaceId, timestamp) index
Security: - Stop persisting raw config object versions (prevVersion/newVersion) to AuditLog.changes — these can contain API keys, passwords and OAuth tokens. Only objectType + objectName are stored now. - API allow-lists safe fields in the response, so any pre-existing rows with raw config payloads are also redacted on read. UX: - Audit log table is full-width - Time column shows relative time (with absolute UTC tooltip) - Drop the auth-method chip from the actor cell - Drop the expandable raw-changes JSON row - Render config-object events as 'Updated <type> <name>' with a link to the entity edit page (no link for delete)
There was a problem hiding this comment.
Found two correctness issues that should be fixed before merge.
- Auth login/logout events are saved without workspaceId, so they cannot appear in the new workspace-scoped audit log view.
- Canceling a pending invitation currently emits member-removed audit events (and account alerts), which creates false security notifications for actions that did not remove a member.
Reuse the per-type outputFilter (the same one used by the config editor) to mask sensitive fields, so prevVersion / newVersion can stay in the audit log: - destinations: secrets at credentialsUi paths replaced with MASKED_SECRET - services: airbyte_secret-marked fields masked - streams: privateKey/publicKey plaintext + hash stripped - types without a registered outputFilter (link, profilebuilder, function code): fall back to a generic name-based scrubber that masks fields whose key matches password / secret / token / apiKey / etc. Rows written through this helper are tagged `_redacted: true`. The read API only exposes prevVersion / newVersion when that flag is present, so any rows already in the DB from before this fix continue to be redacted on read (objectType + objectName only). UI: expandable row shows the masked Before / After diff side-by-side.
There was a problem hiding this comment.
I found three correctness issues that will cause missing or misleading audit signals: (1) auth events are written without workspace scope and won’t appear in workspace audit log, (2) workspace audit helper is never wired to workspace update/delete paths, so workspace events/alerts are never emitted, and (3) member removal logs are emitted even for no-op deletes. I left inline comments with concrete fixes.
|
Review findings (could not attach inline due GitHub API thread errors in this environment):
|
audit-log.ts imported config-objects.ts at module load, which transitively pulls pages/api/[workspaceId]/domain-check.ts -> lib/api.ts -> nextauth.config -> back to audit-log.ts. The cycle surfaced as 'Cannot access httpMethods before initialization' on the first /api request. Lazy-require config-objects on first call, and inline the MASKED_SECRET constant so we don't need to pull lib/schema/destinations at load time.
There was a problem hiding this comment.
Found two correctness regressions that should be fixed before merge:\n\n1. events are written without , so they never appear in the new workspace-scoped audit log API/UI.\n2. The member-remove endpoint now emits audit/security alerts even when nothing was actually removed.\n\nPlease address these and I can re-review quickly.
There was a problem hiding this comment.
Clarification of requested fixes: (1) auth login/logout rows are currently inserted without workspace scope, so they do not appear in the workspace audit-log API/UI, and (2) member-removal audit/alerts are emitted even for no-op delete requests. Inline comments include exact locations and suggested guard conditions.
- API computes a flat diff [{ field, description }] from the masked
prevVersion/newVersion server-side and returns it as `diff`. The raw
prev/next blobs are no longer sent to the client. Masked secrets are
surfaced as 'changed (secret value)' so the sentinel doesn't leak.
- API enriches connection (link) entries with a synthesized
'from.name → to.name' display name (links have no name field of their own).
- UI Time column shows absolute UTC + relative ('ago') stacked.
- Entity name is the visible label; the underlying ID lives in a tooltip.
- Expandable row renders the diff as a small Field / Change table.
There was a problem hiding this comment.
I found a few correctness issues that should be fixed before merge:
-
webapps/console/pages/api/[workspaceId]/audit-log.ts:61
flattenDiffreturns early onshallowEqual(prev, next), so when both sides areMASKED_SECRETit returns no diff entry. This hides secret rotations instead of showingchanged (secret value). Handle masked-secret cases before the early return. -
webapps/console/pages/api/workspace/[workspaceIdOrSlug]/users/index.ts:153
DELETElogsmember-removedunconditionally even when bothemailanduserIdare omitted (both params are optional). In that case no deletion happens, but a security audit event and account alert are emitted. Validate that one target is provided and only log when a row is actually removed. -
webapps/console/pages/api/workspace/[workspaceIdOrSlug]/users/[userId]/role.ts:61
No-op role updates (prevRole === body.role) still emitmember-role-changedand send account alerts, creating false-positive security notifications. Short-circuit or skip audit/alerts for no-op updates.
- API derives objectName from prevVersion/newVersion server-side when the stored 'objectName' is empty. Pre-fix rows still showed entity IDs because they don't have the dedicated field — this fallback fixes that without exposing the raw versions to the client. - Expand toggle uses an explicit +/- icon (lucide Plus/Minus) so the expandable rows are obvious. Rows without a diff render a fixed-width spacer so columns stay aligned.
- Drop the custom +/- expand icon. Use Antd's default chevron — it's the same widget shown elsewhere in the app and avoids a fragile manual layout. - Compute the diff for non-redacted rows too. Re-run a name-based scrubber on the way out so credentials in legacy rows never reach the client. Without this, only post-fix rows showed an expand handle.
|
Review findings:
|
- API strips id, workspaceId, type, cloneId from prev/new before diffing. These are identity / metadata fields that the write path strips before saving (legacy audit-log payloads sometimes captured them inline, which surfaced as confusing 'id removed' rows). - Replace the nested Antd Table in the expandable row with a plain two-column grid — same field/change information, no header bar, no row borders or stripes. Field name in monospace, description plain text.
There was a problem hiding this comment.
Found two correctness gaps that block the intended account-alert behavior:
-
webapps/console/lib/server/account-alerts.ts:63
dispatchAccountAlertonly delivers tochannel === "email"rows with explicitemails. In this PR, notification channels configured from the workspace Settings UI are still forced tochannel: "slack"(webapps/console/pages/[workspaceId]/settings/notifications.tsx:146-148) while adding the newaccountevent option there. Result: users can select “Account Activity” in Slack notifications, but no alert is actually delivered. -
webapps/console/lib/server/audit-log.ts:217
workspaceAuditLogis introduced (includingworkspace-deletedalert dispatch), but it is not wired into workspace update/delete mutation handlers. As a result,workspace-updated/workspace-deletedaudit events and workspace-deletion account alerts are never emitted.
Please wire these flows before merge so the new audit/alert UI doesn’t advertise behavior that currently never triggers.
|
Review findings:
|
- New components/AuditLogDiff/AuditLogDiff.tsx — self-contained, full-width,
responsive (stacks field/change on narrow widths). Card with header
('Changes' + N fields summary), thin row separators, no per-cell borders.
- Expanded row indents the card to align with the Time column (past Antd's
expand-chevron gutter), with a subtle neutral-50 backdrop.
There was a problem hiding this comment.
I found a few correctness/security issues that should be fixed before merge (details inline). The main ones are: legacy audit rows can still leak sensitive values through diff, auth events are currently not visible in the workspace audit log because they are stored with workspaceId = null, and workspace audit events/alerts are defined but not wired to update/delete handlers.
Recurse into plain objects even when one side is undefined, so creating an
entity produces:
data.mode added: "batch"
data.batchSize added: 10000
data.dataLayout added: "segment-single-table"
…
instead of one '(root) → added: {…}' blob. Same for deletes — every leaf is
listed as 'removed (was …)'. Arrays stay atomic, so 'foo.bar → added: []'.
- Add ssh_key, ssl_key, signing_key, encryption_key to the redaction patterns on both the write side (lib/server/audit-log.ts) and the read-side scrubber (pages/api/[workspaceId]/audit-log.ts). credentials.tunnel_method.ssh_key on Airbyte connectors is now masked even on legacy rows that didn't go through outputFilter at write time. - Server: relax the per-value cap from 80 to 2000 chars so the client gets the full string to display. - AuditLogDiff: middle-truncate long descriptions to ~100 chars and show the full text in an Antd Tooltip on hover. Truncated values get a 'help' cursor as a hover affordance.
There was a problem hiding this comment.
Thanks for pushing this through. I found two correctness issues that should be fixed before merge:
- Auth login/logout events are written without
workspaceId, but the new read API only queries by workspace. Those events will not be visible in the workspace audit log UI. - Secret-field diff rendering has an unreachable branch, so updates where both sides are masked get dropped from the diff instead of surfacing as a secret change.
There was a problem hiding this comment.
Thanks for shipping this. I found a few blocking correctness issues that prevent key parts of the feature from working as described:
- Auth login/logout rows are written with
workspaceId = null, so they never appear in the workspace-scoped audit log API/UI. - Account-activity alerts currently filter to persisted
notificationconfig rows withchannel === "email", but the product flow only creates Slack notification rows; this leaves no real subscribers for the new account alerts. workspace-deleted/workspace-updatedtypes are exposed in the UI, but no route currently callsworkspaceAuditLog(), so those events are never produced.
Please address these before merge so the SOC2-facing behavior is actually reachable end-to-end.
- Revert the regex extension (ssh_key etc). Use the per-type outputFilter on
the read side instead — same masking the editor UI does. Pulls
config-objects via lazy require so the leaf API route doesn't reintroduce
the api.ts cycle. credentials.tunnel_method.ssh_key on Airbyte sources is
now masked via airbyte_secret on legacy rows.
- API returns structured diff entries: { field, kind, prev?, next? } where
kind is added | removed | changed | secret-changed | noop.
- AuditLogDiff renders each kind with an icon (+, −, →, key, no-change).
changed rows show prev → next, both middle-truncated with full-text tooltip.
- For 'config-object-update' rows whose prev and next end up byte-identical
(the user clicked Save without editing — produces a real audit row but
empty diff), show a single 'no field-level changes' noop entry so the row
is still expandable and the situation is explicit.
There was a problem hiding this comment.
Found two correctness regressions that should be addressed before merge.
- Auth login/logout events are written without
workspaceId, but the new audit-log API is workspace-scoped (where: { workspaceId }). As implemented, the UI exposes Login/Logout filters but those events never appear in workspace audit logs. - The workspace users DELETE endpoint now always emits a
member-removedaudit entry (and account-alert side effects), including invitation cancellation flows and even requests with neitheremailnoruserId, which can generate misleading security alerts.
…oading
- Replace ALWAYS_SAFE_FIELDS allow-list with a small deny-list:
prevVersion, newVersion (rendered server-side via 'diff' instead) and the
internal _redacted marker. Everything else our audit-log helpers write is
safe summary metadata; we shouldn't have to enumerate it here.
- Add isMasked() that recognizes both the local display sentinel
('*********') and the canonical sentinel ('__MASKED_BY_JITSU__') emitted
by lib/schema/secrets#maskSecrets via outputFilter. Without this the
secret-changed kind never fires for legacy rows masked through the
per-type filter.
- UI: while a Load-more fetch is in flight, show a disabled loading
button. The previous logic briefly fell through to 'End of log' because
query.data is undefined during the in-flight request.
There was a problem hiding this comment.
Thanks for the implementation. I found two correctness issues that block key parts of the feature set:
auth-*events are persisted withoutworkspaceIdin all new call sites, while the new read API hard-filters byworkspaceId; as a result, login/logout events won’t appear in the workspace audit log.workspaceAuditLog()is introduced but not wired into workspace update/delete flows, soworkspace-updated/workspace-deletedrows (and theworkspace-deletedaccount alert email path) are never emitted.
Please address these before merge.
The audit-log API never sent prev/new on the wire, but the previous code expressed that as 'pass r.changes through, strip prev/new on the way out', which inverted the intent. Switch to an explicit allow-list of summary fields (objectType, objectName, actorEmail, …) — the raw config blobs simply aren't enumerated, so they can't accidentally leak. The diff is the only place the per-field changes show up.
There was a problem hiding this comment.
Thanks for the substantial audit-log work. I found a few correctness issues that should be addressed before merge:
member-removedaudit events (and account-alert emails) can be emitted even when no membership/invitation was actually removed.- Role updates emit
member-role-changedeven when the requested role equals the current role. - Auth login/logout rows are written without
workspaceId, but the new audit-log API is workspace-scoped, so those auth events are not retrievable from the new UI/API.
- Move /api/[workspaceId]/audit-log -> /api/audit-log with workspaceId as
an optional query param. With workspaceId: workspace-scoped, requires
manageUsers in that workspace. Without: cross-workspace, requires admin
(verifyAdmin). API also bulk-fetches workspace name+slug per row and
returns it as 'workspace' so the admin view can render a workspace
column.
- Extract the table into components/AuditLog/AuditLog.tsx. When workspaceId
is undefined the component runs in admin mode: adds a Workspace column
with name + link to /<slug-or-id>, and uses each row's per-workspace
slug to build entity edit links.
- /admin/audit-log: new page that renders <AuditLog /> in admin mode.
- /[workspaceId]/settings/audit-log: now a thin wrapper around <AuditLog
workspaceId={...} workspaceSlug={...} />.
- Add 'Admin Audit Log' entry next to 'Admin Workspaces' in the workspace
selector menu (admin users only).
/api/fb-auth/create-session is called every time Firebase mints or refreshes a session cookie — every short-lived ID-token rotation, every reload after a long idle — not just on actual sign-in. That flooded the audit log with dozens of 'Logged in' rows per user per day. Suppress duplicate auth-login rows for the same (userId, authType) inside a 30-minute window. Logouts still fire on every signout — they are explicit user actions, not implicit refreshes.
The previous in-memory throttle didn't work across replicas. Switch to a DB-backed check keyed on the auth event's actual timestamp: - authAuditLog gains an opts.authTime parameter. When provided, it queries for any auth-login row for this user with timestamp >= authTime; if found, it skips the write. Firebase rotates ID tokens on a schedule but auth_time only changes on actual re-authentication, so refresh calls collapse onto the original row instead of creating duplicates. - create-session decodes the ID token (already does, for the user identity), pulls auth_time, and passes it through. - NextAuth and OIDC don't need this — their callbacks only fire on actual auth flows, not on token refreshes. - Drop the in-memory map and the per-(userId, authType) 30-min throttle.
Drop the dedup logic entirely. Replace it with explicit instrumentation on the two client entry points users actually hit: - New endpoint /api/fb-auth/audit-login that takes an idToken in the body, verifies it via Firebase Admin, and writes the auth-login row. - firebase-client signIn (email/password) and signInWith (popup OAuth) both call audit-login right after Firebase reports success. - create-session no longer logs — it's the cookie-mint endpoint and runs on every ID-token rotation, which is what was flooding the audit log. - authAuditLog reverts to the simple (user, op, authType, workspaceId?) signature; no opts, no auth_time, no DB lookup. The signOut path was already correct (revoke-session is only hit at sign-out), so logout needs no changes. Net: one audit row per actual user-driven sign-in, zero per token refresh / session re-establish.
- show auth events in workspace-scoped audit-log via member-id OR clause - gate member-removed audit on confirmed deletion + reject empty target - skip role-changed audit when role is unchanged - wire workspace-deleted (DELETE) and workspace-updated (PUT) audit rows - account-alerts: also fan out to workspace members with notifications.account on - detect secret rotations at write time (_rotatedSecrets) and surface them as secret-changed on read - prettier reformat across the 9 files CI flagged
There was a problem hiding this comment.
I found one correctness regression that should be fixed before merge.
workspace-deleted alerts currently skip member recipients because the recipient query filters out deleted workspaces. Since the workspace is marked deleted before this alert is dispatched, the members list comes back empty in the common case, and no email is sent unless an explicit notification channel exists.
Please adjust recipient selection for workspace-deleted events so workspace members can still receive this alert.
…ed alert The recipient query had `w.deleted = false`, which silently dropped all member recipients exactly for workspace-deleted events (the workspace is flagged deleted before the alert dispatches).
There was a problem hiding this comment.
Found one blocking issue in the new audit-log API pagination logic.
The first workspace-scoped page is filtered correctly, but the cursor branch overwrites the existing workspace OR-clause. That causes subsequent pages to drop workspace scoping and potentially return audit rows from other workspaces. This is a data exposure bug, so this should be fixed before merge.
There was a problem hiding this comment.
Reviewed the full diff in this PR (audit log API/UI, auth/membership/workspace event emitters, and account-alert delivery wiring).
I did not find actionable bugs, security issues, or correctness regressions in the submitted changes. The access controls on the new audit API routes are in place, secret redaction paths are handled defensively, and event emission is best-effort without breaking user flows.
Summary
Implements the SOC2 audit log + Account Activity Alerts task needed to satisfy two failing Vanta tests:
customer-facing-logs— customers can now view a workspace-scoped activity log underSettings > Audit Log(owner-only).customer-account-activity-alerts— customers can subscribe to security events via the existing notification channels (newaccountevent type).What it does
AuditLogwith aseveritycolumn (info/warning/security) and a composite(workspaceId, timestamp)index.authAuditLog()andmembershipAuditLog()inlib/server/audit-log.ts, gated on the existingCONSOLE_ENABLE_AUDIT_LOGenv var.jwtcallback (login) + newevents.signOut; Firebase create-session and revoke-session; OIDC callback (login) and logout./api/user/accept), role change (users/[userId]/role).GET /api/[workspaceId]/audit-logwith cursor pagination + filters (type, severity, from, to). Owner-only viaverifyAccessWithRole(user, ws, "manageUsers")./[workspaceId]/settings/audit-logwith filterable, expandable Antd table."account"value inNotificationChannel.events+ label entry.lib/server/account-alerts.ts(fire-and-forget after the DB write — no cron round-trip). Fires on allsecurityseverity events.emails/account-alert.tsxfor the alert mail.Severity / dispatch matrix
auth-login,auth-logoutmember-{invited,joined,removed,role-changed}workspace-deletedconfig-object-*Test plan
CONSOLE_ENABLE_AUDIT_LOG=true; runpnpm console:dev.pnpm db:update-schema(addsseverity+ composite index).AuditLogwithtype=auth-login,authType=firebase. Logout →auth-logout.authType=nextauth-github.authType=oidc.member-invited. Accept the invite →member-joined. Change their role →member-role-changed. Remove them →member-removed. Verify all fourseverity=securityrows.Settings > Audit Logas an owner → events visible, paginated, filterable.accountevent type, trigger a member-invite from another browser → email delivered.Notes
prisma/schema/is gitignored (auto-generated by zod-prisma);prisma generateregenerates it.prisma db push, not migrations — no migration SQL file checked in.webapps/consolepackage has no existing Jest infrastructure. Verification is manual + typecheck.🤖 Generated with Claude Code