Implement B01-B06 with regression coverage and retained validation evidence. Preserve the production ingress preflight; F04 runtime verification remains open.
16 KiB
B02 — Account isolation and retained-request protection
Implementation date: 2026-09-08. B02 / V01 / F08 resolved locally, with the original failure reproduced before application edits and rejected after the fix. All B02 acceptance criteria pass; the pre-existing standalone TypeScript gate remains open under R01a. No schema migration, deployment, revision protocol, idempotency framework, billing change or UI redesign. B01 is preserved; B03 has not been started.
Original behavior and fresh reproduction
The original V01/F08 evidence and browser probe remain unchanged. A new permanent API regression was run before editing application code. It signed in as A, retained A's profile/request, signed out, signed in as B, and submitted A's content with B's current cookies and CSRF value.
Observed before the fix: HTTP 200, A remained Private A, and B became A draft must never enter B. The expected safe assertion failed. This confirms the original browser result independently through the real API, authentication/session middleware, controller, Identity and isolated SQLite database. Retained before/after facts.
Ownership semantics and root cause
| Operation | Actual contract and authority |
|---|---|
GET/PUT /career/profile |
An account-owned singleton; PUT creates or replaces the current user's profile. UserManager.GetUserAsync(User).Id supplies the owner. CareerProfileSaveRequest contains only Profile and CvText, with no authoritative account/resource ID. The old request carried no session precondition. |
| Profile children | Structured job/education/project Id values are profile-local item keys, not database row IDs or foreign keys. CareerProfileMapper.AddChildren assigns database CareerProfileId and OwnerUserId server-side. Incoming nested ownership properties are not DTO fields. |
| Profile versions/restore | Version numbers are scoped to the principal's profile. Service queries select the owner profile before selecting its version. There is no standalone profile delete/attach endpoint. |
| CV variant read/update/delete/duplicate | Explicit variant IDs. CvVariantService.GetAsync selects Id + OwnerUserId; mutations use that lookup. B receives 404 for A's private variant; safe history listing returns an empty array. |
| CV variant create/job reference | Creates under the principal. Both controller and service check job.Id + authenticated owner. Unavailable and foreign job references use the existing generic 400 response; this convention is preserved. |
| Nearby profile extraction and identity endpoints | Extraction run lookups select run ID plus principal owner. Identity/profile DTOs do not accept role, plan or owner reassignment. Anonymous public CV reads are an intentional publication path, separate from private variant access. |
The server was already preventing B from loading/updating A's explicit object. The demonstrated failure was A's free-text state being submitted as a valid B singleton write. Principal-only ownership checks cannot identify that content's earlier browser context. Reading the latest CSRF cookie in api.ts allowed the stale page to pass CSRF after the shared cookie switched to B. Same-window auth events did not invalidate the other tab, and late promises/caches remained usable.
Exact security boundary
SessionContextMiddleware runs after authentication and authorization. Authenticated unsafe requests must provide exactly one nonempty X-Session-Context matching the validated principal's existing sid claim. Missing context returns 428 / session_context_required; stale/forged/multiple context returns 409 / session_context_mismatch, before the endpoint runs. Error bodies disclose no target existence, user ID or expected session value. The rule applies to cookie and bearer requests, including admin requests.
The header is a rejection-only precondition, never an owner selector or authentication credential. Existing JWT validation and LocalSessionValidator still verify session ID, user ID, expiry, revocation and account state against server records. The caller's actual principal still determines ownership, and object/association authorization still applies even with a matching context. The session ID was already exposed by the authenticated sessions list; possession alone grants no access.
/auth/me now supplies sessionContext and is explicitly non-cacheable. Authenticated reads carrying context are checked too, preventing a stale browser from fetching B's private data into an A page. Unbound reads remain available for bootstrap/direct document links, with their existing owner authorization. Explicit anonymous login/challenge/logout/webhook/public endpoints retain their existing contracts and independent validation.
No new token database, crypto scheme or ownership-bearing DTO was introduced. JSON userId, ownerUserId, accountId, role and plan over-posting cannot select another owner or change privileges. A fresh B request with B's context still cannot read/update/delete/duplicate A's variant or reference A's job.
Frontend behavior and privacy
sessionBoundary.ts binds a JavaScript page to one server session after /auth/me. It never adopts a second session, including a new login for the same account. Requests use that in-memory context, not a freshly read localStorage identity. Storage signals, cookie changes, focus/visibility, a one-second cookie fallback and private-page BFCache restoration invalidate the page.
api.ts captures request context synchronously, aborts in-flight work on invalidation, rejects stale contexts and discards late data/errors. It does not retry a rejected mutation or fetch a new identity to make an old mutation pass. A queued callback in the old page remains invalid even if it runs after account switching.
App.tsx unmounts the entire application/provider tree before navigating to a fresh login document. This drops forms, confirmations, prompts, toasts, cached companies, pending callbacks and route state together. Cancelled auth responses cannot clear a newer bootstrap result. The login page explains in English/Bokmål that private data and unsaved edits were cleared. Anonymous public history remains usable.
Repository review found no React Query, Redux or Zustand private-data store. The principal module cache is useCompanies; a fresh page discards it. Existing saved views and profile-headline storage are already account-keyed; B's views do not read A's keys. Theme/layout/language preferences are not private documents. Existing provider-managed Google/MSAL sign-in state is not treated as an application data cache. No persistent-draft subsystem was added, and A's unsaved draft is intentionally discarded across account boundaries.
Server-created background operations already store the authenticated owner and execute under that explicit owner; a browser switch does not retarget accepted work. B02 prevents newly dispatched stale-session work, not completion of previously accepted A work under A.
Permanent regression coverage
- 13 backend middleware cases: mutation verbs, missing/stale/forged/multiple headers, safe bootstrap versus bound reads, matching owner, bearer-equivalent requests without cookies, missing
sid, admin and anonymous-auth flow behavior. - 15 frontend boundary cases: real Axios interceptors with inert adapters, immutable context, fresh-cookie/stale-callback refusal, logout/account/same-account session signals, cancellation, delayed private reads, stale 401/bootstrap responses, no automatic mutation retry, BFCache and cookie fallback.
- 4 real API/browser integration scenarios: original A→logout→B replay, missing/forged/over-posted context, explicit CV read/update/delete/duplicate/job-association denial, same-account controls, private singleton reads, two-tab privacy clearing and Back navigation followed by a legitimate B save.
- Existing authorization/session/profile/CV tests continue to run. The existing smoke helper now obtains the required context for its direct API mutations; no old-client bypass was added for tests.
After the fix, the original retained request returns 409, with both stored profiles unchanged. Context-free legacy writes return 428. B's valid context does not authorize A's resources. Same-account profile and CV operations still pass.
Verification results and reproduction commands
The full backend run passed 853 tests; one pre-existing IMAP test that performs real DNS was explicitly excluded. The focused backend run passed 91. The full frontend run passed 294 tests across 66 suites. The full browser run passed 17, and the final B02 rerun passed 4, with no failures/skips/flaky outcomes. Backend Release build with warnings as errors, frontend production build, targeted ESLint and git diff --check passed. Standalone TypeScript exited 2 with the single existing diagnostic below. The test manifest retains exact results and hashes of local raw outputs. Results overlap and must not be added together.
Root, PowerShell:
& C:/Users/Connor/.dotnet/dotnet.exe test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-restore --filter 'FullyQualifiedName~SessionContextMiddlewareTests|FullyQualifiedName~CareerProfile|FullyQualifiedName~CvBuilder|FullyQualifiedName~AuthSessionRevocation|FullyQualifiedName~ApiAuthorizationPosture|FullyQualifiedName~LocalAuthIdentity' --logger 'trx;LogFileName=focused.trx' --results-directory .artifacts/b02 -- xUnit.parallelizeTestCollections=false xUnit.maxParallelThreads=1
& C:/Users/Connor/.dotnet/dotnet.exe test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-restore --no-build --filter 'FullyQualifiedName!~ImapServiceSsrfGuardTests.ConnectAsync_rejects_unresolvable_host_without_leaking_dns_detail' --logger 'trx;LogFileName=backend.trx' --results-directory .artifacts/b02 -- xUnit.parallelizeTestCollections=false xUnit.maxParallelThreads=1
& C:/Users/Connor/.dotnet/dotnet.exe build JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-restore -warnaserror
git diff --check
New-Item -ItemType Directory -Force .artifacts/b02 | Out-Null
Copy-Item docs/audits/2026-09-07/validation/evidence/b02-browser-config.ts.txt .artifacts/b02/playwright.offline.config.ts
UI directory, PowerShell:
node node_modules/jest/bin/jest.js src/session-boundary.test.ts src/account-scoped-state.test.tsx src/login-page.test.tsx src/career-workspace-page.test.tsx src/profile-page.test.tsx --runInBand --watchAll=false --json --outputFile=../.artifacts/b02/frontend-focused.json
node node_modules/jest/bin/jest.js --runInBand --watchAll=false --json --outputFile=../.artifacts/b02/frontend.json
node node_modules/typescript/bin/tsc --noEmit --pretty false
node node_modules/eslint/bin/eslint.js src/api.ts src/sessionBoundary.ts src/session-boundary.test.ts src/App.tsx src/views/LoginPage.tsx src/i18n/translations.ts e2e/account-isolation.spec.ts e2e/smoke.spec.ts --max-warnings=0
$env:NEXT_TELEMETRY_DISABLED = '1'
$env:NEXT_PUBLIC_API_BASE_URL = '/api'
node --max-old-space-size=4096 node_modules/next/dist/bin/next build
$env:DOTNET_HOST_PATH = 'C:/Users/Connor/.dotnet/dotnet.exe'
node node_modules/@playwright/test/cli.js test --config=../.artifacts/b02/playwright.offline.config.ts --reporter=json
node node_modules/@playwright/test/cli.js test e2e/account-isolation.spec.ts --config=../.artifacts/b02/playwright.offline.config.ts --reporter=json
All test data is synthetic and the browser/API fixture uses fresh temporary data roots. External browser destinations were blocked and provider keys disabled for the full browser run. No production, real external provider or migration against existing data was used. Initial browser fixture corrections were an exact-label mismatch (Username or email) and Playwright's fixture callback name triggering the React use lint rule; these were test errors, not application regressions.
Existing unrelated type failure: standalone tsc still reports src/interview-prep.test.tsx(120,10): error TS2571: Object is of type 'unknown'. This is the same previously validated test-only error; B02 introduces no new diagnostic. Jest and Next build do not replace that standalone gate. R01a remains open; no typing workaround or unrelated fix was included.
Self-review, limits and rollout
The server rejects stale context even when B supplies a fresh CSRF token, manually crafts A's IDs or switches authentication transport. IDs/ownership in JSON cannot override the principal. Profile-local item keys are not cross-owner associations, and variant job references are checked explicitly in both controller and service. Middleware is after authorization, so it cannot grant an admin/tenant permission or replace session validation.
A caller genuinely authenticated as B can obtain B's context and deliberately submit arbitrary new text to B's own profile. The server cannot infer the origin of free text. That does not grant access to A or make A's old request/session valid. B02 prevents silent context substitution and cross-owner object access; it does not claim to prevent intentional copying or arbitrary same-origin malicious JavaScript from acting with a user's own credentials.
Browser cleanup applies to the updated bundle. A server cannot remotely erase memory in an already-open old bundle; its writes are now denied until the client updates. Standard tabs clear on storage signals; suspended pages are checked on activation, and cookie-only changes have the polling fallback. This is not a claim of instantaneous cross-process memory erasure.
Deploy the compatible API before the updated UI, then ensure clients load the new bundle. Old clients/API consumers must obtain /auth/me.sessionContext for writes; missing-context writes deliberately fail closed. Avoid a mixed API pool where some instances omit/enforce different contracts. Do not remove the server guard to restore old-client compatibility. Rolling back the API reopens B02 unless private mutations are disabled or equivalently protected. No database rollback is involved.
Session context is independent of record revision and creation intent. Same-session stale saves and duplicate creation remain separate open tickets. Already-authorized in-flight work remains bound to its original server principal; cancellation is not a transaction rollback.
Files changed and next ticket
Application: JobTrackerApi/Services/SessionContextMiddleware.cs (new), JobTrackerApi/Program.cs (one middleware registration), JobTrackerApi/Controllers/AuthController.cs; UI src/sessionBoundary.ts (new), src/api.ts, src/App.tsx, src/views/LoginPage.tsx, src/i18n/translations.ts.
Tests: JobTrackerApi.Tests/SessionContextMiddlewareTests.cs (new), UI src/session-boundary.test.ts (new), e2e/account-isolation.spec.ts (new), e2e/smoke.spec.ts (context-aware helper). Documentation: this report, B02 evidence/manifest, validation README/queue/evidence and docs/todo/work.md. Pre-existing B01 files and original audit reproductions are preserved.
Next recommended ticket: B03 — shared PDF admission. Do not begin it automatically. B02 completion is not public-launch or deployment approval; the other validated release gates remain open.