Skip to content

enhancement(audit): comprehensive audit logging with inline activity views#466

Merged
therealbrad merged 40 commits into
mainfrom
enhancement/project-audit-log
Jun 23, 2026
Merged

enhancement(audit): comprehensive audit logging with inline activity views#466
therealbrad merged 40 commits into
mainfrom
enhancement/project-audit-log

Conversation

@therealbrad

@therealbrad therealbrad commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Description

Replaces the previous partial, application-layer audit logging with a comprehensive database-level change-data-capture (CDC) pipeline, and surfaces that audit trail directly in the product UI.

Comprehensive capture. Postgres triggers on an allowlist of tables record every data change — including the child/value tables, bulk operations, and worker/raw-client paths the old app-layer hooks structurally missed — into an append-only log. A background worker correlates those raw changes into readable audit entries: it groups all the writes of a single save into one action, resolves foreign-key ids to human-readable names, and snapshots the actor/entity/project at write time so entries are immutable. Semantic events (logins, exports, role/SSO/SCIM changes) stay app-layer, and the legacy per-entity app-layer data hooks are retired now that the triggers cover them (reconciled by parallel-run, so there is no coverage regression).

Inline activity views. The audit trail is now surfaced where people work:

  • An Activity view on individual test cases, test runs, and sessions — a slide-out, filterable history of every change to that item (who, when, and before/after values), including a readable "N test cases added / removed: <names>" summary for run case-list changes.
  • A per-project audit log view in the project menu for project administrators.

Together with the per-user profile Audit Log shipped in v0.38.4, this completes the inline activity-log surfacing requested in #362. (The user-info-field portion of that issue is covered by SCIM 2.0 user provisioning; any further fields would be a separate request.)

Related Issue

Closes #362

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Refactoring (no functional changes)
  • Performance improvement

How Has This Been Tested?

  • Unit tests
  • Integration tests
  • E2E tests
  • Manual testing

Unit tests cover the correlation pipeline (grouping, owner rollup, humanization, the add/remove summaries, secret redaction, multi-tenant polling) and the trigger registry; E2E specs cover the per-entity Activity views and the project audit log; and the change was UAT'd end-to-end against a production build (per-entity history, actor attribution, add/remove summaries). The full suite (9,740 tests) passes.

Test Configuration:

  • OS: macOS
  • Browser (if applicable): Chromium (Playwright)
  • Node version: 24.x

Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published
  • I have signed the CLA

Screenshots (if applicable)

Additional Notes

  • The audit UI keeps its existing shape — the inline views reuse the audit-log viewer and detail components, scoped per entity or per project.
  • New user-facing strings are added to en-US.json only (*.auditLog); Crowdin syncs the other locales.
  • The CDC pipeline is multi-tenant aware, and credential/secret columns are denylisted from capture (with nested-JSON redaction) so they never reach the change log.

- New project-scoped Audit Logs page (project menu item) for system
  admins and for Project Administrators on their assigned projects,
  backed by a PROJECTADMIN read policy on AuditLog
- Add Project filter and Project-column sorting to the admin and
  profile audit log views
- Default both the admin and project views to no date window; the
  infinite scroll + virtualized table handle the full range
- Add searchProjectAuditLogUsers server action (raw SQL, restricted to
  ADMIN or an assigned PROJECTADMIN) for the project user filter, with
  unit tests covering the authorization boundary
- Document the project audit log under Projects and link it from the
  permissions guide
Add a per-case Activity sheet to the test-case detail page plus the audit plumbing behind it:

- RepositoryCaseAuditLogSheet: lazy right-side sheet showing the case's audit trail, gated by a new AuditLog read policy so anyone who can view the case can read its audit entries (mirrors the RepositoryCases read rules, scoped to entityType='RepositoryCases').
- Audit CaseFieldValues mutations per-row via ENTITY_AUDIT_MODELS, with project scope backfilled through the parent test case.
- Consolidated 'case content changed' entry: after a save, /api/audit/case-version diffs consecutive version snapshots (custom fields, steps, tags, issues, parameters) into one readable AuditLog row.
- Restrict audit metadata (IP / user agent) to admins in the detail modal.
- Version selector shows a compact 'v{n}' badge.

