Skip to content

feat(audit): structured audit trail for user-initiated changes - #243

Merged
cl8dep merged 6 commits into
mainfrom
feat/audit-log
Jul 29, 2026
Merged

feat(audit): structured audit trail for user-initiated changes#243
cl8dep merged 6 commits into
mainfrom
feat/audit-log

Conversation

@cl8dep

@cl8dep cl8dep commented Jul 29, 2026

Copy link
Copy Markdown
Member

Summary

Adds a structured audit trail. Until now SetAuditTimestamps() only stamped CreatedAt/UpdatedAt on entities, so there was no record of who changed what. This adds an append-only AuditLog table, a SaveChanges interceptor that records changes to entities marked IAuditable, a read-only API endpoint restricted to Owner and Admin, and an admin page under Logs with a diff viewer.

The trail records what people did, not what the system did. Nothing is written when no authenticated user resolves, so background jobs, Quartz, seeding and migrations leave no trace even when they touch a marked entity. Entries are grouped by a correlation id assigned per SaveChanges, so one user action that touches an entity and its join rows reads as one transaction rather than several unrelated rows.

Related

Closes #17

Changes

Domain

  • IAuditable, an opt-in marker. The interceptor ignores anything without it, so forgetting the marker means a change goes unrecorded rather than the table filling with machine-written noise. An opt-out marker would have been fewer files to touch but would silently capture high-volume tables like CheckDataPoint the moment a new entity appears.
  • 34 entities marked: monitoring configuration, incidents, postmortems, maintenances, alerting and escalation, on-call, identity and access, integrations, taxonomy. Deliberately unmarked: the machine-written tables (CheckDataPoint, Alert, the delivery logs, the outbox, worker registrations) and the token tables, which hold secrets and are better left out entirely than trusted to a property-level exclusion.
  • [NotAudited] keeps secrets out of the JSON snapshots: ApiKey.HashedKey, OidcProviderConfig.ClientSecret, Integration.ConfigJson. Saml2ProviderConfig.IdpSigningCertificate stays audited because a public signing certificate is not a secret.

Persistence

  • AuditLog is append-only by construction: no UpdatedAt, no write endpoints, and no code path that revisits a written row.
  • No foreign key on UserId, following the precedent set by NotificationDeliveryLogConfiguration. The trail has to survive the account being deleted, which is precisely when it matters most, so UserEmail is denormalised onto the row.
  • Four indexes: CorrelationId for the grouping, (EntityType, EntityId) for one row's history, UserId for one person's activity, CreatedAt for date ranges and the feed's ordering.

Interceptor

  • Runs in two passes because neither half of what an entry needs exists at a single moment: before the save the change tracker still holds original values, after it inserted rows have their generated keys. A reentrancy flag keeps the audit write from being audited.
  • ICurrentUserAccessor is the seam that keeps HttpContext out of Infrastructure. The API implements it over IHttpContextAccessor; the DbContext only attaches the interceptor when the container can resolve it, so the Worker never walks the change tracker at all.
  • An update touching no audited property writes no row. Rotating a [NotAudited] secret would otherwise produce an entry with two identical snapshots, which is worse than no entry because it implies a change you cannot see.
  • SaveChangesFailed clears the captured batch, so a rolled-back transaction cannot leak entries into the next save on the same context.

API

  • GET /api/v1/audit-logs, [Authorize(Roles = "Owner,Admin")]. The trail covers identity and access configuration across the whole instance, so it is administrative rather than operational.
  • Paginates over transactions rather than rows, in two queries. A page means a stable number of user actions and no transaction is split across a page boundary. A filter selects transactions but never prunes them: filtering by Service still returns the ServiceTag entries the same action wrote, since hiding the siblings would misrepresent what changed.
  • IAuditLogWriter records authentication events, which are not changes to an entity and so are invisible to the interceptor. A failed login is the one entry written with no verified actor; the email recorded is only what the request claimed.

Frontend

  • New page under Logs, alongside System Logs and Delivery Logs. One row per transaction; clicking opens a dialog with a GitHub-style unified diff per entity the action touched.
  • A dialog rather than an expandable row: a single action can touch several entities and each diff is a wide block of monospaced text, which deforms the table's columns when nested in a row.
  • Property names are shown exactly as the backend recorded them, untranslated. A mapping of friendlier labels would drift from the entities every time one changes, and a stale label on an audit trail is worse than a blunt one.
  • Unchanged properties are hidden behind a toggle rather than dropped. A snapshot records the whole entity so most rows are usually noise, but "what else was true at the time" is a fair question to ask of an audit trail.
  • api-types.ts regenerated from the OpenAPI document rather than hand-written, per the project convention.

Dependencies (unrelated to the audit log, folded in as its own commit)

  • dotnet list package --vulnerable --include-transitive reported eight advisories. All are now clear: Scriban 7.1.0 to 7.2.5, Microsoft.OpenApi 2.4.1 to 2.11.0, System.Security.Cryptography.Xml 10.0.7 to 10.0.10.
  • OpenApi stays on the 2.x line. The advisory publishes patches on both 2.7.5 and 3.5.4, so closing it did not require the major bump and the breaking changes it would bring to the OpenAPI transformers.
  • The crypto.xml fix needs a direct PackageReference: it arrives transitively through ITfoxtec.Identity.Saml2 4.20.1, which is the latest published version and still pins the vulnerable one. The comment on that line records why it exists and when it can go. This is the most pointed of the three, since SAML is the path that parses signed XML from an external identity provider.
  • Scriban had no test coverage despite rendering every email and notification template, so a clean build verified nothing about crossing the mass-assignment fix. Added tests over the rendering patterns actually in use.

Testing

  • dotnet test passes (unit + integration)
  • pnpm exec tsc -b passes in affected frontend app(s) (apps/web, apps/admin)

487 backend tests (389 unit, 98 integration) and 72 admin tests. pnpm build also passes in apps/admin.

New coverage:

  • 7 interceptor tests against a real Postgres: create, update and delete; CheckDataPoint ignored; an unauthenticated save writing nothing; a Service and its ServiceTag sharing one correlation id with the Service marked primary; ApiKey.HashedKey staying out of the snapshot while the masked value does not.
  • 10 repository tests: the grouping, the transaction count, ordering under identical timestamps, each filter, whole-transaction results under a partial filter, and the auth events.
  • 15 diff tests: updates, creates, deletes, added and removed properties, non-string values, nested objects, and malformed or absent snapshots, which return an empty diff rather than throwing.

Two defects were caught by these tests rather than by reading, both fixed here:

Ordering the feed by CorrelationId alone was wrong. UUIDv7 is time-ordered only to millisecond precision; within one millisecond the remaining bits are random, so transactions written in the same millisecond came back shuffled. Reproduced with five ids sharing a timestamp prefix that sorted 4,1,3,2,0. The feed now orders by the group's timestamp with the id as a deterministic tie-break, at the cost of the GROUP BY that ordering by the id was meant to avoid.

SetAuditTimestamps restamped CreatedAt on every added entity, AuditLog included, overwriting the timestamp the interceptor had just set. An audit entry has to be able to state when the change it records happened, so it is now excluded.

Not verified: the page was not exercised in a browser, so the rendered diff has no visual confirmation from me. The diff logic is covered by unit tests and both tsc -b and the Vite build pass.

Worth flagging separately: pnpm lint currently reports 24 errors in apps/admin, all pre-existing. Verified by stashing this branch's changes and re-running against the base, which produces the identical 29 problems. None are in the new audit-logs code. That means lint is already failing on main and probably deserves its own issue.

Database

  • Adds an EF Core migration
  • Migration is safe against a populated production DB (no data loss on existing rows)
  • Down() is reversible

20260728235941_AddAuditLog creates one new table and its four indexes, with no changes to any existing table. That the generated migration is purely additive also confirms marking 34 entities with an empty interface leaves the schema untouched. Down() drops the new table.

One thing to note for operations: retention is out of scope here, so the table grows unbounded. Same caveat as NotificationDeliveryLog. Worth a follow-up issue for a purge job.

Screenshots

Checklist

  • Title follows conventional commits
  • Applied all relevant labels
  • Docs updated if behavior/config changed (wiki, README, or RFC status)
  • No secrets, credentials, or .env/appsettings.*.json values committed

Docs are not updated. The wiki has no page for the audit log yet, and the feature is self-explanatory in the UI, but a short page covering what is and is not recorded would be worth adding.

