Skip to content

Commit d90b71d

Browse files
committed
feat(api): optimistic concurrency on report editing (ADR D87)
Closes the gap D86 recorded and left open. A redaction placeholder carries the address of the slot its value came from, which is what makes a single editor's reorder safe — but the address names a slot of the document as it was at the GET, and the PUT resolves it against the document as it is now. Between the two, another editor can reorder the destinations, and destinations[0] then addresses a different bucket than the one the first editor was shown: the wrong section's credential, restored into a section the caller defined. GET /reports/{name}/config now returns an ETag and PUT /reports/{name} honours If-Match with a 412. The header is optional — requiring it would break every client of an endpoint that shipped one release ago, and the realistic scenario is two people in the Builder, which always sends it. The comparison runs against the same stored read Restore later merges against; re-reading would reopen the window inside the handler. The security pass corrected the central choice. The first cut hashed the STORED document, reasoning that it is what Restore resolves against. That made the tag a free offline verification oracle for the values the endpoint exists to withhold: the redacted body and the stored document are byte-identical apart from those values, so a caller could reconstruct candidates, hash them, and confirm a guessed connection string with no failed login to notice. The tag is now over the REDACTED form — the bytes the caller already holds — which carries no information at all and is still the right validator, because an address is invalidated by a change to the document's structure and that structure is fully visible there. A keyed MAC also closes it but its tags differ between instances, so a load-balanced host would answer 412 at random. Code review caught two client-half defects, both of which left the feature working and the user unable to act on it: the 412 was ProblemDetails while the client reads `error`, so the user was told the configuration was invalid and never told to reload; and OriginalVersion was captured once and never advanced, so any second save from the same page — a retry after "Run now" failed to start, a double-click — was a guaranteed 412 naming a conflict with the save that had just succeeded. A successful PUT now returns the new ETag and the wizard adopts it. Not fixed, recorded in the ADR: the check is still check-then-act, so a third writer inside the same instant is not caught. That needs a compare-and-swap on IReportConfigStore — an interface every custom store implements — for a race orders of magnitude smaller than the human one this closes. Each fix verified by reverting it and confirming the new test fails. Full suite: 1 687 green across 33 projects.
1 parent 1322472 commit d90b71d

13 files changed

Lines changed: 531 additions & 29 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@ The `NeoReports.Abstractions` contract follows SemVer strictly.
88

99
## [Unreleased]
1010

11+
### Added
12+
- **Optimistic concurrency on report editing (ADR D87).** `GET /api/reports/{name}/config` now returns
13+
an `ETag`, and `PUT /api/reports/{name}` honours `If-Match`, answering `412 Precondition Failed`
14+
when the stored document changed since it was read. This closes the window D86 recorded and left
15+
open: the redaction placeholder carries the address of the slot its value came from, so if another
16+
editor reorders the destinations between the `GET` and the `PUT`, `destinations[0]` addresses a
17+
different bucket than the one the first editor was shown — and the wrong section's credential is
18+
restored. The header is **optional**, so clients that do not send it keep working exactly as before;
19+
the Builder always sends it.
20+
1121
### Fixed
1222
- **Editing a report in the Builder no longer starts from a blank form (ADR D86).** Reported by the
1323
maintainer: *Edit* prefilled almost nothing the report actually reads from — source type, query, key

