Skip to content

Close open issues #99-#102: rate limit partitioning, broker transport coverage, token lifecycle, event trail UI - #103

Merged
MaximumTrainer merged 5 commits into
mainfrom
feat/gap-review-implementation
Sep 13, 2026
Merged

MaximumTrainer merged 5 commits into
mainfrom
feat/gap-review-implementation

Conversation

@MaximumTrainer

@MaximumTrainer MaximumTrainer commented Sep 13, 2026 •

Copy link
Copy Markdown
Owner

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.

  • 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. The ASP.NET loopback defaults are cleared: nothing is trusted until named in ForwardedHeaders:KnownProxies / KnownNetworks.
  • 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.
  • The general policy 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 via RequireRateLimiting on the controller group: that overrode the action-level attributes, so session-start silently ran under the general budget instead of its own. A test pins the precedence.
  • Health checks stay outside it — throttling what a load balancer polls turns a traffic spike into an instance being pulled from service.

#100 — RabbitMqSessionEventTransport has no test coverage

The adapter that carries session events between replicas had no test of any kind. SessionEventEmitterTests covered the emitter and the port through an in-process fake; the only implementation that runs in a real deployment was unverified.

  • Unit tests for the wire format, the poison-message path and the lifecycle guard. The poison path moved out of the consumer lambda into DeliverAsync so it can be tested without a broker.
  • Broker-backed tests for the deployment-shaped case (publish through one transport, receive on another) and that a malformed body on the wire does not stop the consumer.
  • 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. Verified the test is not vacuous by flipping AutomaticRecoveryEnabled off and watching it fail.
  • Broker tests skip with a clear reason when nothing is listening, so dotnet test stays dependency-free. CI declares a rabbitmq:3-management service container and sets REQUIRE_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

  • Renewal. Step submission returns a replacement token. Activity extends the credential, so no separate refresh endpoint is added.
  • Revocation. 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.
  • Frontend. Stores each renewal, drops the token when the journey completes, and distinguishes a spent credential (401 expired, 403 terminal) from a server fault — showing "your session has expired, start again" instead of a generic error banner.

#102 — The persisted analytics event trail has no operator UI

SessionDetail now 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.

  • Server and client events are distinguished by label as well as styling.
  • A gap is never rendered negative — a browser-timestamped client event can land fractionally before the server event it followed.
  • Both empty states are named rather than left blank: a trail with no client events is normal, and an empty trail may be a session whose events aged past retention.
  • Read through operatorFetch and the admin session cookie; the applicant page still never requests it, and the existing assertion in App.routing.test.tsx still holds.

Verification

Backend, no broker 343 passed, 3 skipped
Backend, broker up, REQUIRE_BROKER_TESTS=1 346 passed, 0 skipped
Frontend vitest 175 passed (was 150)
Frontend lint + build clean
Release build of OpenOnboarding.slnx 0 errors

Baseline before this work was 300 backend / 150 frontend.

Noted, not fixed

UploadDocuments and GetDocument on WorkflowController have [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, and GetDocument takes a fileId with no session scoping at all. Out of scope for these four issues; flagging it for a separate one.

🤖 Generated with Claude Code

MaximumTrainer and others added 5 commits September 13, 2026 09:50
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>
@MaximumTrainer
MaximumTrainer merged commit 1cc8620 into main Sep 13, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant