Skip to content

feat(api): record which connected app acted in the audit log - #1138

Open
mforce wants to merge 4 commits into
mainfrom
feat/800-oauth-audit-provenance
Open

mforce wants to merge 4 commits into
mainfrom
feat/800-oauth-audit-provenance

Conversation

@mforce

@mforce mforce commented Oct 8, 2026 •

Copy link
Copy Markdown
Owner

Closes #800

What this does

An audit row now records the connected app a person acted through. The person stays the actor, so ActorUserId and ActorEmail are unchanged (#500). Two new nullable columns on AuditEvents hold the app. ConnectedAppClientId is the OAuth client_id, and ConnectedAppName is the app's registered name, copied at write time like ActorEmail. A session request writes nulls. Seeders and system actors write nulls.

This PR has the API part only. The audit screen change waits for the maintainer's pick between mockup directions A, B and C, and will land in this same PR.

How the app reaches the audit row

  1. At /oauth/authorize, the server looks up the client's registered display name and puts it in the access token as a client_name claim. OpenIddict already puts client_id in every access token. A probe of the validated principal found sub, client_id, oi_prst, oi_au_id and the token ids.
  2. TenantResolutionMiddleware reads client_id and client_name into CurrentUserContext.ConnectedApp. A session JWT never carries client_id, so session requests resolve no app.
  3. AuditWriter passes ICurrentUser.ConnectedApp to AuditEvent.Create.

The name travels in the token, so a write needs no extra query. Registration never renames a client, so the name at authorize time equals the name at write time. The audit row keeps both values after the app is disconnected or pruned, because nothing reads the OpenIddict tables at read time.

ConnectedApp is on ICurrentUser because the middleware resolves it from the same principal at the same point as the user. It is provenance only. FlockScopeGuard and every other authorization check ignore it.

Read contract and filter

IInsightsModule.ListAuditEventsAsync takes two new filters, and AuditEventRead gains ConnectedAppClientId and ConnectedAppName. On GET /api/v1/audit:

  • connectedAppsOnly=true keeps only actions taken through any connected app.
  • connectedAppClientId=<id> keeps one app's actions.

Ordering is unchanged. It is OccurredAtUtc descending, then Sequence descending (#508).

Index

IX_AuditEvents_ConnectedApp is a partial index on (AccountId, OccurredAtUtc) WHERE "ConnectedAppClientId" IS NOT NULL. Without it, connectedAppsOnly on a farm with few app actions walks the farm's whole (AccountId, OccurredAtUtc) index backwards to fill one page. Session rows never enter the partial index, so the writes that make up nearly every row pay nothing for it. One index serves both filters, because one app's rows are a subset of all app rows. #505 stays as it is: no time partition.

The migration creates the index without CONCURRENTLY, like every other migration here. It scans AuditEvents once and blocks writes to the table while it runs, during the pre-deploy migrate step.

Export

The audit-events export gains connectedAppClientId and connectedAppName as its last two columns. Positional readers of the earlier columns keep working. The CSV formula guard already covers both, which matters because the name comes from an anonymous registration.

Depends on #1136

On main, no business endpoint accepts an OAuth token yet. #1136 (#796) is what lets an OAuth principal through the request chain. This PR does not depend on #1136's code. It reads client_id, which OpenIddict sets on every access token whatever #1136 decides. Two tests split the path at the token.

  • OAuthToken_CarriesTheAppsRegisteredName runs the real register, authorize and token flow, then validates the token. The token carries client_id and the sanitised client_name.
  • The write tests use a test host whose session principal also carries client_id and client_name, the shape feat(api): run every fail-closed check on OAuth-authenticated requests #1136 gives an OAuth principal. They go through the real middleware chain and a real write endpoint.

The /authorize change touches the same method #1136 rewrites. Whichever PR merges second needs a small merge. When #1136 merges, I will merge main here and add a test that writes through a real OAuth token.

Name hygiene

The name comes from Dynamic Client Registration (DCR) and the client chose it. #797 already turns control and format characters, bidi among them, into spaces and caps the name at 100 UTF-16 units. AuditEvent.Create truncates to the column length again in case a client is ever registered another way. The UI will render it as text.

GDPR (#272), not solved here

The app name adds a new immutable snapshot to AuditEvents, beside ActorEmail. An app name is not personal data in itself, but a client can put a person's name in it. #272's erasure-versus-pseudonymisation question now covers this column too.

For reviewers

Start with TenantResolutionMiddleware.ConnectedAppOf and the /authorize change in OAuthEndpoints.cs. Then read AuditEventRepository.ListAsync and AuditEventConfiguration. The migration and docs/schema/ are generated.

Proof

All runs below are local and filtered. I never ran the full suite.

Targeted tests at a02e502e.

  • Cluckwork.Api.IntegrationTests filtered to Audit, Export, OAuth, Migration, SchemaDocs, TenantResolution, OpenApi, CurrentUser, BusinessRecord and Chronolog: 275 tests. One failed on the first run. My tie-order test reused the pinned ids of List_WhenTwoEventsShareAnInstant_ReturnsTheOneWrittenLastFirst and hit a duplicate key in the shared database. It now uses its own ids, and AuditConnectedAppTests plus AuditProvenanceTests pass 56 of 56.
  • Cluckwork.Application.Tests filtered to Architecture|Documentation|TenantBypass: 467 of 467. That covers the module ledger, the contract purity walk and the table owners.
  • Cluckwork.Domain.Tests: 495 of 495.
  • CI's first integration run at 35c008dc failed 1 of 2,104. BodyReadingEndpointTests flagged GET /oauth/authorize, because the handler now takes HttpContext. My local filter did not include that guard. 1165c05a lists the endpoint as reviewed, with the same reason feat(api): run every fail-closed check on OAuth-authenticated requests #1136 gives, and the guard passes locally.

Mutations. I used the runner from tools/oauth/mutation-check.sh. For each mutant it builds, runs the one named test with a TRX logger, and classifies from the TRX. The suite was AuditConnectedAppTests plus OAuthServerTests. The baseline passed 17 of 17, and the restore passed 17 of 17.

Mutant Rule it breaks Named test Verdict
app-not-resolved the middleware resolves the app from client_id WriteThroughAConnectedApp_RecordsTheAppBesideThePerson killed
writer-drops-app AuditWriter records the app same killed
name-not-in-token the token carries the registered name OAuthToken_CarriesTheAppsRegisteredName killed
session-gets-app a session write records no app SessionWrite_RecordsNoApp killed
name-read-live the name survives disconnect and prune (the mutant reads it from the OpenIddict table at read time) AppName_SurvivesTheAppBeingDeleted killed
filter-any-ignored connectedAppsOnly filters List_FiltersToConnectedApps_AndToOneApp killed
filter-one-ignored connectedAppClientId filters same killed
filter-crosses-farms the filter stays inside the farm (the mutant adds IgnoreQueryFilters) List_AppFilter_StaysInsideTheFarm killed
tiebreak-by-id the filtered list keeps the Sequence tiebreak List_AppFilter_KeepsWriteOrderForTiedInstants killed
export-drops-columns the export carries both columns Export_IncludesTheAppColumns killed
session-claims-in-token #795's existing mutant, re-anchored OAuthToken_ForcedThroughTheDefaultScheme_IsStillRejected killed
account-id-in-token #795's existing hold mutant, re-anchored same held

filter-one-ignored first came back INCONCLUSIVE because my replacement text did not compile. With the corrected replacement it was killed, and the restore passed 17 of 17.

My /authorize change moved the code that two existing rows in tools/oauth/mutation-check.sh matched on. They now anchor on the unchanged Subject line, and the last two rows above show both still behave. #1136 rewrites that script too, so the second PR to merge will need a merge there.

Index. I measured on a throwaway Postgres with the repo's pinned image. The table had 1,000,000 rows across 20 farms, and 1 row in 500 went through a connected app. The query was one farm's newest 100 app actions, ordered by OccurredAtUtc, then Sequence.

Plan Buffers Time
Without the partial index bitmap scan of the farm's 50,000 rows, 48,000 removed by the filter 13,779 108 ms
With it, any app backward scan of IX_AuditEvents_ConnectedApp 103 2.4 ms
With it, one app bitmap scan of IX_AuditEvents_ConnectedApp's 2,000 rows 2,011 18 ms

Not done here, for the coordinator's batch

mforce added 4 commits October 8, 2026 20:57
An audit row now carries the OAuth client's id and display name beside
the person, who stays the actor (#500). The name rides in the access
token as client_name, set at authorize from the registered name, so the
row snapshots it without a lookup per write and keeps it after the app
is disconnected or pruned. Session writes record nulls.

The audit list filters to actions through any connected app or one app,
backed by a partial index that session rows never enter. The audit
export gains both columns.

Refs #800
The two session-claim mutants matched Authorize's old signature, which
#800 changed, so they no longer applied.
Authorize now takes HttpContext for the OpenIddict request, which the
body-reading guard counts as able to reach the body. A GET carries none.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OAuth slice 6: audit provenance — record which application acted, alongside the human

1 participant