DECISIONS.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2129,3 +2129,74 @@ formats was opened in the Builder — every step prefilled — its page size cha
21292129
stored document came back with the password intact, the filter intact, the column type and display
21302130
name intact, `90` still a number, and the new page size applied. Saving with no changes at all
21312131
reproduces the original document.
2132+
2133+
## D87 — Optimistic concurrency on report editing (2026-08-18)
2134+
2135+
Closes the one gap D86 recorded and did not fix.
2136+
2137+
### The window
2138+
2139+
D86's redaction placeholder carries the address of the slot its value came from —
2140+
`${neoreports:redacted:destinations[1]}` — so a client may reorder, remove or retype sections and each
2141+
placeholder still resolves to its own stored value. That is exactly what makes a *single* editor safe.
2142+
2143+
It is blind in the other direction. The address names a slot of the document **as it was at the
2144+
`GET`**, and `PUT` resolves it against the document **as it is now**. Between the two, another editor
2145+
can reorder the destinations, and `destinations[0]` then addresses a different bucket than the one the
2146+
first editor was shown. The result is the wrong section's credential restored into a section the
2147+
caller defined — the precise outcome the address exists to prevent, reached through the stored side
2148+
rather than the incoming one.
2149+
2150+
### The decision: an HTTP validator, not a lock
2151+
2152+
`GET /reports/{name}/config` returns an `ETag`. `PUT /reports/{name}` accepts `If-Match` and answers
2153+
`412 Precondition Failed` when it no longer matches. Three properties matter:
2154+
2155+
- **The validator is over the *redacted* form — the bytes the client was given.** The first cut hashed
2156+
the *stored* document, on the reasoning that it is what `Restore` resolves against. The security pass
2157+
showed that reasoning bought a real weakening: the redacted body and the stored document are
2158+
byte-identical apart from the redacted values, so publishing a hash of the stored document hands any
2159+
caller a free, offline **verification oracle** — reconstruct a candidate document with a guessed
2160+
connection string, hash it, compare. The secret still never leaves the host, but the host would
2161+
confirm guesses about it, at unlimited speed and with no failed login on the database to notice.
2162+
Hashing the redacted form removes that entirely: the tag carries nothing the client does not already
2163+
hold. It is also still the *right* validator, which is the part that makes this cheap — an address is
2164+
invalidated by a change to the document's **structure** (sections added, removed, reordered), and
2165+
that structure is wholly visible in the redacted body. A change to a secret *value* moves no address,
2166+
and an editor sending a placeholder back is asking for whatever is stored now, so resolving to the
2167+
newer secret is the correct outcome rather than a conflict.
2168+
- **A keyed MAC was considered and rejected.** An HMAC under a per-process key also removes the oracle,
2169+
but its tags do not survive a restart and, worse, differ between instances — a load-balanced host
2170+
would answer `412` at random depending on which instance received the save. Deriving the key from
2171+
Data Protection would fix that and costs a dependency and a key-management story, for no benefit over
2172+
simply not hashing the secret in the first place.
2173+
- **`If-Match` is optional, and honoured when sent.** Requiring it would break every existing client
2174+
of an endpoint that shipped one release ago, to protect against a window those clients are not
2175+
exposed to any more than they were yesterday. The realistic scenario is two people in the Builder,
2176+
and the Builder always sends it. A caller that omits the header gets exactly D86's behaviour.
2177+
- **The comparison happens against the same read that `Restore` uses.** Reading the stored document
2178+
once and using it for both the check and the merge is what makes the check meaningful; re-reading
2179+
would reintroduce the window inside the handler.
2180+
2181+
`*` is accepted with its RFC 9110 meaning — "if the resource exists" — which the handler has already
2182+
established by the time it looks.
2183+
2184+
### Why not a lock, a version column, or a last-modified date
2185+
2186+
A lock needs a lifetime and an owner, and a browser tab that closes has neither. A version number in
2187+
the document would be config the user can edit, and editing it is exactly what the field must survive.
2188+
`Last-Modified` has one-second resolution, which is wider than the window it is guarding.
2189+
2190+
### Not covered
2191+
2192+
Two editors whose changes do not overlap still lose one of them — the second gets a `412` and has to
2193+
reload. Merging concurrent edits is out of scope for v1 and would need a per-field model the config
2194+
document does not have.
2195+
2196+
The check is also **check-then-act, not atomic**: `IReportConfigStore` has no compare-and-swap, so a
2197+
third writer landing between the comparison and `SaveAsync` is still not caught. That residual window
2198+
is microseconds of in-process work rather than the human-scale minutes an editor spends on a form,
2199+
which is the window worth closing and the one this closes. Making it atomic would mean a new
2200+
store-contract method — a change to an interface every custom store implements, for a race that needs
2201+
two saves inside the same instant. Recorded rather than papered over: this narrows the window, it does
2202+
not serialise writes.

src/Integrations/NeoReports.AspNetCore/NeoReportsEndpointRouteBuilderExtensions.cs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,13 @@ private static async Task<IResult> GetReportConfigAsync(
407407

408408
try
409409
{
410-
return Results.Text(ReportConfigSecrets.Redact(document), "application/json");
410+
string redacted = ReportConfigSecrets.Redact(document);
411+
412+
// ADR D87. Deliberately a validator over what the CLIENT can see: hashing the stored
413+
// document instead would let a caller confirm a guessed connection string offline, since
414+
// the two differ only in the redacted values.
415+
http.Response.Headers.ETag = ReportConfigETag.For(document);
416+
return Results.Text(redacted, "application/json");
411417
}
412418
catch (ConfigurationException ex)
413419
{
@@ -597,6 +603,26 @@ private static async Task<IResult> ReplaceReportAsync(
597603
statusCode: StatusCodes.Status500InternalServerError);
598604
}
599605

606+
// Optimistic concurrency (ADR D87), checked against the very document Restore is about to
607+
// resolve against — re-reading here would reopen the window it exists to close. A request
608+
// without If-Match states no precondition and behaves exactly as it did before D87.
609+
if (!ReportConfigETag.Allows(http.Request.Headers.IfMatch, stored))
610+
{
611+
// Shaped like every other rejection this endpoint returns, not as ProblemDetails: the
612+
// client reads `error`, so a ProblemDetails body left it with nothing to show and the
613+
// user was told the configuration was invalid instead of being told to reload — which is
614+
// the entire point of answering 412 rather than saving.
615+
return Results.Json(
616+
new
617+
{
618+
error = $"'{name}' changed since you opened it — another editor saved it in the " +
619+
"meantime. Reload the report and apply your change again. Saving now could " +
620+
"resolve a held-back value against a section that is no longer the one it " +
621+
"came from.",
622+
},
623+
statusCode: StatusCodes.Status412PreconditionFailed);
624+
}
625+
600626
string document = await ReadBodyAsync(http, cancellationToken).ConfigureAwait(false);
601627

602628
ReportConfig config;
@@ -668,6 +694,12 @@ private static async Task<IResult> ReplaceReportAsync(
668694

669695
await ReconcileScheduleAsync(name, compiled, scheduler, http, cancellationToken).ConfigureAwait(false);
670696

697+
// The validator for what was just stored (ADR D87). Without it an editor's captured tag goes
698+
// stale the instant its own save succeeds, so any second save from the same page — a retry
699+
// after "Run now" failed to start, a double-click — would be refused with a 412 that names a
700+
// conflict with itself.
701+
http.Response.Headers.ETag = ReportConfigETag.For(document);
702+
671703
var columns = compiled.Schema.Columns.Select(c => c.Name).ToArray();
672704
return Results.Ok(new ReportCreatedResponse(name, columns));
673705
}

src/Integrations/NeoReports.AspNetCore/README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ reports and jobs, via Minimal API.
1616
| GET | `/reports/{name}/config` | the stored config document, credential-bearing values replaced by `${neoreports:redacted}` (ADR D86) → `404` for a code-registered report |
1717
| POST | `/reports` | register a report at runtime from a config document → `201` (`409` if the name exists, `400` if the config is invalid) |
1818
| POST | `/reports/validate` | dry-run compile a config document → `200 { valid, error, name, columns, nameTaken }`; never registers or persists |
19-
| PUT | `/reports/{name}` | replace a runtime-registered report in one step → `200` (`400` if the config is invalid — nothing is changed, `409` for a code-registered report, `404` if unknown) |
19+
| PUT | `/reports/{name}` | replace a runtime-registered report in one step → `200` (`400` if the config is invalid — nothing is changed, `409` for a code-registered report, `404` if unknown, `412` if `If-Match` no longer matches the stored document) |
2020
| DELETE | `/reports/{name}` | remove a runtime-registered report → `204` (`409` for a code-registered report, `404` if unknown) |
2121
| GET | `/capabilities` | source/format/destination type ids the host has registered |
2222
| GET | `/jobs` | list jobs, filterable by `status`/`report`/`since`, paged (`limit` ≤ 200, `offset`) |
@@ -39,6 +39,14 @@ by the reserved placeholder `${neoreports:redacted}`; sending that placeholder b
3939
the user having to retype a connection string, and without the secret ever leaving the host. A
4040
`${VAR}` placeholder is not a secret and comes back verbatim.
4141

42+
**Concurrent edits.** That same response carries an `ETag`, computed over the **stored** document
43+
rather than the redacted body. Send it back as `If-Match` on the `PUT` and the engine answers
44+
`412 Precondition Failed` when another editor saved in between: a placeholder addressed
45+
`destinations[0]` would otherwise resolve against a document whose destinations have since been
46+
reordered, restoring the wrong section's credential (ADR D87). The header is **optional** — a request
47+
that omits it states no precondition and behaves as it did before D87 — and a successful `PUT` returns
48+
the new `ETag`, so an editor can save twice in a row.
49+
4250
`POST /reports/validate?for={name}` resolves the placeholder the same way, so a dry run means the
4351
same thing while editing as it does while creating. `POST /reports` rejects the placeholder outright:
4452
there is no stored document to resolve it against.
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
using System.Security.Cryptography;
2+
using System.Text;
3+
using Microsoft.Extensions.Primitives;
4+
using NeoReports.Abstractions;
5+
using NeoReports.Core.Configuration;
6+
7+
namespace NeoReports.AspNetCore;
8+
9+
/// <summary>
10+
/// The entity-tag for a report configuration document (ADR D87). Lets an editor detect that the
11+
/// document changed under it between reading the configuration and saving it back.
12+
/// </summary>
13+
/// <remarks>
14+
/// <para>
15+
/// Computed over the <b>redacted</b> form — the same bytes the client was given — and never over the
16+
/// stored document. Hashing the stored document made the tag a free, offline <i>verification oracle</i>
17+
/// for the very values the endpoint exists to withhold: the redacted body and the stored document are
18+
/// byte-identical apart from the redacted values, so a holder of the body could reconstruct candidate
19+
/// documents, hash them, and confirm a guessed connection string without ever contacting the database.
20+
/// </para>
21+
/// <para>
22+
/// It is still the right validator. What a placeholder's address can be invalidated by is a change to
23+
/// the document's <i>structure</i> — sections added, removed or reordered — and that structure is
24+
/// wholly visible in the redacted body. A change to a secret <i>value</i> moves no address, and an
25+
/// editor that sends a placeholder back is asking for whatever is stored now, so resolving to the
26+
/// newer secret is the correct outcome rather than a conflict.
27+
/// </para>
28+
/// </remarks>
29+
internal static class ReportConfigETag
30+
{
31+
/// <summary>The strong entity-tag for a stored document, quoted, ready for a header.</summary>
32+
/// <param name="storedDocument">The document as the config store holds it.</param>
33+
/// <exception cref="ConfigurationException">Thrown when the document is not readable.</exception>
34+
/// <remarks>
35+
/// Takes the <i>stored</i> document and redacts it here rather than offering an overload for the
36+
/// already-redacted body: one entry point means no call site can hash the wrong form, which is the
37+
/// mistake this whole type is now shaped to prevent.
38+
/// </remarks>
39+
internal static string For(string storedDocument)
40+
{
41+
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(ReportConfigSecrets.Redact(storedDocument)));
42+
43+
// Base64url so the value needs no escaping inside the quotes, and truncated to 128 bits —
44+
// this detects an edit, it does not authenticate one, and a shorter header reads better in a
45+
// log. Collisions at this width are not reachable by a report being edited twice.
46+
return '"' + Convert.ToBase64String(hash, 0, 16).Replace('+', '-').Replace('/', '_').TrimEnd('=') + '"';
47+
}
48+
49+
/// <summary>
50+
/// Whether the request may proceed under RFC 9110 <c>If-Match</c>: no header at all means "no
51+
/// precondition" (D87 keeps the header optional so clients from before it still work), <c>*</c>
52+
/// means "if the resource exists", and otherwise one of the supplied tags has to match.
53+
/// </summary>
54+
/// <param name="ifMatch">The raw <c>If-Match</c> header values, as the request carries them.</param>
55+
/// <param name="storedDocument">The document the request is about to be applied to.</param>
56+
/// <exception cref="ConfigurationException">Thrown when the document is not readable.</exception>
57+
internal static bool Allows(StringValues ifMatch, string storedDocument)
58+
{
59+
if (StringValues.IsNullOrEmpty(ifMatch))
60+
return true;
61+
62+
string[] candidates = ifMatch
63+
.Where(value => !string.IsNullOrWhiteSpace(value))
64+
.SelectMany(value => value!.Split(
65+
',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
66+
.ToArray();
67+
68+
// "*" first, and before computing anything: the tag now costs a parse of the stored document,
69+
// and a wildcard has already said it does not care what the tag is.
70+
if (candidates.Contains("*", StringComparer.Ordinal))
71+
return true;
72+
73+
// A weak tag ("W/…") never satisfies If-Match, which requires strong comparison — it simply
74+
// fails to equal the strong tag rather than needing a rule of its own.
75+
if (candidates.Length > 0)
76+
return candidates.Contains(For(storedDocument), StringComparer.Ordinal);
77+
78+
// A header present but empty carries no tag to match, so it states no precondition either.
79+
return true;
80+
}
81+
}

src/UI/NeoReports.UI/Pages/Builder.razor

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,10 @@
211211
_configUnavailable = config is not { Outcome: ApiConfigOutcome.Ok, Document: { } document }
212212
|| !BuilderConfigMapper.Hydrate(Wizard, document, RegisteredSourceType);
213213

214+
// Kept whatever the outcome: Hydrate clears it through Reset(), and a null here simply means
215+
// the save states no precondition, which is the pre-D87 behaviour rather than a failure.
216+
Wizard.OriginalVersion = config.Version;
217+
214218
if (_configUnavailable)
215219
return;
216220

src/UI/NeoReports.UI/Pages/BuilderReview.razor

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,9 +160,21 @@
160160
private async Task<ApiCreateResult> PersistAsync()
161161
{
162162
string configJson = BuilderConfigMapper.ToConfigJson(Wizard);
163-
return Wizard.IsEditing
164-
? await Api.TryReplaceReportAsync(Wizard.EditingOriginalName, configJson)
165-
: await Api.TryCreateReportAsync(configJson);
163+
if (!Wizard.IsEditing)
164+
return await Api.TryCreateReportAsync(configJson);
165+
166+
ApiCreateResult result = await Api.TryReplaceReportAsync(
167+
Wizard.EditingOriginalName, configJson, Wizard.OriginalVersion);
168+
169+
if (result.Outcome == ApiCreateOutcome.Created && result.Version is not null)
170+
Wizard.OriginalVersion = result.Version;
171+
172+
// A successful save invalidates the validator this page is holding — it described the
173+
// document as it was BEFORE the save. Keeping the old one would refuse the next save from
174+
// this same page (a retry after "Run now" failed to start, a double-click) as a conflict
175+
// with itself. Only advanced on success; a rejected save changed nothing.
176+
177+
return result;
166178
}
167179

168180
private static string DescribeFailure(ApiCreateOutcome outcome) => outcome switch

src/UI/NeoReports.UI/Services/BuilderState.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,13 @@ public string ConnectionSummary
202202
/// </summary>
203203
public string? OriginalDocument { get; set; }
204204

205+
/// <summary>
206+
/// The entity-tag the configuration was read under (ADR D87), echoed back as <c>If-Match</c> when
207+
/// the edit is saved so the engine can refuse it if another editor changed the report meanwhile.
208+
/// <c>null</c> when creating, or when the engine did not supply one.
209+
/// </summary>
210+
public string? OriginalVersion { get; set; }
211+
205212
/// <summary>
206213
/// Identifies the source the loaded document described, so the patch can tell "the user changed
207214
/// the page size" from "the user pointed this report at a different source". Properties from the
@@ -273,6 +280,7 @@ public void Reset()
273280
IsEditing = false;
274281
EditingOriginalName = "";
275282
OriginalDocument = null;
283+
OriginalVersion = null;
276284
LoadedSourceIdentity = "";
277285
AdditionalDestinationCount = 0;
278286
AdditionalOutputCount = 0;

0 commit comments

Comments
 (0)