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()