Repository navigation
Conversation
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.
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.
Closes #800
What this does
An audit row now records the connected app a person acted through. The person stays the actor, so
ActorUserIdandActorEmailare unchanged (#500). Two new nullable columns onAuditEventshold the app.ConnectedAppClientIdis the OAuthclient_id, andConnectedAppNameis the app's registered name, copied at write time likeActorEmail. 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
/oauth/authorize, the server looks up the client's registered display name and puts it in the access token as aclient_nameclaim. OpenIddict already putsclient_idin every access token. A probe of the validated principal foundsub,client_id,oi_prst,oi_au_idand the token ids.TenantResolutionMiddlewarereadsclient_idandclient_nameintoCurrentUserContext.ConnectedApp. A session JWT never carriesclient_id, so session requests resolve no app.AuditWriterpassesICurrentUser.ConnectedApptoAuditEvent.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.
ConnectedAppis onICurrentUserbecause the middleware resolves it from the same principal at the same point as the user. It is provenance only.FlockScopeGuardand every other authorization check ignore it.Read contract and filter
IInsightsModule.ListAuditEventsAsynctakes two new filters, andAuditEventReadgainsConnectedAppClientIdandConnectedAppName. OnGET /api/v1/audit:connectedAppsOnly=truekeeps only actions taken through any connected app.connectedAppClientId=<id>keeps one app's actions.Ordering is unchanged. It is
OccurredAtUtcdescending, thenSequencedescending (#508).Index
IX_AuditEvents_ConnectedAppis a partial index on(AccountId, OccurredAtUtc) WHERE "ConnectedAppClientId" IS NOT NULL. Without it,connectedAppsOnlyon 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 scansAuditEventsonce and blocks writes to the table while it runs, during the pre-deploymigratestep.Export
The
audit-eventsexport gainsconnectedAppClientIdandconnectedAppNameas 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 readsclient_id, which OpenIddict sets on every access token whatever #1136 decides. Two tests split the path at the token.OAuthToken_CarriesTheAppsRegisteredNameruns the real register, authorize and token flow, then validates the token. The token carriesclient_idand the sanitisedclient_name.client_idandclient_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
/authorizechange touches the same method #1136 rewrites. Whichever PR merges second needs a small merge. When #1136 merges, I will mergemainhere 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.Createtruncates 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, besideActorEmail. 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.ConnectedAppOfand the/authorizechange inOAuthEndpoints.cs. Then readAuditEventRepository.ListAsyncandAuditEventConfiguration. The migration anddocs/schema/are generated.Proof
All runs below are local and filtered. I never ran the full suite.
Targeted tests at
a02e502e.Cluckwork.Api.IntegrationTestsfiltered toAudit,Export,OAuth,Migration,SchemaDocs,TenantResolution,OpenApi,CurrentUser,BusinessRecordandChronolog: 275 tests. One failed on the first run. My tie-order test reused the pinned ids ofList_WhenTwoEventsShareAnInstant_ReturnsTheOneWrittenLastFirstand hit a duplicate key in the shared database. It now uses its own ids, andAuditConnectedAppTestsplusAuditProvenanceTestspass 56 of 56.Cluckwork.Application.Testsfiltered toArchitecture|Documentation|TenantBypass: 467 of 467. That covers the module ledger, the contract purity walk and the table owners.Cluckwork.Domain.Tests: 495 of 495.35c008dcfailed 1 of 2,104.BodyReadingEndpointTestsflaggedGET /oauth/authorize, because the handler now takesHttpContext. My local filter did not include that guard.1165c05alists 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 wasAuditConnectedAppTestsplusOAuthServerTests. The baseline passed 17 of 17, and the restore passed 17 of 17.app-not-resolvedclient_idWriteThroughAConnectedApp_RecordsTheAppBesideThePersonwriter-drops-appAuditWriterrecords the appname-not-in-tokenOAuthToken_CarriesTheAppsRegisteredNamesession-gets-appSessionWrite_RecordsNoAppname-read-liveAppName_SurvivesTheAppBeingDeletedfilter-any-ignoredconnectedAppsOnlyfiltersList_FiltersToConnectedApps_AndToOneAppfilter-one-ignoredconnectedAppClientIdfiltersfilter-crosses-farmsIgnoreQueryFilters)List_AppFilter_StaysInsideTheFarmtiebreak-by-idSequencetiebreakList_AppFilter_KeepsWriteOrderForTiedInstantsexport-drops-columnsExport_IncludesTheAppColumnssession-claims-in-tokenOAuthToken_ForcedThroughTheDefaultScheme_IsStillRejectedaccount-id-in-tokenfilter-one-ignoredfirst 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
/authorizechange moved the code that two existing rows intools/oauth/mutation-check.shmatched on. They now anchor on the unchangedSubjectline, 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, thenSequence.IX_AuditEvents_ConnectedAppIX_AuditEvents_ConnectedApp's 2,000 rowsNot done here, for the coordinator's batch