Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,14 @@ response carries `applicantToken`, an HMAC-signed JWT the browser sends as
extends the credential, so a journey being worked on never outlives its own token, and there is no
separate refresh endpoint to protect. Operators get no token back: their own credential already
covers the session.
- **Documents.** Upload and download are scoped to the session in the route, like every other
session endpoint. Upload follows the terminal-status rule below. Download also scopes the
*lookup*: a file id is resolved only if an upload recorded against that session and node carries
it, so holding an id for another applicant's document does not make it readable through your own
session. A file that exists but belongs elsewhere returns `404`, never `403` — a refusal would
confirm the id is real.
- **Terminal sessions.** Once a session is `Completed` or `Abandoned` its token is refused for
writes — step submission and `POST /api/analytics/events` both return `403`. Reads still work, so
writes — step submission, document upload and `POST /api/analytics/events` all return `403`. Reads still work, so
the completion screen renders. This bounds how long a credential left behind on a shared machine
stays useful: the visit, not the full lifetime. Operators are unaffected; the rule is about a
stale applicant credential, not about who may act on a finished session. Abandoning an already
Expand Down
17 changes: 16 additions & 1 deletion src/backend/OpenOnboarding.Api/Controllers/WorkflowController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,14 @@ public async Task<IActionResult> UploadDocuments(
IList<IFormFile> files,
CancellationToken cancellationToken)
{
// Same ownership rule as every other session endpoint. The route's sessionId was previously
// trusted, so any applicant token could write documents into any session - and the resulting
// Submission row reads in the operator console as the real applicant's upload. Checked
// before the files are read, so a refused caller never reaches storage or the virus scanner.
var session = await sessionAnalyticsService.GetSessionAsync(sessionId, cancellationToken);
var authResult = await authorizationService.AuthorizeAsync(User, session, SessionOwnershipRequirement.Write);
if (!authResult.Succeeded) return Forbid();

if (files == null || files.Count == 0)
return BadRequest(new ProblemDetails { Title = "No files provided.", Status = 400 });

Expand Down Expand Up @@ -292,7 +300,14 @@ public async Task<IActionResult> GetDocument(
[FromRoute] string fileId,
CancellationToken cancellationToken)
{
var (stream, info) = await workflowService.GetDocumentAsync(fileId, cancellationToken);
var session = await sessionAnalyticsService.GetSessionAsync(sessionId, cancellationToken);
var authResult = await authorizationService.AuthorizeAsync(User, session, SessionOwnershipRequirement.Read);
if (!authResult.Succeeded) return Forbid();

// The session and node are passed through rather than left in the route as decoration.
// Ownership alone is not enough: it would still let the owner of one session ask for
// another's document through their own route. Anything not recorded here is a 404.
var (stream, info) = await workflowService.GetDocumentAsync(sessionId, nodeId, fileId, cancellationToken);
return File(stream, info.ContentType, info.FileName);
}

Expand Down
Loading
Loading