i18n: repository.auditLog (en-US).
Record every mutation to audited tables (cases, runs, sessions, their
child/value tables, and the tag/issue link tables) into an immutable,
append-only DataChangeLog via Postgres triggers — capturing writes from
every path (hooked client, raw SQL, workers, bulk operations) that the
app-layer audit hooks structurally miss.

- DataChangeLog model + a generic audit_row_change() trigger: changed-column
  {old,new} diffs, a per-table column denylist + 32KB size guard, a recursion
  guard, and a skip_audit hatch for high-volume imports
- Table registry + an idempotent apply-triggers script (via DIRECT_DATABASE_URL),
  chained into pnpm generate and the deploy entrypoints, plus a drift test
- Append-only enforcement at the database level (UPDATE/DELETE raise a privilege
  error) alongside an INSERT-only grant
- Actor / operation / tenant attribution via SET LOCAL app.audit_context in the
  hooked-client transactions and worker entry points

Runs alongside the existing app-layer audit; nothing consumes DataChangeLog yet.
… entries

Materialize the immutable DataChangeLog into the existing AuditLog so the
trigger-captured changes become a human-readable account of who changed what.

- A restart-safe worker loop polls DataChangeLog (FOR UPDATE SKIP LOCKED),
  rolls child-row changes up to their owning case/run/session, humanizes FK
  values (field and state names), and writes AuditLog rows — the insert and
  the cursor advance happen in one transaction so a crash never duplicates or
  drops a row
- Per-operation grouping: a client operationId (X-Operation-Id header via a
  provider-level fetcher) threads through the audit context onto every change
  row, so a multi-request save collapses into a single entry; the audit views
  group by it
- AuditLog gains operationId and sourceTable; an idempotency index guards
  re-materialization
- Soft-deletes (isDeleted flips) surface as deletions in the trail

Still runs alongside the existing app-layer audit (intentional double-capture
until reconciliation).
… hook/trigger reconciliation

- Extend the audit trigger registry from 23 to 68 tables, covering the
  remaining currently-audited data entities (users, projects, config
  catalogs, issues, milestones, comments, attachments, permissions and
  their child/value tables). Credential columns
  (Integration/LlmIntegration/CodeRepository), User.lastActiveAt, and
  rich-text columns are denylisted; credential/token tables stay excluded
  from triggers entirely.
- Add scripts/reconcile-audit-coverage.ts (pnpm reconcile:coverage): a
  runtime parity report that partitions AuditLog rows by sourceTable
  (app-hook-sourced vs trigger-sourced) and proves trigger capture is a
  superset of the hook capture, exiting non-zero on any gap.
- Add a credential-denylist integration test proving a write to a
  credential-bearing table never records the credentials column into the
  change log.
- Tag the existing raw-SQL and bulk-createMany capture assertions for the
  client/method-agnostic coverage requirement.
…f database triggers

The Postgres trigger substrate now captures every data-change audit event,
so the parallel app-layer hooks are removed:

- Remove the per-entity Prisma $extends data-audit hooks, the
  ENTITY_AUDIT_MODELS entries, and the DATA-entity RPC audit shim. Actor
  attribution (the SET LOCAL GUC), Elasticsearch sync, outbound webhooks,
  and all semantic/security events (login, export, role/permission changes,
  SSO, SCIM, API-key lifecycle) are preserved.
- Delete the interim per-case audit pieces (the CaseFieldValues hooks and
  the /api/audit/case-version endpoint); the per-case Activity view reads
  the materialized audit log directly.
- Apply the audit triggers in the E2E harness setup so the audit specs run
  against the trigger path.
- Add a 30-day retention worker for the change-log table, with a carve-out
  in the append-only enforcement that permits pruning processed rows while
  still forbidding deletion of unprocessed ones.
…x the prod deploy crash

- Move `pg` from devDependencies to dependencies. The production container's
  entrypoint runs the trigger-apply script, which requires `pg`; with it pruned
  from the production image the container crash-looped on every deploy.
- Attribute trigger-captured changes to the acting user on the model API route.
  ZenStack's enhance() client (and the fast-path create) bypass the Prisma
  extension that set the audit-context GUC, so the captured actor was empty.
  enhanceWithAudit runs each write inside a transaction that sets the GUC first
  and re-issues the write on the transaction client, so the policy check and the
  write share it and the trigger records who made the change. Reads pass through;
  nested writes don't re-wrap (preserving caller transaction atomicity).
