You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
Copy file name to clipboardExpand all lines: src/Integrations/NeoReports.AspNetCore/README.md
+9-1Lines changed: 9 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -16,7 +16,7 @@ reports and jobs, via Minimal API.
16
16
| GET |`/reports/{name}/config`| the stored config document, credential-bearing values replaced by `${neoreports:redacted}` (ADR D86) → `404` for a code-registered report |
17
17
| POST |`/reports`| register a report at runtime from a config document → `201` (`409` if the name exists, `400` if the config is invalid) |
18
18
| 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) |
20
20
| DELETE |`/reports/{name}`| remove a runtime-registered report → `204` (`409` for a code-registered report, `404` if unknown) |
21
21
| GET |`/capabilities`| source/format/destination type ids the host has registered |
22
22
| 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
39
39
the user having to retype a connection string, and without the secret ever leaving the host. A
40
40
`${VAR}` placeholder is not a secret and comes back verbatim.
41
41
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
+
42
50
`POST /reports/validate?for={name}` resolves the placeholder the same way, so a dry run means the
43
51
same thing while editing as it does while creating. `POST /reports` rejects the placeholder outright:
44
52
there is no stored document to resolve it against.
0 commit comments