Repository navigation
Close open issues #99-#102: rate limit partitioning, broker transport coverage, token lifecycle, event trail UI - #103
Merged
Conversation
Each policy was a single limiter shared by all callers, so one client exhausting a budget locked out every other. On session-start - anonymous by design and the entry point of every journey - 100 requests a minute denied service to all applicants. Each policy now partitions: session-start by client address, analytics-ingest by the applicant session in the token, the operator policies by principal. Keys are prefixed by kind so an address cannot collide with a session id. X-Forwarded-For is honoured only from a configured proxy, since the connection address behind a load balancer is the balancer's and would collapse every caller into one partition again. The ASP.NET loopback defaults are cleared: nothing is trusted until it is named. A global ceiling runs alongside the per-caller policies so a distributed flood still has a bound. The runbook claimed limits were already per IP. It now describes the real partitioning, the proxy configuration, and that counters are per-instance rather than cluster-wide. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The adapter that carries session events between replicas had no test of any kind. SessionEventEmitterTests proves the fan-out contract through an in-process fake, so the emitter and the port were covered and the only implementation that runs in a real deployment was not. A defect in it reproduces the failure it exists to prevent: an SSE stream that stays open and delivers nothing. Unit tests cover the wire format, the poison-message path and the lifecycle guard. Broker-backed tests cover the deployment-shaped case - publish through one transport, receive on another - and that a malformed body on the wire does not stop the consumer. The poison path moved out of the consumer lambda into DeliverAsync so it can be tested without a broker. Recovery was assumed from client defaults. It is now stated explicitly and proven: the recovery test drops the connection from the broker side, which is the only way to get one - the client treats an application-initiated close as deliberate and does not recover from it. Connections carry a name so the management UI can identify which replica holds which queue. Broker tests skip with a clear reason when nothing is listening, so dotnet test stays dependency-free. CI declares a rabbitmq service container and sets REQUIRE_BROKER_TESTS=1, which turns a missing broker into a failure rather than a silent skip. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The per-session token was issued once at session start and never renewed or withdrawn, which cost at both ends. A journey left open past the lifetime returned to a live session holding a credential the API no longer accepted, with no way forward but to start again and lose everything entered. Step submission now returns a replacement token: activity extends the credential, so no separate refresh endpoint is needed and nothing to protect is added. A finished session kept a working token until expiry - up to a day, and on a shared machine well past the visit that created it. The ownership requirement now carries read/write intent: writes against a Completed or Abandoned session are refused for an applicant token, reads still serve the completion screen. Analytics ingest applies the same rule. Operators are unaffected. Abandon stays a read so its documented idempotency holds. The browser stores each renewal, drops the token when the journey completes, and distinguishes a spent credential - 401 expired, 403 terminal - from a server fault, showing a "your session has expired, start again" state instead of a generic error banner. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The durable event trail had no reader. An operator investigating why an applicant stalled had to query the API by hand; the only reference to the endpoint anywhere in the frontend was a test asserting the applicant page does not call it. SessionDetail now renders the trail beside the submissions it already showed. Submissions say what was entered, the trail says how the applicant got there: events in occurrence order, each with its type, step, absolute timestamp, source, payload summary, and the gap since the previous event - the column that makes a stall visible. Server and client events are distinguished by label as well as styling, since a reader should not have to know the schema to interpret a gap. A gap is never rendered negative: a browser-timestamped client event can land fractionally before the server event it followed, and a negative gap would read as an ordering bug. Both empty states are named rather than left blank. A trail with no client events is normal - an ad blocker or a closed tab stops them reaching the API - and an empty trail may be a session whose events aged past the retention window, which after the sweep is indistinguishable from one that never had any. Read through operatorFetch and the admin session cookie, like every other operator view; the applicant page still never requests it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s for The policy was declared, partitioned and configurable, and attached to nothing. Every controller endpoint bar the three naming a specific policy was unlimited while appearing covered, and #99 asked for per-partition isolation tests on every policy - which was not reachable over HTTP for a policy no endpoint used. It is now added as endpoint metadata wherever no policy is already present. Not via RequireRateLimiting on the controller group: that applies to the whole group and, because the middleware takes the last matching metadata, it overrode the action-level attributes - session-start silently ran under the general budget instead of its own. A test pins that precedence so the two can never start stacking. Health checks stay outside it. Throttling the endpoint a load balancer polls turns a traffic spike into an instance being pulled from service. The three CI jobs that drive the API from a single address - which is one partition - get explicit headroom, keeping their behaviour as it was before the policy was attached. Rate limiting has its own dedicated tests; throttling those suites would only add flake. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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 #99
Closes #100
Closes #101
Closes #102
Works through the four open issues on the board. Each is a separate commit.
#99 — Rate limiters are global rather than per-caller
Every policy was a single limiter shared by all callers, so one client exhausting a budget locked out every other. On
session-start— anonymous by design and the entry point of every journey — 100 requests a minute denied service to all applicants.session-startby client address,analytics-ingestby the applicant session in the token, the operator policies by principal. Keys are prefixed by kind so an address cannot collide with a session id.X-Forwarded-Foris honoured only from a configured proxy. The ASP.NET loopback defaults are cleared: nothing is trusted until named inForwardedHeaders:KnownProxies/KnownNetworks.generalpolicy was declared, partitioned and configurable — and attached to nothing, leaving every controller endpoint bar three unlimited while appearing covered. It is now the fallback wherever no specific policy is named. Not viaRequireRateLimitingon the controller group: that overrode the action-level attributes, sosession-startsilently ran under the general budget instead of its own. A test pins the precedence.#100 — RabbitMqSessionEventTransport has no test coverage
The adapter that carries session events between replicas had no test of any kind.
SessionEventEmitterTestscovered the emitter and the port through an in-process fake; the only implementation that runs in a real deployment was unverified.DeliverAsyncso it can be tested without a broker.AutomaticRecoveryEnabledoff and watching it fail.dotnet teststays dependency-free. CI declares arabbitmq:3-managementservice container and setsREQUIRE_BROKER_TESTS=1, which turns a missing broker into a failure rather than a silent skip.#101 — Applicant session tokens cannot be refreshed or revoked
CompletedorAbandonedsession are refused for an applicant token; reads still serve the completion screen. Analytics ingest applies the same rule. Operators are unaffected. Abandon stays a read so its documented idempotency holds.#102 — The persisted analytics event trail has no operator UI
SessionDetailnow renders the trail beside the submissions it already showed: events in occurrence order, each with type, step, absolute timestamp, source, payload summary, and the gap since the previous event.operatorFetchand the admin session cookie; the applicant page still never requests it, and the existing assertion inApp.routing.test.tsxstill holds.Verification
REQUIRE_BROKER_TESTS=1OpenOnboarding.slnxBaseline before this work was 300 backend / 150 frontend.
Noted, not fixed
UploadDocumentsandGetDocumentonWorkflowControllerhave[Authorize(Policy = "ApplicantOrOperator")]but no resource-based ownership check — unlike every other session endpoint. An applicant token for session A appears able to upload documents against session B, andGetDocumenttakes afileIdwith no session scoping at all. Out of scope for these four issues; flagging it for a separate one.🤖 Generated with Claude Code