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:
@@ -0,0 +1,83 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using BigRegister.Api.Contracts;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace BigRegister.Tests;
|
||||
|
||||
/// RB-01/BIO-004: GET /uploads/{id}/content and /uploads/status used to take no
|
||||
/// HttpContext at all — a diploma or identity scan was protected by GUID
|
||||
/// unguessability alone, while DELETE on the same resource was owner-scoped.
|
||||
public class UploadAccessTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
|
||||
private const string OtherCitizen = "999999990";
|
||||
|
||||
private async Task<string> UploadAsOwner()
|
||||
{
|
||||
var form = new MultipartFormDataContent();
|
||||
var file = new ByteArrayContent(new byte[] { 1, 2, 3 });
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
|
||||
form.Add(file, "file", "diploma.pdf");
|
||||
form.Add(new StringContent("diploma"), "categoryId");
|
||||
form.Add(new StringContent("local-rb01"), "localId");
|
||||
form.Add(new StringContent("registratie"), "wizardId");
|
||||
var res = await _client.PostAsync("/api/v1/uploads", form);
|
||||
Assert.Equal(HttpStatusCode.Created, res.StatusCode);
|
||||
return (await res.Content.ReadFromJsonAsync<UploadResponse>())!.DocumentId;
|
||||
}
|
||||
|
||||
private Task<HttpResponseMessage> Get(string path, params (string Name, string Value)[] headers)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Get, path);
|
||||
foreach (var (name, value) in headers) req.Headers.Add(name, value);
|
||||
return _client.SendAsync(req);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_owner_can_read_the_bytes()
|
||||
{
|
||||
var id = await UploadAsOwner();
|
||||
Assert.Equal(HttpStatusCode.OK, (await Get($"/api/v1/uploads/{id}/content")).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Another_citizen_gets_404_not_403()
|
||||
{
|
||||
var id = await UploadAsOwner();
|
||||
// 404, not 403: a foreign id must not be distinguishable from one that never existed.
|
||||
Assert.Equal(HttpStatusCode.NotFound,
|
||||
(await Get($"/api/v1/uploads/{id}/content", ("X-Subject", OtherCitizen))).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_behandelaar_can_read_a_linked_document()
|
||||
{
|
||||
var id = await UploadAsOwner();
|
||||
Assert.Equal(HttpStatusCode.OK,
|
||||
(await Get($"/api/v1/uploads/{id}/content", ("X-Medewerker", "medewerker-1"))).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_medewerker_without_the_behandelaar_rol_does_not()
|
||||
{
|
||||
var id = await UploadAsOwner();
|
||||
Assert.Equal(HttpStatusCode.NotFound,
|
||||
(await Get($"/api/v1/uploads/{id}/content",
|
||||
("X-Medewerker", "medewerker-1"), ("X-Rollen", "geen"))).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_reports_another_citizens_localId_as_unknown()
|
||||
{
|
||||
await UploadAsOwner();
|
||||
var res = await Get("/api/v1/uploads/status?localIds=local-rb01", ("X-Subject", OtherCitizen));
|
||||
res.EnsureSuccessStatusCode();
|
||||
var status = (await res.Content.ReadFromJsonAsync<UploadStatusDto>())!;
|
||||
var item = Assert.Single(status.Results);
|
||||
Assert.Equal("unknown", item.Status);
|
||||
Assert.Null(item.DocumentId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user