cl8dep added 6 commits July 28, 2026 20:57
First phase of the audit log (#17): the domain vocabulary only, no
persistence or interception yet.

`IAuditable` is an opt-in marker. The interceptor added in a later phase
ignores every entity that does not implement it, so forgetting the marker
means a change goes unrecorded rather than the audit table filling with
machine-written noise. An inverted opt-out marker would have been fewer
files to touch, but it would silently capture high-volume tables such as
CheckDataPoint the moment a new entity appears.

34 entities are marked: monitoring configuration, incidents, postmortems,
maintenances, alerting and escalation, on-call, identity and access,
integrations, and taxonomy. Deliberately unmarked are the machine-written
tables (CheckDataPoint, Alert, the delivery logs, the outbox, worker
registrations) and the token tables, which hold secrets and are better
left out entirely than trusted to a property-level exclusion.

`[NotAudited]` keeps secrets out of the JSON snapshots. It is applied to
ApiKey.HashedKey, OidcProviderConfig.ClientSecret and
Integration.ConfigJson. Saml2ProviderConfig.IdpSigningCertificate is left
audited because a public signing certificate is not a secret. The
attribute cannot reach members inherited from ASP.NET Core Identity, so
the interceptor will also carry a name-based deny list.

AuditLog itself is append-only by construction: no UpdatedAt, and the API
will expose reads only. Entries carry a CorrelationId — one UUIDv7 per
SaveChanges — so a single user action that touches an entity and its join
rows reads as one transaction instead of several unrelated rows, with
IsPrimary marking the entry that names it.

Refs #17
Second phase of the audit log (#17): EF mapping and migration. Nothing
writes to the table yet — the interceptor comes next.

No foreign key on UserId, following the precedent set by
NotificationDeliveryLogConfiguration. The trail has to survive the account
being deleted, which is precisely when it matters most, so UserEmail is
denormalised onto the row rather than joined from AspNetUsers.

Four indexes, each backing a specific access pattern. CorrelationId serves
double duty: it fetches every entry in a transaction, and it orders the
feed — the listing paginates over transactions rather than rows, and since
the interceptor assigns UUIDv7 whose ordering is already chronological,
the default sort is an index scan with no aggregate over CreatedAt.
(EntityType, EntityId) answers "what happened to this Service?", UserId
answers "what did this person do?", and CreatedAt covers date ranges.

Action is stored as a string rather than an int, matching how the other
enums in this schema are mapped. The value snapshots are unbounded text: a
snapshot is as wide as the audited entity's scalars.

The generated migration contains only the new table and its indexes, with
no incidental changes elsewhere, confirming that marking 34 entities with
an empty interface leaves the schema untouched.

Refs #17
`dotnet list package --vulnerable --include-transitive` reported eight
advisories across three packages. All are now clear.

Scriban 7.1.0 -> 7.2.5, closing two high and two moderate advisories. The
minimum patched version was 7.2.2 (GHSA-7jvp-hj45-2f2m, template writes
reaching arbitrary CLR properties through TypedObjectAccessor).

Microsoft.OpenApi 2.4.1 -> 2.11.0. The advisory publishes patches on two
branches, 2.7.5 and 3.5.4, so staying on 2.x closes it without the
breaking changes 3.x would bring to PatchSchemaTransformer and
SecuritySchemeTransformer. Whether to take the major is a separate call.

System.Security.Cryptography.Xml 10.0.7 -> 10.0.10 (CVE-2026-50648, five
high advisories) needs a direct PackageReference: it arrives transitively
through ITfoxtec.Identity.Saml2 4.20.1, which is the latest published
version and still pins 10.0.7, so no upgrade resolves it. The comment on
that line records why it exists and when it can go. This is the most
pointed of the three, since SAML is the path that parses signed XML coming
from an external identity provider.

Scriban had no test coverage at all despite rendering every email and
notification template, so a clean build verified nothing about the 7.1 ->
7.2 jump across the mass-assignment fix. Added tests over the rendering
patterns actually in use — anonymous and typed models, conditionals and
loops, unknown members degrading to empty — plus one asserting that a
template can no longer write back into its model, which is the behaviour
the advisory changed.
Third phase of the audit log (#17): the interceptor that actually writes
entries, plus the seam that tells it who is acting.

The interceptor runs in two passes because neither half of what an entry
needs exists at a single moment. Before the save the change tracker still
holds original values and entity states; after it, inserted rows finally
have their database-generated keys. So SavingChanges captures states and
snapshots, and SavedChanges resolves ids and writes. That write is itself a
SaveChanges, so a reentrancy flag keeps the audit write from being audited.

ICurrentUserAccessor is the seam that keeps HttpContext out of
Infrastructure. The API implements it over IHttpContextAccessor; hosts with
no request pipeline resolve nothing. The DbContext only attaches the
interceptor when the container can resolve the accessor, so the Worker does
not merely skip auditing, it never walks the change tracker at all.

Nothing is recorded when no user resolves, which is the "only what a person
did" rule from the design discussion. Reverting to a fuller forensic record
later means deleting one early return, with no schema change.

Two details worth naming. An update touching no audited property writes no
row: rotating a [NotAudited] secret would otherwise produce an entry with
two identical snapshots, which is worse than no entry because it implies a
change you cannot see. And SaveChangesFailed clears the captured batch, so
a rolled-back transaction cannot leak its entries into the next save on the
same context.

Join-entity detection — a composite key made entirely of foreign keys — is
a heuristic, used only to decide which entry names the transaction. A false
positive degrades a label; it cannot corrupt an entry.

Seven integration tests against a real Postgres cover create, update and
delete, that CheckDataPoint is ignored, that an unauthenticated save writes
nothing, that a Service and its ServiceTag share one correlation id with
IsPrimary on the Service, and that ApiKey.HashedKey stays out of the
snapshot while the already-masked value does not.

Refs #17
Fourth phase of the audit log (#17): the read endpoint, and the writer for
events the interceptor cannot see.

GET /api/v1/audit-logs is restricted to Owner and Admin — the trail records
changes to identity and access configuration across the whole instance, so
it is administrative rather than operational. There is no write endpoint of
any kind: the table is append-only.

The feed paginates over transactions rather than rows, in two queries: page
the correlation ids, then fetch every entry belonging to those groups. A
page therefore means a stable number of user actions and no transaction is
ever split across a page boundary. A filter selects transactions but never
prunes them — filtering by Service still returns the ServiceTag entries
that the same action wrote, since hiding the siblings would misrepresent
what changed.

Authentication is not a change to an entity, so IAuditLogWriter states
those events explicitly from AuthController, where the caller's IP lives
and where — for a rejected attempt — there is no principal for
ICurrentUserAccessor to resolve. A failed login is the one entry written
with no verified actor: the email recorded is only what the request
claimed.

Two corrections to earlier phases, both found by tests rather than by
reading:

Ordering by CorrelationId alone was wrong. UUIDv7 is time-ordered only to
millisecond precision; within one millisecond the remaining bits are
random, so transactions written in the same millisecond came back shuffled
— reproduced with five ids sharing a timestamp prefix that sorted 4,1,3,2,0.
The feed now orders by the group's timestamp with the id as a deterministic
tie-break, at the cost of the GROUP BY that ordering by the id was meant to
avoid. UUIDv7 stays for index locality, which is what it is actually good
for here. The comments asserting otherwise are fixed in all four places.

SetAuditTimestamps restamped CreatedAt on every added entity, AuditLog
included, overwriting the timestamp the interceptor had just set from
TimeProvider. An audit entry has to be able to state when the change it
records happened, so it is now excluded.

Ten integration tests cover the grouping, the transaction count, ordering
under identical timestamps, each filter, whole-transaction results under a
partial filter, and the auth events.

Refs #17
Final phase of the audit log (#17): the UI, under Logs alongside System Logs
and Delivery Logs.

One row per transaction, not per entity change, matching how the API
paginates. Clicking a row opens a dialog with a GitHub-style unified diff
per entity the action touched: removed values on red `-` lines, new values
on green `+` lines, one property per row.

A dialog rather than an expandable row. A single action can touch several
entities and each diff is a wide block of monospaced text, which deforms
the table's columns when nested inside a row.

Property names are shown exactly as the backend recorded them — the C#
model's names, untranslated. A mapping of friendlier labels would drift from
the entities every time one changes, and a stale label on an audit trail is
worse than a blunt one.

Unchanged properties are hidden behind a toggle rather than dropped. A
snapshot records the whole entity so most rows are usually noise, but "what
else was true at the time" is a fair question to ask of an audit trail.

The diff logic lives in a plain module with 15 tests covering updates,
creates, deletes, added and removed properties, non-string values, nested
objects, and malformed or absent snapshots — the last of which returns an
empty diff rather than throwing, since a snapshot that cannot be parsed
must not take the page down with it.

No role gating in the frontend, following the existing convention: the
endpoint is restricted to Owner and Admin server-side, which is where it
actually matters.

api-types.ts is regenerated from the OpenAPI document rather than
hand-written, per the project's convention.

Refs #17
@cl8dep cl8dep added enhancement New feature or request backend Backend / API work frontend Frontend / UI work auth Authentication & authorization labels Jul 29, 2026
@cl8dep
cl8dep marked this pull request as ready for review July 29, 2026 15:14
@cl8dep
cl8dep merged commit 9d8cd80 into main Jul 29, 2026
9 of 10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auth Authentication & authorization backend Backend / API work enhancement New feature or request frontend Frontend / UI work

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Audit log

1 participant