diff --git a/backend/src/BigRegister.Api/Data/DocumentStore.cs b/backend/src/BigRegister.Api/Data/DocumentStore.cs index ffcf436..1f2faa0 100644 --- a/backend/src/BigRegister.Api/Data/DocumentStore.cs +++ b/backend/src/BigRegister.Api/Data/DocumentStore.cs @@ -96,6 +96,22 @@ public static class DocumentStore } } + /// Which of the given ids do NOT resolve to a document owned by + /// (unknown id or owned by someone else) — named for what it returns (the offending ids), so a + /// caller can 400 with the specific ids rather than a bare boolean. Guards submit/draft-sync + /// against a citizen attaching another citizen's upload to their own aanvraag. + public static IReadOnlyList ForeignIds(IEnumerable documentIds, string owner) + { + var ids = documentIds.ToList(); + lock (_gate) + { + using var db = Db.Create(); + var owned = db.Documents.Where(d => ids.Contains(d.DocumentId) && d.Owner == owner) + .Select(d => d.DocumentId).ToHashSet(); + return ids.Where(id => !owned.Contains(id)).ToList(); + } + } + /// Persist the DRC url an OpenZaak upload (WP-51) registered for a document. public static void SetDrcUrl(string documentId, string drcUrl) { diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index b2faab0..0724a9c 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -314,9 +314,19 @@ api.MapPost("/applications", (CreateApplicationRequest req, HttpContext ctx) => // Draft sync per step — idempotent; keep it debounced on the client (it is chatty). api.MapPut("/applications/{id}", (string id, DraftSyncRequest req, HttpContext ctx) => - ApplicationStore.SyncDraft(id, ctx.Zorgverlener().Bsn, req.Draft, req.StepIndex, req.StepCount, req.DocumentIds) - ? Results.NoContent() : Results.NotFound()) +{ + var owner = ctx.Zorgverlener().Bsn; + // A citizen may only reference their own uploads in a draft — reject before the sync + // writes a foreign document id into the aanvraag (ADR-0001: the FE holds no authority). + if (req.DocumentIds is { } ids && DocumentStore.ForeignIds(ids, owner) is { Count: > 0 } foreign) + return Results.Problem( + detail: $"Onbekend of niet-eigen document(en): {string.Join(", ", foreign)}.", + statusCode: StatusCodes.Status400BadRequest); + return ApplicationStore.SyncDraft(id, owner, req.Draft, req.StepIndex, req.StepCount, req.DocumentIds) + ? Results.NoContent() : Results.NotFound(); +}) .Produces(StatusCodes.Status204NoContent) +.ProducesProblem(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status404NotFound); // Cancel a Concept (cascades to its unlinked documents). Submitted aanvragen cannot @@ -353,6 +363,13 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re var docs = req.Documents; var documentIds = docs?.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!).ToList(); + // A citizen may only submit their own uploads — reject before the submit writes a + // foreign document id onto the aanvraag (ADR-0001: the FE holds no authority). + if (documentIds is { Count: > 0 } && DocumentStore.ForeignIds(documentIds, ctx.Zorgverlener().Bsn) is { Count: > 0 } foreignIds) + return Results.Problem( + detail: $"Onbekend of niet-eigen document(en): {string.Join(", ", foreignIds)}.", + statusCode: StatusCodes.Status400BadRequest); + var submitted = ApplicationStore.Submit(id, ctx.Zorgverlener().Bsn, reject, autoApprovable, documentIds); if (submitted is null) return Results.Conflict(); @@ -401,6 +418,7 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re return Results.Ok(new SubmitApplicationResponse(referentie, status)); }) .Produces() +.ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status409Conflict) .Produces(StatusCodes.Status404NotFound); diff --git a/backend/tests/BigRegister.Tests/ApplicationTests.cs b/backend/tests/BigRegister.Tests/ApplicationTests.cs index 7a773b9..c8034a4 100644 --- a/backend/tests/BigRegister.Tests/ApplicationTests.cs +++ b/backend/tests/BigRegister.Tests/ApplicationTests.cs @@ -172,6 +172,51 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture } } + // --- WP-68 (F1): a citizen may only reference their own uploads — submit/draft-sync must + // reject a foreign documentId rather than silently attaching it. --- + + private static async Task UploadAs(HttpClient client, string owner, string localId) + { + var content = new MultipartFormDataContent(); + var file = new ByteArrayContent(new byte[] { 1, 2, 3 }); + file.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf"); + content.Add(file, "file", "d.pdf"); + content.Add(new StringContent("diploma"), "categoryId"); + content.Add(new StringContent(localId), "localId"); + content.Add(new StringContent("registratie"), "wizardId"); + var req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/uploads") { Content = content, Headers = { { "X-Subject", owner } } }; + var res = await client.SendAsync(req); + Assert.Equal(HttpStatusCode.Created, res.StatusCode); + return (await res.Content.ReadFromJsonAsync())!; + } + + [Fact] + public async Task Submitting_a_foreign_documentId_is_rejected_and_leaves_it_deletable_by_its_owner() + { + var foreignDoc = await UploadAs(_client, "999888777", Guid.NewGuid().ToString()); + var a = await Create("registratie"); + + var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", + new { diplomaHerkomst = "duo", documents = new[] { new { categoryId = "diploma", channel = "digital", documentId = foreignDoc.DocumentId } } }); + Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode); + + // The rejected submit must not have flipped the foreign document's Linked flag — its + // owner can still delete it. + var deleteReq = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/uploads/{foreignDoc.DocumentId}") { Headers = { { "X-Subject", "999888777" } } }; + Assert.Equal(HttpStatusCode.NoContent, (await _client.SendAsync(deleteReq)).StatusCode); + } + + [Fact] + public async Task Draft_sync_with_a_foreign_documentId_is_rejected() + { + var foreignDoc = await UploadAs(_client, "999888777", Guid.NewGuid().ToString()); + var a = await Create("registratie"); + + var res = await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}", + new { draft = new { }, stepIndex = 0, stepCount = 1, documentIds = new[] { foreignDoc.DocumentId } }); + Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode); + } + // --- Auto-approval is computed on read: exercise the window boundary without waiting. --- private static Aanvraag Accepted(bool autoApprovable) => new()