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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user