enhancement(audit): comprehensive audit logging with inline activity views#466
Merged
Conversation
- 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.
Contributor
Author
|
🎉 This PR is included in version 0.40.6 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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
How Has This Been Tested?
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:
Checklist
Screenshots (if applicable)
Additional Notes
en-US.jsononly (*.auditLog); Crowdin syncs the other locales.