Define the configuration and secrets strategy (Vikunja task #3) #9
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/configuration-secrets"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Implements Vikunja task #3, with one acceptance criterion deliberately not implemented because it is incoherent. See below.
The deviation —
appsettings.Development.json.examplewas not createdThe criterion asks for it. The
.exampleconvention works because the real file is gitignored — that is why.env.exampleworks. Butappsettings.Development.jsonis tracked. An example sitting beside a live tracked file gives no indication which to edit, and copying it produces a tracked file the developer has just been invited to put credentials into.The .NET analogue of
.envis notappsettings.Development.json— it is the user secrets store. So the template issrc/PlaceMark.Api/secrets.json.example, applied unedited:It cannot shadow anything, because .NET never loads a
secrets.jsonfrom a project directory. The literal-but-coherent variant (gitignoreappsettings.Development.json, ship the example beside it) was considered and rejected: an ignored file inside the tree is onegit add -ffrom being committed, whereas the store lives outside the tree and cannot be committed by any accident.Precedence — observed, not recited
Verified by printing the live provider chain from a running API and testing five cases as real processes:
<null>secrets.jsonConnectionStrings__PlaceMarkenv varASPNETCORE_ENVIRONMENT=Production, secret still set<null>— nosecrets.jsonprovider present at all--ConnectionStrings:PlaceMarkon the command lineTwo things that would not have come from memory: .NET 10 also probes
PlaceMark.Api.settings[.{Environment}].jsonbetween the appsettings files and user secrets, andWebApplicationBuilderchains host configuration in last.Case F, the failure mode: a password placed directly in
appsettings.jsonresolves with no warning and no error. Worse, with a user secret also set it is silently overridden locally — so the author of the leak is the one person who cannot see it in use. That is why the rule is written intoappsettings.Development.jsonitself, the file where the mistake gets made.Only one key is defined
ConnectionStrings:PlaceMark. No JWT or OIDC keys — their shape follows from code that does not exist, and a placeholder signing key in a tracked file is precisely the artefact this ticket exists to prevent, because placeholders get edited in place and then committed. The template says so explicitly, so the omission does not read as an oversight.ADR-0014
Not because "we use user secrets" is architectural, but because three clauses constrain the system's shape: deployed configuration must be expressible as environment variables (ruling out file-based deployment); nothing the WebAssembly client ships can be confidential, which means ADR-0002's OIDC flow cannot hold a client secret in the browser and must complete at the API; and the deviation above needs a durable home.
Verification
dotnet build— 0 warnings, 0 errors (after rebase onto the analysers from #8)dotnet format --verify-no-changes— exit 0dotnet test— exit 0dotnet user-secrets init/set/listproven, then removed — no state left on the machinegit check-ignoreconfirmssecrets.jsonis ignored andsecrets.json.exampleis notRebase note
Branched before #8 merged; rebased onto it. The conflict was the one the author predicted:
PlaceMark.Api.csprojnow carries only<UserSecretsId>—TargetFramework,NullableandImplicitUsingsstayed inDirectory.Build.propsrather than being restored. The ADR index carries 0012, 0013 and 0014 in order.Flagged
No enforcement exists.
.gitignorecovers the plausible slip (copying the template and filling it in), but "no secrets committed" is unfalsifiable without a scanner. A CI secret scan would close it and belongs with task #5.Deliberately no test. A
WebApplicationFactorytest pinning precedence needs a package and apublic partial class Programadded solely for tests, and would break the moment DI requires a live database. It belongs with the first ticket that actually reads configuration, where the harness exists anyway.Small accepted drift: the connection string appears in both the README's Database block and
secrets.json.example. The template names compose as the source of truth.Verdict: changes needed
Independent review at
ee05db9. One blocking finding, and it is a factual one in a document that becomes immutable the moment this merges.On the deviation — I agree with it
Taking the central question first, because everything else is detail.
The reasoning is right, and it is not a rationalisation. Three things convince me:
appsettings.Development.jsonis tracked (git ls-filesconfirms), and the SDK web template generates it. The.exampleconvention derives all of its meaning from the real file being absent from the tree — that is why.env.examplereads unambiguously andappsettings.Development.json.examplewould not. Shipping both files would leave two near-identically named files, one live and one inert, with nothing on the face of either saying which.secrets.json.example, and met better, because it is executable. I ran the documented command and it worked first time.appsettings.Development.json, ship the example beside it) is fair. The stronger of the two stated grounds is the first one — a file that is loaded but untracked makes "why does it behave differently on your machine" unanswerable from the repository, and that is a real, recurring cost. The second ground reads weaker than it is at first glance, but it holds up:git add -Adoes respect ignores, yet Docker build contexts, archives and backup tooling do not, and a credential file inside the tree is exposed to all of them.So: honour the intent, record the deviation, move on. That is what this does. My one reservation about the argument (not the decision) is the blocking finding below.
Blocking
The claim that a project-directory
secrets.jsonis never loaded is false in exactly the fresh-clone window.Stated in three places:
docs/adr/0014-configuration-and-secrets.md:59— "secrets.json.examplecannot shadow anything, because .NET never loads configuration namedsecrets.jsonfrom a project directory at all.".gitignore:441— "such a file is inert — .NET never reads it from the project directory"src/PlaceMark.Api/secrets.json.exampleheader — "never from the repository, so nothing you set can be committed by accident and nothing here takes effect until you apply it"AddUserSecretsbuilds aPhysicalFileProviderover~/.microsoft/usersecrets/<id>/only if that directory exists. If it does not, the file provider is null andAddJsonFile("secrets.json", optional: true)falls back to the builder's default provider — which is rooted at the content root, i.e. the project directory. Note the provider is registered asJsonConfigurationProvider for 'secrets.json' (Optional)with no path, which hides this.Reproduced on this branch, with a
src/PlaceMark.Api/secrets.jsonpresent throughout:~/.microsoft/usersecrets/2b9d7035…/Host=project-dir-secrets-json-WAS-READ← the project-directory file was loadedsecrets.jsoninside)<null>The consequence matters because of who is in that window: it is precisely the developer who has cloned, has not yet run
dotnet user-secrets set, reads a file namedsecrets.json.example, does the thing that name invites —cp secrets.json.example secrets.json— and finds that it works. It works silently, it teaches the wrong mechanism, and the only thing standing between that file and the history is the.gitignoreentry, which the surrounding comment describes as a mere backstop for an inert file. It is not a backstop; in that window it is the control.None of this undermines the decision. User secrets is still the right mechanism and the template is still the right artefact. But ADR-0001 freezes an accepted ADR's body on merge, so a wrong fact merged here can only be corrected by superseding it. Fix it now:
secrets.jsonis loaded only while the store directory does not yet exist, which is why.gitignorecovers it. That is still a decisive argument againstappsettings.Development.json.example, and it does not depend on an overstatement..gitignorecomment and the template header from "inert" to "live in the window before the store exists".secrets.example.jsonorusersecrets.template.json. Any name other thansecrets.jsonis never loaded from the project directory under any condition, so this restores the "cannot shadow" property the ADR wants to claim.Non-blocking
<redirection;<is a reserved operator in PowerShell and errors. The same section names%APPDATA%\Microsoft\UserSecrets\, so a Windows reader is explicitly contemplated. AddGet-Content src\PlaceMark.Api\secrets.json.example | dotnet user-secrets set --project src\PlaceMark.Api.CLAUDE.mdasks for tickets to be kept updated as work lands; the backlog is the place someone will look in six months. Annotate the criterion with a link to ADR-0014 before closing.ChainedConfigurationProviderthatWebApplicationBuilderappends last (host configuration). The PR body notes it; the README does not. It changes nothing observable here — I could not construct a case where it contradicts the documented order — so this is FYI, not a correction.appsettings.Development.jsonare fine for the configuration provider (it parses withCommentHandling.Skip— verified by the app starting and loading the file), but any plainSystem.Text.Jsonconsumer of that file would throw. Nothing consumes it today.ConnectionStrings:PlaceMarkis defined — right call, and not an under-delivery. A placeholder signing key in a committed template is the artefact that gets copied into a deployed environment because nobody noticed it was a placeholder; and JWT/OIDC key names invented now would be a guess the implementation must then honour or churn. The template says so explicitly, which is what stops the omission reading as an oversight. The stale-template risk this creates is correctly called out in ADR-0014's consequences.ADR-0014 as a record
Follows the template —
Status/Date/Source, then Context, Decision, Alternatives considered, Consequences, matching the shape of 0012 and 0013; the supersession lines are correctly omitted rather than left empty. Index row atdocs/adr/README.md:24matches the title and status exactly, and no existing ADR body was touched. Alternatives are honest, including the one that reads as a close call.The three constraint clauses justify the ADR. The WebAssembly clause is sound and, more to the point, correctly hedged: "any flow requiring one must be completed by
PlaceMark.Api" leaves browser-side PKCE with a public client intact, which a looser wording would have wrongly excluded. Placing it here is defensible — ADR-0002 explicitly leaves the OIDC flow open and cannot be edited to point forward, and this derives a constraint rather than deciding the flow. The mild cost is discoverability: the constraint lives in a configuration ADR and is reachable only from the index or from 0014's link back to 0002. Worth naming in the eventual OIDC ADR.What I ran
Full clean checkout at
ee05db9in an isolated worktree.dotnet build— Build succeeded, 0 Warning(s), 0 Error(s)dotnet test --no-build— exit 0 (no test files present yet; pre-existing)dotnet format --verify-no-changes— exit 0git check-ignore -v src/PlaceMark.Api/secrets.json→.gitignore:445:secrets.json, exit 0;git check-ignore src/PlaceMark.Api/secrets.json.example→ exit 1. Correct in both directions.PlaceMark.Api.csproj—<UserSecretsId>only;TargetFramework,Nullable,ImplicitUsingsnot reintroduced. No UTF-8 BOM on the csproj, the template,appsettings.Development.jsonor the ADR (3c 50 72,7b 0a 20,7b 0a 20,23 20 41).docker-compose.ymlexactly —127.0.0.1(compose binds127.0.0.1:${POSTGRES_PORT:-5432}), port5432, database/username/password allplacemarkfrom the:-placemarkdefaults. Correct on a fresh clone with no.env.dotnet user-secrets set --project src/PlaceMark.Api < src/PlaceMark.Api/secrets.json.example→ "Successfully saved 1 secrets to the secret store", exit 0.//comments tolerated; the nested object flattened to the store as"ConnectionStrings:PlaceMark", listed back bydotnet user-secrets listas expected. Working tree unchanged afterwards.Precedence, re-derived rather than accepted — real processes, printing
GetConnectionString("PlaceMark")and the live provider chain:<null>ConnectionStrings__PlaceMark=Host=env-var-winsHost=env-var-wins— env var beats user secretsASPNETCORE_ENVIRONMENT=Production, secret still set<null>, and nosecrets.jsonprovider in the chain at all-- --ConnectionStrings:PlaceMark=Host=command-line-winsHost=command-line-wins— command line beats env varThe observed Development chain also confirms the README's
PlaceMark.Api.settings[.{Environment}].jsonnote, sitting between the appsettings files and user secrets. Every row of the README table is accurate.Cleanup: the user secret set during testing was cleared and
~/.microsoft/usersecrets/2b9d7035-162a-4282-ac95-0bb3e3fdbcdd/removed;~/.microsoft/usersecrets/is empty, as I found it. All probing was done in a throwaway copy outside the reviewed worktree, since deleted. The reviewed worktree isgit statusclean.Once the "never loads a
secrets.jsonfrom a project directory" claim is corrected in ADR-0014,.gitignoreand the template header, this is ready to merge.Verdict: changes needed
Re-reviewed at
1c9c754, read viagit fetchandgit archive— I did not check out or mutate the branch.The blocking finding is fully resolved, and the fix is better than the one I asked for. I asked for accurate wording; the author removed the hazard instead. I verified both properties they claim, plus the boundary they mapped, and every one holds.
One new blocking item, in the paragraph added to explain the deferral. It is a one-sentence fix and it is the only thing between this and merge.
The fix, verified
Ten runs against a clean export of
1c9c754, with~/.microsoft/usersecrets/genuinely empty at the start — the state the whole finding depends on, and the one nobody who has used the tool is normally in:<null>src/PlaceMark.Api/secrets.jsonplantedsecrets.jsonplanted at the repository root<null>— property 1 holds: the root is outside the content root the fallback targetsuser-secrets.jsonplanted in the project directory and the root<null>— property 2 holdscp user-secrets.example.json user-secrets.jsonat the root<null>src/PlaceMark.Api<null>So the obvious copy is inert wherever it is made, and each property independently suffices — the belt and the braces are both real. The relocation is doing work the rename alone would not: had the template stayed beside the project under any name, a developer could still land on
secrets.jsonby other routes, but with it at the root there is nothing insrc/PlaceMark.Apito copy in the first place.The boundary claim is the one I would have been most inclined to take on trust, and it is the most surprising, so I ran it:
secrets.jsonplanted, store absentdotnet user-secrets list<null>A command that reports nothing is configured, changes nothing, and is the natural thing to run when investigating, is sufficient to close the hole permanently on that machine. That is a genuinely nasty property, and "nobody who has used the tool can reproduce it — including every reviewer" is the right thing to have written down. Stating it in all three places is proportionate rather than repetitive: each is read by someone in a different position, and the
.gitignorecomment in particular is now read by whoever next wonders whether that line can be deleted.Applying the template from its new path:
dotnet user-secrets set --project src/PlaceMark.Api < user-secrets.example.json→ "Successfully saved 1 secrets", and the API then resolved the docker-compose default. Store cleared and the directory removed afterwards;~/.microsoft/usersecrets/is empty, as I found it.Blocking — one sentence, factually wrong, about the repository's own contents
docs/adr/0014-configuration-and-secrets.md, Consequences:Directory.Build.propsalready exists. It was added by2d4865a— "Establish coding standards and static analysis (#8)" — which is this branch's own merge base, is onmain, and appears ingit ls-tree 1c9c754. This branch was rebased onto it, and thePlaceMark.Api.csprojcomment two directories away points at it by name. The only other open PR, #10, is the CI pipeline and does not touch it.This matters for the same reason the first finding did, and it is the same class of defect in the paragraph written to correct that defect: ADR-0001 freezes an accepted body on merge, so a wrong fact merged here can only be fixed by superseding the record. It is also load-bearing — it is the stated reason for not adding a control that the same paragraph calls the only thing in the path. And it is checkable with one
lsagainst the same commit.The decision it defends is right (see below), so this is a reword, not a rethink. Something like: "The second belongs in
Directory.Build.propsso that it covers every project rather than only the API. It is not done here because a build-time error is enforcement rather than strategy: it belongs with the CI secret scan, in the ticket that owns enforcement, where the two can be designed together."Your question: defer the build-time check, or carry a narrower version here?
Defer — but the recorded reason is the wrong one, and the honest reason is stronger.
The placement argument does not survive
Directory.Build.propsalready existing. With that file in the tree, there is no narrow-version-that-must-be-moved to avoid; the correct, universal version is available today at about five lines:So if placement were the only objection, I would say do it now. It is not, and three better reasons remain:
.gitignorealready prevents the actual harm. The harm is a credential reaching the history. A build-time error catches the file only after it exists, and only for someone who builds. Its value is pedagogic — it tells a developer they have done something dangerous — not protective. Pedagogy can wait; the protection is already in place and is now correctly described as a control.dotnet format, which builds. That is a PR, not an addendum.The one thing I would insist on is that the promise be attached to something. "Belongs to the CI ticket" currently exists only as a sentence in an ADR consequence paragraph. Put it on Vikunja task #5, or wherever the secret scan lands, so the deferral is a plan rather than an intention.
Non-blocking
list_commentsreturns empty. This was the one item from the last round that is genuinely still outstanding; the other three are done. Worth doing before the ticket is closed, and it can now carry the better story: the criterion was not met, something stronger was, and the reason is in ADR-0014.user-secrets.jsonis not gitignored. Verified:secrets.jsonmatches in any directory;user-secrets.jsonanduser-secrets.example.jsonmatch nothing. The naming makes such a copy inert, which is the point, and an inert file gets deleted rather than filled in — but it is still committable if someone puts a real password in it before discovering it does nothing. One line would close it. Your call; the argument for leaving it is that every extra ignore line dilutes the one that is actually a control..gitignorecomment, plus the template header — four places) is justified, but it is now four copies of a fact that will change if Microsoft roots the provider unconditionally. If that happens, the ADR is frozen and the other three drift. Not worth restructuring; worth knowing.Everything else re-verified at this head
dotnet build— 0 Warning(s), 0 Error(s);dotnet test --no-build— exit 0;dotnet format --verify-no-changes— exit 0. None of the three creates the user secrets directory, which is what let me run the vulnerable-state experiments after them.git check-ignore:secrets.jsonignored in both the project directory and the root;user-secrets.example.jsonnot ignored, so the template is live.PlaceMark.Api.csprojunchanged fromee05db9—<UserSecretsId>only, nothing fromDirectory.Build.propsreintroduced. No BOM onuser-secrets.example.json.docker-compose.ymlexactly (127.0.0.1,5432,placemark×3).secrets.json.exampleis deliberate, explaining why the template is not called that. ADR title unchanged, so the index row atdocs/adr/README.md:24still matches.Get-Content … | dotnet user-secrets set). The JSON-comments note now correctly attributes the tolerance to the configuration provider rather than to JSON, and namesJsonCommentHandling.Skip. The precedence table's "five rows are the sources worth setting a value in" framing is better than the previous footnote, and the chained host configuration is now named — matching the provider chain I printed last round.Reword the
Directory.Build.propssentence and this is ready. The substance of the fix is right, the experiment that produced it was the right experiment, and mapping the boundary before choosing the remedy is what turned a documentation correction into an actual removal of the trap.Verdict: mergeable
Final review at
1fb48dd, read viagit fetchandgit archive— no checkout, no mutation. Both blocking findings are resolved. Nothing further is required before merge.The blocking finding
The
Directory.Build.propssentence is gone and what replaced it is correct: the file exists at this head (git ls-tree 1fb48ddconfirms), the record now says the check "would take about five lines inDirectory.Build.props, which already exists", and it states outright that the deferral is a judgement about ownership rather than a technical obstacle.That last clause is the part that matters, and it is a better record than the one I would have accepted. My concern was never only that the sentence was false — it was that a reader in a year would find a plausible-sounding technical excuse and quietly inherit it. A record that says "this is cheap and available, and we chose not to" cannot be misread that way, and it puts the decision back where it belongs, with whoever owns enforcement.
The three reasons are stated more precisely than I put them, particularly "it would not protect anything the ignore rule does not already protect" — that is the crux, and it is the sentence that makes the deferral obviously right rather than merely defensible.
The promise now has an owner
Vikunja task 39 carries it, and the comment is better than a cross-reference. It names both controls, the five-line cost, the fallback mechanism, the fresh-clone window, the detail that even
listcloses it, and that.gitignoreis currently the only control in that path — so whoever picks up the CI ticket has the full picture without reading an ADR first. Added withadd_comment, so no fields were clobbered. "A promise that exists only in an ADR is a promise nobody is scheduled to keep" is the right principle and it has been applied rather than just written.Both non-blocking items
user-secrets.jsonignored. I agree with the reasoning, including the part that resisted flattening the two names into one hazard.secrets.jsonworks and gives no feedback;user-secrets.jsondoes nothing and is noticed in minutes. They are not the same risk, and the comment keeps that visible while still ignoring both — the trade is one irreversible outcome against one mildly annoying one, and irreversible wins. Verified independently at this head:user-secrets.example.jsongit ls-filesconfirms it is trackeduser-secrets.json.gitignore:450src/PlaceMark.Api/user-secrets.json.gitignore:450secrets.json,src/PlaceMark.Api/secrets.json.gitignore:449Both directions correct, and the new unanchored pattern does not catch the template by prefix — which was the one way this change could have silently un-shipped it.
Consolidation. The split is well chosen and the principle behind it is sound: the frozen document holds the framework behaviour that could change, the living files hold instructions that stay true either way. So if Microsoft ever roots the provider unconditionally, exactly one document is wrong and it is the one that is allowed to be, because it is dated and superseded rather than edited. The README bullet,
.gitignorecomment and template header now each say the actionable thing — it loads on a fresh clone, do not create it, apply the template instead — and defer the mechanism. Nothing lost: the two facts a reader acts on, and the reason nobody in review will have seen it, both survive in every copy.Re-verified at this head
dotnet build— 0 Warning(s), 0 Error(s), underTreatWarningsAsErrors,EnforceCodeStyleInBuildandAnalysisMode=RecommendedfromDirectory.Build.props;dotnet test --no-build— exit 0;dotnet format --verify-no-changes— exit 0.dotnet user-secrets set --project src/PlaceMark.Api < user-secrets.example.json→ "Successfully saved 1 secrets", and the API resolved the docker-compose default. Worth re-running rather than assuming, since the edited comments sit inside the JSON the parser has to tolerate.src/PlaceMark.Apicontains nosecrets.json-shaped artefact;PlaceMark.Api.csprojstill carries<UserSecretsId>alone. ADR title unchanged, so the index row atdocs/adr/README.md:24still matches. No American spellings in any changed file.secrets.jsonloads with the store absent;user-secrets.jsonloads from nowhere;secrets.jsonat the repository root loads from nowhere;dotnet user-secrets listcloses the window while reporting that nothing is configured.Store cleared and
~/.microsoft/usersecrets/2b9d7035-…deleted afterwards; the directory is empty as I found it. All probing was done in agit archiveexport outside the reviewed tree, since removed.One follow-up, explicitly not blocking
Vikunja task 37 (task #3) still has no comment —
list_commentsreturns empty. This is a bookkeeping item for merge time, not a code change: the third acceptance criterion reads as unmet, and the record of why it was replaced by something stronger lives only in this PR and ADR-0014. Given the effort spent making the other deferral properly owned, it would be a shame for this one to be the loose end.Merge when ready. The strategy is right, the trap that the strategy accidentally created has been removed rather than documented, and the record of both is accurate — including about its own limits.