Files
atomic-design-poc/backend/src/BigRegister.Api/Data/DocumentStore.cs
T
ehoandClaude Opus 5 a394950a1d fix(backend): reject foreign documentIds on submit and draft-sync (WP-68 F1)
submit and draft-sync took document ids straight from the request body with no
ownership check: a caller who knew a foreign document's id could attach another
citizen's upload to their own aanvraag (surfacing on the behandelaar's beoordeling
screen, POSTed to OpenZaak as their zaakinformatieobject) and permanently block the
victim's own delete by flipping Linked=true. ADR-0001 holds the FE has no authority;
this trusted it anyway.

Adds DocumentStore.ForeignIds(ids, owner) and calls it from both write paths before
any write, 400 ProblemDetails on a mismatch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:24:32 +02:00

203 lines
7.6 KiB
C#

namespace BigRegister.Api.Data;
/// <summary>
/// Stored document: metadata + bytes. The demo persists bytes in the SQLite file
/// (WP-22) purely so a re-opened wizard can preview/download what was uploaded — a
/// real backend persists them to blob storage keyed by DocumentId. Bytes are never
/// serialized into a JSON response; only the dedicated content endpoint streams them.
/// </summary>
public sealed record StoredDocument(
string DocumentId, string LocalId, string CategoryId, string WizardId,
string FileName, long SizeBytes, string ContentType, byte[] Content, string Owner, DateTimeOffset UploadedAt)
{
public bool Linked { get; set; }
/// <summary>The OpenZaak DRC enkelvoudiginformatieobject's URL, set once Upload (WP-51)
/// registers one — null under the local source. Persisted so the later zaak-link step can
/// find it without re-uploading; not part of the positional constructor, same reasoning as
/// <see cref="Linked"/> (every existing `new StoredDocument(...)` call site keeps working).</summary>
public string? DrcUrl { get; set; }
}
/// <summary>Id is EF Core's auto-increment key — not part of the positional
/// constructor, so every existing `new AuditEntry(at, action, ...)` call site
/// keeps working unchanged; EF Core assigns it on insert.</summary>
public sealed record AuditEntry(DateTimeOffset At, string Action, string DocumentId, string CategoryId, string Actor)
{
public long Id { get; init; }
}
/// <summary>
/// EF Core/SQLite-backed document store + audit log (WP-22 — was a static
/// Dictionary). ponytail: one global lock, same as before — SQLite tolerates only
/// one writer at a time anyway, and this process already serialized all access
/// through a single gate, so it now doubles as a coarse single-writer guard for
/// the DB file. The audit log holds metadata only (never file content or other PII).
/// </summary>
public static class DocumentStore
{
/// The single seeded user (the demo has no real auth; ownership = this id) — a real,
/// elfproef-valid 9-digit BSN (src/app/shared/kernel/bsn.ts's own checksum), distinct from
/// SeedData.Registration.BigNummer ("19012345601", 11 digits — the seeded doctor's BIG-nummer,
/// a different Dutch identifier scheme). Previously this constant reused that BigNummer value
/// as a stand-in BSN, which is invalid Dutch-BSN shape: harmless against the local store, but
/// a real OpenZaak instance rejects it outright — GET /api/v1/applications 500s (`inpBsn` query
/// filter validation) and every submit's rol-creation POST fails (`inpBsn` max_length) once
/// Zgw:Enabled=true. Not "111222333" or "999888777" — both already mean a different fixture
/// identity (the OpenZaak-harness/unit-test caller, and ApplicationTests' "other citizen").
public const string DemoOwner = "123456782";
private static readonly object _gate = new();
public static StoredDocument Add(string localId, string categoryId, string wizardId, string fileName, string contentType, byte[] content, string owner)
{
var doc = new StoredDocument(Guid.NewGuid().ToString(), localId, categoryId, wizardId, fileName, content.LongLength, contentType, content, owner, DateTimeOffset.UtcNow);
lock (_gate)
{
using var db = Db.Create();
db.Documents.Add(doc);
db.SaveChanges();
}
Audit("upload", doc.DocumentId, categoryId, owner);
return doc;
}
public static StoredDocument? Get(string documentId)
{
lock (_gate)
{
using var db = Db.Create();
return db.Documents.Find(documentId);
}
}
/// Status for the poll-on-return pattern: a known localId is "complete" (it
/// arrived), an unknown one is still in flight / never started.
public static IReadOnlyList<StoredDocument> ByLocalIds(IEnumerable<string> localIds)
{
var set = localIds.ToHashSet();
lock (_gate)
{
using var db = Db.Create();
return db.Documents.Where(d => set.Contains(d.LocalId)).ToList();
}
}
/// <summary>Documents by DocumentId (WP-65's beoordeling detail reads an aanvraag's already-
/// linked documents) — the DocumentId-keyed counterpart of <see cref="ByLocalIds"/>, which is
/// keyed by the wizard's own LocalId instead.</summary>
public static IReadOnlyList<StoredDocument> ByIds(IEnumerable<string> documentIds)
{
var set = documentIds.ToHashSet();
lock (_gate)
{
using var db = Db.Create();
return db.Documents.Where(d => set.Contains(d.DocumentId)).ToList();
}
}
/// <summary>Which of the given ids do NOT resolve to a document owned by <paramref name="owner"/>
/// (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.</summary>
public static IReadOnlyList<string> ForeignIds(IEnumerable<string> 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();
}
}
/// <summary>Persist the DRC url an OpenZaak upload (WP-51) registered for a document.</summary>
public static void SetDrcUrl(string documentId, string drcUrl)
{
lock (_gate)
{
using var db = Db.Create();
var d = db.Documents.Find(documentId);
if (d is null) return;
d.DrcUrl = drcUrl;
db.SaveChanges();
}
}
/// Mark digital documents as linked to a finalised submission (blocks user delete).
public static void Link(IEnumerable<string> documentIds)
{
lock (_gate)
{
using var db = Db.Create();
foreach (var id in documentIds)
{
var d = db.Documents.Find(id);
if (d is not null) d.Linked = true;
}
db.SaveChanges();
}
}
public enum DeleteResult { Ok, NotFound, Linked }
/// User delete: owner-scoped; blocked once linked to a finalised submission.
public static DeleteResult DeleteOwned(string documentId, string owner)
{
string categoryId;
lock (_gate)
{
using var db = Db.Create();
var d = db.Documents.Find(documentId);
if (d is null || d.Owner != owner) return DeleteResult.NotFound;
if (d.Linked) return DeleteResult.Linked;
categoryId = d.CategoryId;
db.Documents.Remove(d);
db.SaveChanges();
}
Audit("delete-user", documentId, categoryId, owner);
return DeleteResult.Ok;
}
/// Admin delete: bypasses ownership, unlinks, and (seam) flags the submission for
/// review so a caseworker is notified. Returns false if the document is unknown.
public static bool AdminDelete(string documentId, string actor)
{
string categoryId;
lock (_gate)
{
using var db = Db.Create();
var d = db.Documents.Find(documentId);
if (d is null) return false;
categoryId = d.CategoryId;
db.Documents.Remove(d);
db.SaveChanges();
}
Audit("delete-admin", documentId, categoryId, actor);
return true;
}
public static void Audit(string action, string documentId, string categoryId, string actor)
{
lock (_gate)
{
using var db = Db.Create();
db.AuditEntries.Add(new AuditEntry(DateTimeOffset.UtcNow, action, documentId, categoryId, actor));
db.SaveChanges();
}
}
public static IReadOnlyList<AuditEntry> AuditLog
{
get
{
lock (_gate)
{
using var db = Db.Create();
return db.AuditEntries.ToList();
}
}
}
}