- Record changes that have no originating user (background jobs, seeds,
  migrations) under the existing `__system__` actor sentinel instead of an empty
  actor, with a regression test.
The per-entity Prisma $extends hooks opened a transaction, set the
app.audit_context GUC on it, then ran the write via query(args) — which
executed OUTSIDE that transaction (autocommit). So the write and its
audit / Elasticsearch-sync / webhook side-effects were not atomic, and the
trigger-captured DataChangeLog row recorded no actor (the GUC was read from
a transaction the write never ran in).

Re-issue each hooked write on the transaction client (tx[model][op](args))
so the write, the GUC, and the side-effects share one transaction: the write
is now atomic with its side-effects, and trigger CDC records the acting user
whenever the request established an audit-context frame. Reads are unaffected;
tx is the un-extended base client, so this does not recurse.
…write hook

The per-hook GUC wrapper wrapped each hooked write in its own
baseClient.$transaction. Inside a route's own prisma.$transaction this split
the hooked parent write into a separate transaction: sibling child/value-table
writes (CaseFieldValues, Steps, iterations, ...) stayed in the route's
un-GUC'd outer tx and recorded a null actor (-> __system__), and the parent
committed independently of the route's transaction (atomicity loss + deadlock
risk under a small/pgbouncer pool).

Set app.audit_context once at the transaction boundary instead, and have the
hooks reuse that transaction:

- auditedTransaction(fn) and auditedEnhancedTransaction(session, fn) open the
  transaction, SET LOCAL app.audit_context as the first statement, and publish
  the tx on a shared AsyncLocalStorage (auditTxStore). The enhanced variant
  keeps ZenStack policy enforcement. Raw prismaBase paths (merge, step-sequence
  conversion) use withAuditGuc(prismaBase, buildGucPayload(userId)) to keep
  their alias/policy exemption.
- The 15 GUC-wrapped $extends hooks (withHookTx) now run on the ambient audited
  transaction when one exists -- single tx, full parent+child attribution,
  atomicity preserved -- and only open their own when the write is standalone.
- Convert the request-scoped transactional mutation paths that write audited
  tables to these helpers (bulk-edit, submit-result, edit-result, iterations,
  parameters, dataset import, merge, step-sequence conversion, milestones,
  reviews, project-integration, prompt-config, auto-tag, admin user create,
  generate-iterations, Jira import, ...), adding an audit frame where the
  handler lacked one.

SCIM provisioning and anonymous signup remain unattributed (recorded as
__system__): IdP/automation, not a user session.

Validated end-to-end on the prod stack: a bulk edit of a case's Priority now
writes the RepositoryCases and CaseFieldValues rows under one transaction id
with the editing user as the actor (previously the child row carried an empty
actor in a separate transaction).
…audit log is immutable

The trigger-based CDC migration recorded only ids — actor userId, table + pk —
and left the readable AuditLog's userName/userEmail/entityName/projectId empty,
expecting them to be resolved later. That broke the per-project audit view (it
filters on projectId, which was always null) and showed no entity/user names;
worse, resolving them at read time would show each value as it is NOW, not as it
was when the change happened, defeating the point of an immutable audit log.

Capture the human context AT WRITE TIME and copy it through verbatim — no lookup
at display or in the correlation worker:

- DataChangeLog gains actor_name, actor_email, entity_name, project_id. The
  audit_row_change() trigger snapshots actor name/email from the app.audit_context
  GUC and reads entity_name/project_id straight off the changed row's configured
  name/project columns (per-table nameCol/projectCol in scripts/trigger-registry.ts,
  passed as trigger args). Reading off the row is immutable (the value as it was at
  that instant) and handles bulk edits row-by-row with no per-route fan-out. A
  child/value/join table whose own row carries neither falls back to the GUC
  subject set by the few child-only operations (recording a result).
- buildGucPayload carries userName/userEmail (and an optional subject) into the
  GUC; the session callback already enriches the frame, enhanceWithAudit and
  auditedEnhancedTransaction pass the explicit user, and the model route stamps
  name/email on its frame before tryFastPathCreate runs (which otherwise saw none).
