Skip to content

Stamp updated_by with the requesting seat's principal, so a web edit names who made it - #3151

Merged
erikdarlingdata merged 7 commits into
devfrom
fix/2550-updated-by-subject
Sep 7, 2026
Merged

Stamp updated_by with the requesting seat's principal, so a web edit names who made it#3151
erikdarlingdata merged 7 commits into
devfrom
fix/2550-updated-by-subject

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Closes #2550 — the residual it was rescoped to.

9da00121e landed OIDC token validation and claim-to-role mapping, delivering three of the issue's four benefits. The fourth was identity, and it did not land: DarlingWebEndpoints.cs still read "The web surface has no per-user identity, so a constant is honest" — verbatim the comment the issue opened by quoting — and every custom view created or edited over the web was stamped web, so the surface could not answer who changed this dashboard.

Nothing was missing except the wiring. DarlingWebSeat.EditorPrincipal (Subject ?? "web") and DarlingWebSeat.FromContext both already existed, both were already unit-tested, and neither had a production caller. The auth middleware's own comment named its two consumers — "/api/session, the updated_by stamps" — while neither of them read the seat it published. No schema change, as the issue predicted: config.custom_views.updated_by is unbounded nullable text (PgMigrations V31) and a subject is capped at 251 characters, so it already fits.

What changed

Both custom-view write sites and /api/session now read DarlingWebSeat.FromContext(context). A web edit names the person who made it; a read-only seat is rendered as one.

/api/session is here rather than filed because it is the same one-line defect in the same function: DarlingConfig's viewerRoles documents the SPA hiding edit affordances "when /api/session reports can_edit: false", and the endpoint returned a hardcoded true, so that documented behaviour was unreachable and a viewer saw buttons whose every click the middleware then refused. Nothing changes for a deployment without OIDC — the shared-token seat's CanEdit is true.

The two decisions

What updated_by carries with no OIDC — kept as one heterogeneous column. The literal web, which makes the column sometimes a person and sometimes a provenance marker. Rejected the separate column: the only consumer is a rendered byline (views.js: "updated " + relTime(...) + " by " + updated_by), which wants exactly one value, and splitting it would make every reader join two columns to recover the string it already displays. Nothing filters, sorts or groups on it — the two reads are bare projections in ListSql/GetSql, and ListSql orders by name — so heterogeneity costs nothing a query has to cope with. It is also less of a change than it sounds: an IdP configured to stamp an opaque sub GUID already produces values no more person-like than web. Recorded on the constant so the next reader gets the argument, plus when to revisit it.

MCP keeps its constant. MCP authenticates a client on its own network block with its own token and has no sign-in flow to carry a person through, so there is no subject to prefer — the same argument that used to justify a constant on the web surface, still true there. Left alone as the issue asked, but now pinned and explained, so the asymmetry survives someone reasonably "finishing the job" on a surface that has nothing to finish it with.

The seat the honest answer newly exposes

Making can_edit honest makes a UI path reachable that had only ever described a failed probe. editor.js and notebook.js both told a can_edit: false caller "Couldn't confirm your session… Reload the page to edit" — correct for a broken probe, and wrong for an OIDC viewer, who is correctly signed in and will never gain edit rights by reloading. views-api.js's doc comment asserted the old always-true behaviour outright.

The client could not tell the two apart, because both arrive as can_edit: false. The fail-closed default is built client-side, so it now marks itself — {can_edit: false, probe_failed: true} — and the server's own answer never carries the flag. Each composer branches its notice on it. This is in the PR rather than filed because my change is what made the wrong copy reachable; shipping the honest server answer and filing the misleading message it exposes would be shipping the half nobody sees.

The guard

Derived from the property, not from reading the existing assertions: every custom-view write arriving over the web stamps updated_by with the requesting seat's own EditorPrincipal, and no web write site stamps a constant of its own. Enumerating how that becomes false found two things reading the assertions would not have.

The write-site population is discovered from the source rather than listed, because a pin naming today's two sites is blind to tomorrow's third — with a floor on the discovered count, since a per-site loop over an empty population passes vacuously. A second census, over the whole service project rather than one file, catches the constant being reached for in a file the first scan never opens. The one route with no symptom at all gets its own pin: if the middleware stopped publishing the seat, FromContext returns its documented shared-token default and every named operator's rows silently revert to web with nothing thrown and nothing logged.

