fix(uploads): authorize the document-content and status endpoints (RB-01)

GET /uploads/{documentId}/content took only (string documentId) — no
HttpContext, so no authorization was possible. It streams diploma and
identity scans, protected by GUID unguessability alone, while DELETE on the
same resource has always been owner-scoped. GET /uploads/status had the same
shape and confirmed whether any client-chosen localId exists, plus its
documentId.

Both now take HttpContext. Content is readable by the owning
ZorgverlenerCaller or a caller passing Authz.CanBeoordelen — matched on the
caller kind rather than branched on a boolean, because ctx.Zorgverlener()
throws for a MedewerkerCaller and the behandelportal's beoordeling screen is
a legitimate reader. Status is scoped to ctx.Zorgverlener().Bsn via a new
owner parameter on DocumentStore.ByLocalIds (one call site).

404, not 403, on both: a foreign document id must not be distinguishable
from one that never existed, and a foreign localId reads back as "unknown".

Residual, recorded in the implementation note: both callers reach the URL as
a plain browser navigation (<a href> / previewUrl), which carries no identity
header and no interceptor, so StubIdentityProvider resolves it to the seeded
citizen. That is BIO-002 and belongs to RB-09; the links keep working today
only because one citizen owns every document in the POC.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-27 10:48:04 +02:00
co-authored by Claude Opus 5
parent 176e5baef8
commit a2e935d1d8
4 changed files with 156 additions and 6 deletions
+14 -4
View File
@@ -228,10 +228,18 @@ api.MapPost("/uploads", async (HttpRequest request, HttpContext ctx, IDocumentSo
// Serve stored bytes so a re-opened wizard can preview/download an upload. Inline
// for pdf/image (browser renders it), attachment otherwise (download).
api.MapGet("/uploads/{documentId}/content", (string documentId) =>
// Scoped like DELETE on the same resource (RB-01/BIO-004): the owning citizen, or a
// behandelaar reading an aanvraag's linked documents. A foreign id 404s rather than
// 403s, so the endpoint never confirms that a document id exists.
api.MapGet("/uploads/{documentId}/content", (string documentId, HttpContext ctx) =>
{
var doc = DocumentStore.Get(documentId);
if (doc is null) return Results.NotFound();
var allowed = ctx.Caller() switch
{
ZorgverlenerCaller z => doc?.Owner == z.Bsn,
var caller => Authz.CanBeoordelen(caller),
};
if (doc is null || !allowed) return Results.NotFound();
var inline = doc.ContentType == "application/pdf" || doc.ContentType.StartsWith("image/");
return Results.File(doc.Content, doc.ContentType, fileDownloadName: inline ? null : doc.FileName);
})
@@ -239,10 +247,12 @@ api.MapGet("/uploads/{documentId}/content", (string documentId) =>
.Produces(StatusCodes.Status404NotFound);
// Poll-on-return: which of these client localIds have arrived at the BFF.
api.MapGet("/uploads/status", (string? localIds) =>
api.MapGet("/uploads/status", (string? localIds, HttpContext ctx) =>
{
var ids = (localIds ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var found = DocumentStore.ByLocalIds(ids).ToDictionary(d => d.LocalId);
// Owner-scoped (RB-01/BIO-004): someone else's localId reads back as "unknown", the
// same answer an id that never existed gets.
var found = DocumentStore.ByLocalIds(ids, ctx.Zorgverlener().Bsn).ToDictionary(d => d.LocalId);
var results = ids.Select(id => found.TryGetValue(id, out var d)
? new UploadStatusItemDto(id, "complete", d.DocumentId)
: new UploadStatusItemDto(id, "unknown", null)).ToList();