- The correlation worker copies the four fields straight into AuditLog; child rows
  inherit their owning entity's snapshot from the same operationId group.
- submit-result / edit-result set the run as the audit subject, and the result
  modals mint one operationId per "record result" action so the result and its
  step-result writes group together — the step rows then inherit the run's name
  and the whole action reads as one grouped audit entry.

Validated end-to-end on the prod stack: case create/edit/delete, sessions, and
result submissions all record the acting user's name, the entity's name as it was
then, and the project — every AuditLog row fully attributed, nothing looked up.
… with the acting admin

Webhook configuration changes (create/delete/rotate/activate) produced no audit
record at all: WebhookConfig was absent from the trigger registry and the
webhook-config server actions audit only one of their ~17 functions. The
IntegrationProject external-project mapping was likewise unaudited.

- Add WebhookConfig and IntegrationProject to scripts/trigger-registry.ts. The
  webhook token + secret columns are denylisted so credential material never
  lands in the append-only DataChangeLog (SAF-02/04); the dedicated
  WebhookConfigSecret table stays excluded. WebhookConfig carries name +
  projectId; IntegrationProject self-attributes by its externalProjectName
  (projectId is two hops away via projectIntegration).
- WebhookConfig is written through the hooked client but had no hook, so its
  writes never set the app.audit_context GUC and would record __system__. Add a
  minimal webhookConfig GUC hook (via the shared withHookTx) that only sets the
  context, and have the 11 webhook-config runWithAuditContext frames carry the
  acting user's name + email, so these changes attribute to the admin.

Validated: creating an outbound webhook now records CREATE WebhookConfig
"<name>" by the acting admin in the right project, with the token/secret absent
from the captured diff.
UAT review surfaced several readability problems in the materialized AuditLog.
This addresses them in the correlation worker and the table UI:

- Group rows with no browser operationId by their transaction id (a parent and
  the children written in the same transaction are one atomic save), so the
  children inherit the parent's name/project and the UI collapses them into one
  entry. The synthetic `tx:<txid>` operationId is stamped on those rows.
- Cancel no-op association churn: when a save re-applies an UNCHANGED m2m link it
  writes a join-table DELETE + CREATE of the same link in one operation; drop the
  matched pairs so a rename no longer reads as "removed tag / added tag". One-sided
  adds/removes are preserved. Cancelled rows are still marked processed.
- Comment attribution: a comment carries its creatorId (the actor) and exactly one
  parent FK (the case/run/session/milestone it is on) but no names and no GUC
  actor — so roll it up to that parent and resolve the creator + parent names
  (the one deliberate, comment-only lookup; createPrismaLookup gains those tables).
- Drop SessionVersions from the trigger registry — a version snapshot the app
  writes in its own transaction on every session save, producing a redundant
  no-name audit row (mirrors RepositoryCaseVersions, already excluded). apply-
  triggers now also drops orphaned tpl_audit_* triggers so a registry removal is
  clean and the drift self-check stays in lockstep.
- VirtualizedDataTable: nested sub-rows get a shaded background and a wide colored
  bar on the right edge of the indent column, so a run of detail rows reads as one
  group and the next parent row is clearly the boundary.
…ase logs

Value-only updates to a value table (CaseFieldValues / ResultFieldValues /
SessionFieldValues) only write `value`, so the captured diff lacked the field
identity and the rollup FK, and rolled up to the row's own pk instead of its
owning case/run/session.

- Trigger carries a per-table capture list (rollup FK + fieldId) on every
  UPDATE so value-only edits still attribute to their owner and name the field.
- Correlation re-keys value-table diffs under the field's display name with the
  resolved label ("Priority: Medium -> High") instead of an opaque "value: 3 -> 2".
- Cross-batch owner back-fill rolls name-carrying rows up to their owning entity
  so children written in a later poll batch (e.g. step results) still inherit
  the owner name rather than showing blank.
… and resolve access-control ids to names

The catalog/config/access-control models were audited by BOTH the app-layer
config hooks (AUDITED_CONFIG_MODELS) and the database CDC triggers, so every
such change produced a duplicate AuditLog row. The CDC trigger already captures
the same create/update/delete under the acting admin (the request GUC is set by
the route, not these hooks), so the app-layer config audit is retired here — the
app layer now records only semantic / security events.