Two coverage gaps found by mutating rather than by reading:

  • A check that could not fail. The per-site assertion tested for the substring EditorPrincipal — and WebEditorPrincipal ends in EditorPrincipal, so it passed on precisely the constant-stamping code it existed to reject. Reverting one write site left all eight green. Now matched as a whole identifier.
  • A check satisfied by prose. The new client-side guard asserted probe_failed appeared anywhere in views-api.js — and the mechanism's own doc comment mentions it, so removing the flag from the code left the check green. Scoped to the session probe's body.
  • A falsifier that could not discriminate. The live store round-trip passed "web" and asserted "web" came back, which a store that dropped the argument and hardcoded the constant satisfied perfectly. The fixture now uses distinct subject-shaped placeholders, and the update's is different from the create's — which also pins UpdateSql's $5 end-to-end for the first time, since nothing asserted the update's stamp at all before.

Sixteen mutations, each applied in isolation and confirmed present in the file before running. Both single-site reverts red individually, naming the offending line and argument list, so coverage is per-site rather than aggregate. A new third write site passing a bare "web" literal reds — the literals appear blanked in the failure message, which is why the assertion is positive: a negative check for the constant could not have seen that spelling. Also red: the constant referenced from a third file, the middleware not publishing the seat, publishing it after forwarding, /api/session back to true, EditorPrincipal dropping the subject, the fallback's spelling changing, an MCP site drifting off its constant, the store's write methods renamed so discovery finds nothing, one composer's notice un-branched, and the client default no longer marking itself.

A third route came out of enumerating rather than reading: every argument around updatedBy is a string, so a positional swap with description compiles and stamps the description. That one is closed at the compiler — the parameter is named at all four write sites, and the swap is now CS8323 — with the guards requiring the name so it cannot quietly go away.

Verification, scoped

The new pins were executed against the real built assembly — Darling.Tests targets net10.0-windows and cannot run on macOS, so the actual test file was compiled into a net10.0 console harness with the genuine xunit.v3.assert: 8 passed, and each mutation above observed red there. Darling PostgreSQL tests is the arbiter for the live round-trip, build for the Windows suites.

What this does not prove: these are source-derived. They establish that each write site passes the seat's principal and that the middleware publishes the seat — not that an end-to-end request lands a subject in the column. There is no HTTP-level harness for these endpoints and standing one up needs a live Postgres and a real bind. The behavioural half is the seat's own resolution over real HttpContext instances plus the live store round-trip; the join between them is asserted from the source. Said plainly because "it's tested" unscoped is how #2213's wiring gap survived a green suite.

CHANGELOG

Entry text for the [Unreleased] block, not applied here:

- Web dashboard custom views now record WHO changed them: `updated_by` carries the authenticated OIDC
  subject instead of the constant `web`, and `/api/session` reports the requesting seat's own edit right
  so a read-only seat is rendered as one. A deployment without OIDC is unchanged — a seat with no subject
  still stamps `web`. The MCP surface keeps its `mcp` provenance constant, having no per-user identity to
  stamp. No schema change.
- The web dashboard's composer now tells a read-only seat that its account is read-only, rather than
  reporting a failed session probe and inviting a reload that cannot grant edit rights.

Out of scope, described rather than fixed

The write population is closed at four sites, checked rather than assumed: two web (CreateAsync/UpdateAsync in DarlingWebEndpoints) and two MCP (DarlingMcpCustomViewTools). There is no third surface — ViewerDataService's two custom_views mentions are the schema-probe sentinel and a comment, and MainWindow.Tags.cs's is about the viewer database role's grant, not a write path. The WPF viewer never stamps this column.

MCP per-user identity is the one genuinely deferred thing, and it is a different credential model rather than a line change: MCP would need a sign-in flow before it had a subject to stamp.

Do not conflate the two updated_by columns. config.config_service.updated_by is a different table, stamped 'cli' by the endpoint-toggle commands, and is untouched here.