Assignment/join tables (GroupAssignment, RolePermission, Project*Assignment, ...)
have no name column, so their CDC rows showed raw FK ids. The humanizer now
resolves userId / groupId / roleId / projectId / configurationId /
milestoneTypeId to names, so an access-control change reads
"user: Administrator Account, group: QA" instead of opaque ids.
…g detail modal

The correlation worker resolves FK ids to display names alongside the raw value
(oldName/newName for statusId, fieldId, and the access-control assignment FKs),
but the shared audit detail modal rendered only the raw old/new — so a group
membership change showed a user cuid instead of "Administrator Account", and a
status change showed an id instead of its name. Prefer the resolved name when
present, falling back to the raw value on a humanization miss.
…ntity or TestRuns

ResultFieldValues is a shared value table — a row belongs to a test-run result, a
session result, OR a case (testRunResultsId / sessionResultsId / testCaseId). The
rollup only knew the test-run FK, so a session result's field values fell back to
TestRuns. Correlation now branches by whichever owner FK is set, and the trigger
captures all three so a value-only update still attributes correctly.

SessionResults also materialized with a blank name: session results go through the
generic model route (not a bespoke endpoint), which set no audit subject. The route
now reads the session's name + projectId for SessionResults.create and sets them as
the GUC subject, so the result and its nested result-field values record the session
at write time — matching how submit-result names test-run results.
The CDC audit milestone landed without passing the repo-wide format:check; this runs prettier on the affected files. No behavior change.
Full-repo eslint flagged these imports as unused (the apparent uses were comments or a shadowing local parameter).
The CDC milestone added app.audit_context GUC injection inside DB transactions and decommissioned the app-layer config audit, but the test mocks/expectations were not updated (250 failures). Adds $executeRaw/$queryRaw to transaction mocks, $extends to enhanced-client mocks, missing mock exports, and updates the config-audit tests to the decommissioned (empty AUDITED_CONFIG_MODELS) design.

Test-only changes; no source modified.
Decommission the app-layer ROLE_CHANGED / PERMISSION_GRANT / PERMISSION_REVOKE events (gated by an empty SEMANTIC_ACCESS_AUDIT_MODELS set, mirroring AUDITED_CONFIG_MODELS) so an access change is recorded once by the CDC substrate instead of twice. The correlation layer derives a readable "User -> Project" label + projectId for the nameless permission/membership rows from the FK display names it already resolves.
Stamp operationId from the request audit context onto semantic-event rows so BULK_*/DUPLICATED/ITERATION/copy-move summaries group as the header of their CDC per-row detail rows instead of floating with a null operationId.
copyMoveWorker set a partial audit GUC (userId only), leaving copied rows with a blank actor name and an ungrouped operationId. Build the payload with buildGucPayload() (the processor runs inside runWithAuditContext) so the copied case + children carry the actor name + operationId and group with the semantic CREATE/DUPLICATED summary.
…ving every setting

The Project*Assignment join tables (workflow/status/milestone-type/user) flooded the audit log with thousands of blank-named self-attributing rows on every project setup. They now roll up to their owning Projects row (named, grouped), with the specific setting kept in the humanized diff. A same-owner merge step combines child rows sharing an audit identity (operationId+sourceTable+entityId+action) so EVERY setting is preserved (comma-listed) instead of being dropped by the ON CONFLICT DO NOTHING idempotency index — this also fixes multi-field-value changes losing all but one field.
The 9 implicit Prisma m2m join tables (_*To* for tags and issues) rendered opaque generic A/B columns in the diff ("{A:4,B:13}"). humanize() now collapses each to just the linked entity, named — { Tags: "regression" } / { Issues: "PROJ-123" } — dropping the redundant owner column (the row already attributes to its case/run/session).
ProjectConfigurationAssignment is an audited project-setup join table of the
same nameless {configurationId, projectId} shape as the workflow/status/
milestone-type/user assignment tables, but was missing from the rollup map —
so it flooded the audit log with self-attributing blank-named rows on project
setup instead of grouping under the owning Projects row. Add it to ROLLUP_MAP
and the access-label project-name fallback alongside the other four.
The User audit trigger captured raw column values, so password (bcrypt hash),
twoFactorSecret, and twoFactorBackupCodes — all @omit in schema — landed in the
append-only DataChangeLog and surfaced in AuditLog. Add them to the User
trigger denylist (SAF-02/04), mirroring the semantic audit's SENSITIVE_FIELDS.
The change EVENTS remain audited semantically (PASSWORD_CHANGED,
TWO_FACTOR_ENABLED, …) with the value redacted, so no audit coverage is lost.
maskSensitiveValue only masked top-level columns whose name was sensitive, so a
secret nested in a Json column passed through untouched — SsoProvider.config
logged clientSecret in plaintext. Walk plain JSON objects/arrays and mask any
nested key in SENSITIVE_FIELDS (Dates/Decimals left intact); add clientSecret to
the set. Change detection still compares raw values, so diffs are unaffected.
The audit-log-management E2E specs were stale since the page migrated from a
semantic-table DataTable to VirtualizedDataTable (PR #441): they waited on
getByRole('table')/thead/tbody, which the virtualized ARIA-rolled div structure
never renders, so all 7 timed out. Target the real testids/roles instead
(audit-logs-table, -scroll, audit-log-row-<id>, [role=columnheader]) and add a
data-testid to the row view-details button. All 8 tests pass against a fresh
build with audit data present (real detail-modal + export paths exercised).
DataChangeLog lives in every tenant database (the capture triggers are applied
per-DB), but both the CDC consumer and its retention worker ran only against the
primary database — so in multi-tenant deployments every tenant's audit changes
were captured yet never correlated into their AuditLog and never purged, growing
the log unbounded.

- Loop B (correlation consumer): replace the single-client poll loop with
  pollDataChangeLogsAcrossTenants, a supervisor that re-resolves the live tenant
  set each cycle (runtime additions picked up without a restart, mirroring the
  webhook outbox poller) and runs one poll pass per tenant with per-tenant error
  isolation. Single-tenant mode is a one-element client list.
- DataChangeLog retention worker: add purgeAllTenantsOnce — iterate every tenant
  DB via getTenantPrismaClient with a per-tenant time budget, one DCL_RETENTION_
  PURGED audit row per (tenant, run), and disconnect tenant clients after each
  daily pass. Mirrors webhookRetentionWorker.
- Bump audit-log-worker to the 3G tier: Loop B now caches one raw Prisma client
  per tenant (one Rust query engine each), the same footprint as the webhook
  outbox worker; harmless headroom in single-tenant mode.
- Wire dcl-retention into start:workers:prod for parity with ecosystem.config.js,
  and document both workers' multi-tenant behavior.

Removes the now-superseded single-client pollDataChangeLogs.
…udit-log

# Conflicts:
#	testplanit/e2e/tests/admin/audit-logs/audit-log-management.spec.ts
#	testplanit/lib/prisma.ts
#	testplanit/package.json
…surfaces

Neither new audit surface had E2E coverage (repository-history.spec.ts tests
browser navigation, not the audit sheet). Add Playwright specs that exercise
both against a production build:

- Project > Audit Log: the ADMIN-gated page renders the project-scoped trail,
  the virtualized table + headers, the project's own audit rows, the detail
  modal, and action filtering.
- Repository Case > Activity: the history sheet opens, renders the case-scoped
  trail, surfaces the case's audit rows, and opens the detail modal (scoped by
  title since the sheet is itself a role=dialog).

Add a data-testid to the case history sheet trigger for a stable selector.
Both verified green with real CDC-materialized audit data.
…revoke

UserIntegrationAuth is a credential table excluded from CDC triggers (SAF-04),
and its real mutations run through the raw prismaBase client in
AuthenticationService — so they bypassed the $extends entity-audit hooks
entirely (on main and this branch alike), leaving integration-credential
changes unaudited. Emit an explicit semantic audit at the mutation sites,
mirroring the apiToken precedent: storeUserAuth → CREATE (first auth) / UPDATE
(re-auth or token refresh), revokeUserAuth → DELETE. The encrypted tokens are
never included (entityType + integration name + integrationId only). lastUsedAt
bumps are intentionally not audited (housekeeping noise, like lastActiveAt).
Updates/deletes of the primary entities (Cases, Runs, Sessions, Issues,
...) made through the ZenStack RPC route were recording in the CDC audit
log with an empty actor and no operationId (rendered as "System"), while
their child/value-table writes in the same save attributed correctly.

Two causes:

- enhanceWithAudit enhanced the hooked `lib/prisma` client. When
  ZenStack's enhance() wraps a base client that carries its own $extends
  query hooks (repositoryCases/testRuns/sessions/issue/...), it routes
  those models' writes through a path that skips the post-enhance
  audit-guc $extends, so app.audit_context is never set on the write's
  transaction. Enhance the raw prismaBase instead so $allOperations
  fires for every model uniformly; the hooked client's ES-sync/webhook
  hooks were already bypassed by enhance() (the RPC route carries manual
  shims), so nothing is lost.

- The case editor saved the case via `mutate` (fire-and-forget), racing
  the begin/endOperation window so the write missed X-Operation-Id
  grouping; switched to `mutateAsync` so it completes in-window.

Adds enhanceWithAudit.test.ts pinning the raw-base-client invariant.
The Issue CDC trigger captured the `name` column (the reference key, e.g.
"#213") as the audit entity name, while every other entity shows its
human-readable name/title. Switch the Issue trigger's nameCol to `title`
("[FEATURE] Webhook System") so the audit log reads consistently. Existing
rows are immutable and keep their captured value; new captures use the title.
The old/new change values and the metadata JSON rendered in <pre> blocks
that scrolled horizontally on a single line, hiding long values. Wrap
them (whitespace-pre-wrap + break-words) so the full value is visible.
Also color the case Activity history icon with the foreground token.
Surface the CDC audit trail scoped to a single test run or session, mirroring
the existing per-case Activity sheet.

- Extract a generic ScopedAuditLogSheet (entityType + entityId + labels +
  testids) and refactor RepositoryCaseAuditLogSheet onto it.
- Add RunAuditLogSheet + SessionAuditLogSheet wrappers; wire the Activity
  trigger into the manual run header, the JUnit run header (JunitTableSection),
  and the session header.
- Broaden the AuditLog read policy so anyone who can read a run/session can read
  its audit trail, using the same project-membership predicate as RepositoryCases.
- Add E2E specs for the run and session history surfaces.
Adding cases to a run wrote one merged TestRunCases CREATE audit row whose
columns were comma-joined raw ids (repositoryCaseId: "100383, 100080, …") buried
under iteration counters — unreadable.

- Resolve repositoryCaseId to the case name in the humanizer (reusing the
  existing RepositoryCases lookup delegate).
- Add a summarizeBulkCaseAdds correlation pass that rewrites the merged row into
  one line: "N test cases added: <case names>". The count comes from the numeric
  pk list, robust against case names containing commas. Non-create / non-case
  rows pass through untouched.
- Unit test covering bulk collapse, singular, raw-id fallback, and pass-throughs.
…: <names>"

Removing test cases from a run is a soft-delete (UPDATE isDeleted), which
previously captured only the isDeleted flip — the run history couldn't say
which cases left. Mirror the add side so a removal reads "N test cases
removed: <case names>", attributed to the user who did it.

- The TestRunCases trigger now captures repositoryCaseId, so a soft-delete
  still records which case left the run.
- The correlation worker no longer drops that captured id as unchanged noise
  on a run-case delete (it stays dropped on any other update).
- summarizeBulkCaseChanges (was summarizeBulkCaseAdds) now collapses a bulk
  CREATE or DELETE into one readable line carrying the comma-listed names.
….6 upgrade notification

Document the per-item Activity Log (the Activity button on test cases, runs,
and sessions) and the profile Audit Log section, and announce both in the
in-app upgrade notification for v0.40.6.
Append `pnpm build:docs` (Docusaurus build, which fails on broken links) to the
precommit chain so documentation changes are validated alongside the app checks.
@therealbrad therealbrad merged commit 4933e51 into main Jun 23, 2026
5 checks passed
@therealbrad therealbrad deleted the enhancement/project-audit-log branch June 23, 2026 14:17
@therealbrad

Copy link
Copy Markdown
Contributor Author

🎉 This PR is included in version 0.40.6 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Log view and more user info field

1 participant