The OIDC work resolved the subject, published the seat on HttpContext.Items,
and left both consumers reading literals: the two custom-view write sites
passed WebEditorPrincipal and /api/session answered a hardcoded true. Both
now read DarlingWebSeat.FromContext(context), so a web edit names the person
who made it and a read-only seat is rendered as one.

The MCP stamp stays an unconditional constant and now says why: MCP has no
sign-in flow to carry a person through, so there is no subject to prefer.

Guards derived from the property's violation routes rather than from reading
the existing assertions, including a discovered write-site population so a
third write site is covered without being named, and a second census that
fails differently. The live round-trip's fixture principal is no longer the
web constant, which a store that dropped the argument used to satisfy.
WebEditorPrincipal ends in EditorPrincipal, so a substring test for the
seat read passed on exactly the constant-stamping code it rejects. Found by
reverting one write site and watching every check stay green.
@claude

claude Bot commented Sep 7, 2026

Copy link
Copy Markdown

Reviewed the diff (DarlingWebEndpoints.cs + the two test files). No correctness, security, or Lite/Darling parity issues found.

Notes from the review:

  • Parity: no Lite counterpart needed — custom views / OIDC / the web dashboard are Darling-only (grep for custom_views/CustomView under Lite/ returns nothing), so there's no drift to flag here.
  • Correctness: verified CustomViewStore.CreateAsync/UpdateAsync parameter order against InsertSql/UpdateSql$5 in UpdateSql is indeed updated_by, matching the PR description's claim about the new fixture pinning that binding end-to-end.
  • Security: EditorPrincipal (attacker/IdP-influenced subject string) flows into updated_by only through parameterized Npgsql params (AddNullableText), so no injection surface. Client-side rendering in views.js goes through el()/textContent, never innerHTML, so the now-attacker-influenced value doesn't introduce XSS either.
  • Wiring: confirmed DarlingWebSeat.FromContext/EditorPrincipal have no other production callers before this change (matches the PR's premise), and that the Allow arm publishes the seat into HttpContext.Items before next(context) runs, so the two endpoints reading it will actually see it.
  • The new DarlingWebEditorAttributionTests.cs reuses the existing shared RepoFile/CSharpSourceWalker test-infrastructure conventions already established elsewhere in Darling.Tests (not a new pattern introduced here).

No inline comments — nothing rose to the level of a blocking or even a nitpick finding.

The stamp and fixture comments explained themselves by narrating what the
code did before, which reads as a diff annotation rather than as the reason
the current shape is required. Same arguments, stated as properties that
hold now. Also folds the inline System.IO qualifications into a using.
Every argument the store's writes take around updatedBy is a string, so a
positional swap with description compiles, stamps the description into
updated_by, and satisfies a check that only asks whether the principal
appears somewhere in the argument list. Naming the parameter makes that swap
CS8323 instead, and the guards require the name so it cannot quietly go away.
Comment thread Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
@claude

claude Bot commented Sep 7, 2026

Copy link
Copy Markdown

Reviewed the diff (Darling-only: DarlingWebEndpoints.cs + two test files). No Lite counterpart exists for this feature area (web dashboard / OIDC / custom views), so there's no parity drift to flag. No SQL changed, so the T-SQL style conventions don't apply here. Write paths (CustomViewStore.CreateSql/UpdateSql) are untouched and remain parameterized, so no injection concern from threading the OIDC subject through as updated_by. The subject is already length-capped (DarlingWebSeat.MaxSubjectLength = 251) and control-character-checked at sign-in (pre-existing, unmodified), and the SPA renders updated_by via el()'s text: prop (textContent only, no innerHTML path), so no new XSS surface either.

One real finding, left as an inline comment on DarlingWebEndpoints.cs: making /api/session honestly report can_edit per-seat (instead of the old hardcoded true) makes a previously-unreachable UI path reachable — an OIDC viewer hitting the composer now sees "Couldn't confirm your session… Reload the page to edit," which is misleading for someone who is correctly authenticated but intentionally read-only. editor.js, notebook.js, and a doc comment in views-api.js all still describe the old always-true behavior. Not a security or data-correctness issue — the server-side write gate (DarlingWebSeat.IsRequestAllowed) already refuses the mutation regardless of what the client does — just stale/misleading copy this PR's own fix newly exposes.

The two new test files verify the requested Create/UpdateAsync write-site count via regex (sites.Count >= 2) against exactly the files this PR touches, so they're not at risk of the "vacuous pass" failure mode they're written to guard against today; just noting they'll need re-verification if a third write site is ever added elsewhere, since the discovery walks only DarlingWebEndpoints.cs and DarlingMcpCustomViewTools.cs by fixed path rather than the whole service tree (unlike the WebEditorPrincipal reference scan, which does walk the whole project).

No performance concerns — this only changes what gets bound into an existing parameterized write, and adds one HttpContext.Items lookup already on the hot path via the auth middleware.

An honest can_edit makes a UI path reachable that only ever described a
failed probe, so an OIDC viewer was told to reload to edit — something their
role will never allow. The client's fail-closed default now marks itself with
probe_failed, which the server's own answer never carries, and each composer
branches its notice on it. Every discovered composer is held to that.

The attribution pins read source through the raw reader. Their anchors all
sit on one line, so the LF-normalising variant would change nothing about
what they match while placing the file in a census whose membership means
the opposite.
Asserting probe_failed appears anywhere in views-api.js was satisfied by
this mechanism's own doc comment, so stripping the flag from the fail-closed
default left the check green. Read the session probe's body instead: prose
about a mechanism is not the mechanism.
@claude

claude Bot commented Sep 7, 2026

Copy link
Copy Markdown

Review

Went through the diff (DarlingWebEndpoints.cs, DarlingMcpCustomViewTools.cs, editor.js/notebook.js/views-api.js, and the new DarlingWebEditorAttributionTests.cs) against CONTRIBUTING.md and the correctness/parity/security/perf checklist.

Lite/Darling parity: No parity gap. Custom views, OIDC, and the web dashboard are Darling-only concepts — confirmed Lite/ has no custom_views/web-seat equivalent, so there's nothing for Lite to mirror here.

T-SQL style: N/A — no .sql changes in this PR.

Correctness:

  • Verified DarlingWebSeat.EditorPrincipal (Subject ?? WebEditorPrincipal) and FromContext are wired into both /api/session and the two custom-view write sites, and that the store's updatedBy parameter is threaded through unchanged (nullable text, no schema/length concerns given the 251-char subject cap called out in the PR description).
  • Confirmed the write-authorization boundary is unaffected: DarlingWebSeat.IsRequestAllowed in the auth middleware (DarlingWebHostService.cs) already refuses mutating requests for a read-only seat before routing, so making /api/session's can_edit honest is purely an affordance fix, not a new enforcement path (and not a regression — a viewer could never actually persist a write before or after this change).
  • The named-argument (updatedBy: ...) call shape at all four write sites compiles fine under C#'s non-trailing-named-argument rule (named arg in its correct position followed by a positional trailing arg) — not a build risk.
  • editor.js/notebook.js's probe_failed branch and views-api.js's fail-closed default look correct; app.js's if (session.can_edit) affordance-hiding check is correctly left alone since it never renders the misleading "reload to edit" copy.
  • Spot-checked the pre-existing updated_by byline render in pages/views.js — goes through el(..., { text: meta }), i.e., textContent, not innerHTML, so an OIDC subject with HTML-special characters can't inject markup there.

Security: No injection risk — updatedBy flows into parameterized store calls ($4/$5), never concatenated into SQL. No secrets/network/process surface touched.

Tests: DarlingWebEditorAttributionTests.cs is thorough — the write-site discovery + whole-identifier match genuinely catches the WebEditorPrincipal/EditorPrincipal substring pitfall called out in the PR description, and the live round-trip fixture change (distinct non-"web" principals) closes the "store drops the argument and hardcodes the constant" hole in the old fixture.

No blocking findings. Nice, well-scoped fix with unusually strong regression coverage for a UI/attribution change.

@erikdarlingdata
erikdarlingdata merged commit 148e916 into dev Sep 7, 2026
10 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/2550-updated-by-subject branch September 7, 2026 20:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant