Repository navigation
fix(cli): separate transport auth from operation payload for add_user/alter_user - #1873
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces dedicated authentication arguments (auth_username and auth_password) to the CLI operations, preventing payload fields (such as those in add_user or alter_user) from being mistakenly used as transport credentials. It also updates the CLI help documentation and adds unit tests to verify the priority of these credentials and that they are stripped from the request bodies. Feedback was provided regarding a potential credential cross-contamination risk where a partially specified higher-priority credential source could cause the fallback logic to incorrectly mix admin usernames with payload passwords, along with a suggested fix to guard the fallback.
|
Reviewed; no blockers found. The only change since the last review pass (commit 6c6bd5b) is documentation-only — HELP text noting the stale-token 401 behavior and a comment clarifying |
…/alter_user add_user/alter_user reused the same username=/password= CLI args as both the HTTP Basic auth credentials and the operation payload, so authenticating as an admin while creating/altering a different user was impossible non-interactively — the payload values won the auth race, and env-var auth (HARPER_CLI_USERNAME/PASSWORD) was silently overridden. Give transport auth its own namespace (auth_username=/auth_password=), always preferred for the auth leg, and add them to TRANSPORT_ONLY_FIELDS so they never serialize into the request body. Env-var auth now also beats plain username=/password= when no dedicated auth arg is set, so a configured CI login isn't shadowed by an operation's own payload fields; username=/password= remain the auth fallback for ops where they legitimately ARE the auth. Also fixes the non-deploy body path, which sent the raw, unstripped req object as the wire body. Refs #1872 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…fields Follow-up to the review of the non-interactive auth fix. Three changes: - The saved `harper login` token now outranks the legacy `username=`/`password=` payload fallback. Previously any payload `username=` was folded into the target before the Authorization mode was picked, so `harper login` followed by `add_user username=newuser password=...` authenticated as the user being created and 401'd, leaving the interactive path with the original collision. - Credentials are resolved as an all-or-nothing pair from one source (dedicated args, URL userinfo, or an env-var namespace) instead of per-field `||` chains, which could pair one source's username with another's secret. An incomplete explicit pair is a hard error; an incomplete env pair warns and is skipped, since a lone HARPER_CLI_USERNAME/PASSWORD is a supported `harper login` idiom. `harper login` likewise no longer mixes the HARPER_CLI_* and CLI_TARGET_* namespaces, and prefers HARPER_CLI_* like the rest of the CLI. - Passwords no longer reach the logs: the parsed CLI request is redacted before the trace log, and a password embedded in `target=` is masked wherever the target is printed. Help text leads with the env-var form and notes that a password passed as an argument is exposed to shell history and CI logs. `harper login` now also sends its credentials as dedicated transport auth, so a re-login after expiry authenticates as the caller instead of trying to refresh the very token it is replacing. Refs #1872 Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
676990f to
7cbcd5a
Compare
DavidCockerill
left a comment
There was a problem hiding this comment.
Read through this carefully — the design is sound and the test coverage is genuinely strong (42 assertions across two files). No correctness bugs found. I traced the auth control flow, the atomic credential resolution, and the redaction paths, and confirmed utility/common_utils.ts httpRequest does no logging, so the Basic header can't leak downstream. The only programmatic caller (bin/login.ts) is updated, and the dormant .mjs test is describe.skip. Not requesting changes.
One thing I want to sign off on explicitly rather than let through silently, plus a few notes.
The auth-precedence change is a real behavior change on a stable CLI, and it deserves a conscious yes. Env-var auth and the saved harper login token now outrank payload username=/password=, where the payload previously won (bin/cliOperations.ts:393).
In plain terms: if someone's CI was quietly leaning on username=admin password=… to authenticate — say for create_table — and that same shell also had HARPER_CLI_USERNAME exported for something else, the command now authenticates as the env var instead of the argument. Almost always that's the same admin and nothing visibly changes. But it's exactly the kind of silent "who am I authenticating as" shift that should be a deliberate decision on an auth path rather than a surprise.
It's intended, it's yours, and it's tested — so consider this me blessing it rather than flagging it. Recording it here so the decision is on the record if someone hits it later.
The docs follow-up matters more than usual because of that. New auth_username=/auth_password= args, env-var auth, and new precedence rules are all user-facing. Is the documentation-repo follow-up tracked yet? I'd rather it not lag far behind the merge.
Smaller notes, none blocking:
- Incomplete-env warning is noisy (
cliOperations.ts:171). Exporting a loneHARPER_CLI_USERNAMEis a documentedharper loginidiom (password prompted), but in a non-login op it emitsIgnoring incomplete credentials: …on stderr for that command and every later one in the shell. Consider suppressing it when a saved token or other complete source is going to satisfy the request anyway. redactCredentialsis field-name-based, not exhaustive (cliOperations.ts:225).SECRET_FIELDSis{auth_password, password}plus target-URL userinfo — right for this PR's scope, but any other secret-bearing arg (a token, a key) would still reachlogger.trace. Worth a comment saying so, so nobody assumes the trace log is safe by construction.- Stale-token edge worth a line in the help/docs (
cliOperations.ts:438). If a saved token exists and refresh fails with a non-401,Authorizationstays set to the expired Bearer, so the legacy payload-credential fallback is skipped and the request 401s — where payload creds used to work. That's a coherent consequence of making the token outrank the payload (i.e. the point of the fix), andharper logout/auth_*is the escape hatch — it just deserves to be written down. - On the asymmetry you flagged (incomplete explicit creds fatal, incomplete env creds warn-and-skip): agreed with your default. Failing every op after a bare
export HARPER_CLI_USERNAMEwould be worse. Blessing it rather than letting it pass unremarked. - Housekeeping: you noted
676990fff(theDatabaseTransaction.tstype widening) isn't part of this change and gets dropped on rebase — worth confirming it's gone at merge so it doesn't ride in on an auth PR.
Note on review coverage: this one got a Claude-only pass — both outside-model legs were unavailable at the time. Codex is working again now, so if you'd like a second opinion on the auth surface before merging, say the word and I'll run it.
— Reviewed by DAIvid (Claude Opus 5)
Follow-up to review of the non-interactive auth fix (PR #1873): - HELP text now notes that a saved harper login token outranks username=/password=, so a stale token that fails to refresh 401s rather than falling back to the payload creds — harper logout or auth_username=/auth_password= is the escape hatch. - Noted that SECRET_FIELDS is a by-name allowlist, not exhaustive, so any future secret-bearing arg needs to be added explicitly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Addressed two of the small, non-blocking notes from @DavidCockerill's review in 6c6bd5b:
Left the "noisy incomplete-env warning" suggestion (suppress it when a saved token/fallback ends up authenticating anyway) as-is — deferring/suppressing that warning would contradict the existing The two inline review threads (Gemini + earlier Claude pass on atomic credential resolution) were already resolved in — Claude Sonnet 5 |
DavidCockerill
left a comment
There was a problem hiding this comment.
Approving at 6c6bd5b. The delta since my last pass is six lines of comment and help text across two files — zero logic change, CI green on Node 22/24/26.
Everything I raised is closed:
- The auth-precedence change is documented, and documentation#613 covers all three user-facing pieces — the
auth_*args, env-var auth, and the precedence order — plus stale-token recovery. That was my main ask, since the precedence shift silently changes who a command authenticates as. redactCredentials' field-name-based scope is now noted in a comment.- The stale-token fallback behaviour is written down.
- The stray
676990fffDatabaseTransaction.tscommit is gone; the merge-base is now its standalone fix on main.
Re-verified the security properties at this head: no credential reaches a log, redaction still applied, Basic header still not logged. I also chased and refuted one candidate of my own — a URL parse error potentially leaking target userinfo.
Only open item is the incomplete-env warning noise, which you deliberately deferred; the current test locks in always-warn, so it wants a design call rather than a quick change. Happy to file it as a follow-up.
— Reviewed by DAIvid (Claude Opus 5)
…2545) * test: promote three QA regression anchors from the qa-explorer bank Adds three integration suites, no product code. - integrationTests/resources/qa716-lingering-write-commit.test.ts (QA-716 / P-493) pins harper#1860: writes staged across three tables in one request transaction, behind a paged secondary-index iterator the handler never drains, must be durable and index-consistent the moment the request is acked. Additive to unitTests/resources/lingeringWriteCommit.test.js, which covers the single-table case against the transaction object in isolation. - integrationTests/security/qa627-cli-user-auth-separation.test.ts (QA-627 / P-409) pins harper#1872, fixed by #1873: CLI transport auth resolves separately from the add_user/alter_user payload, as an atomic pair per source. The banked candidate asserted the pre-fix defect; inverted here to assert the shipped behaviour. - integrationTests/mqtt/qa681-mqtt-shared-subscriptions.test.ts (QA-681 / P-460) pins that $share subscriptions are explicitly REFUSED with a SUBACK reason code rather than accepted as an inert filter, that ordinary fan-out stays complete and duplicate-free alongside them, and that a durable session loses nothing across an abrupt mid-stream drop. Refs #1860 Refs #1872 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZPy6UYZtwxhzLgimHWbGD * test: address pre-push review on the QA promotion anchors - QA-681 Q4 polls the durable-session resume on sequence COVERAGE rather than a delivery count. QoS-1 is at-least-once, so a resume may replay a message that was unacked when the transport died; counting deliveries reaches the expected total while a later sequence is still in flight, and the run would then fail a session that recovers everything. - QA-627's CLI home moves under the harness dataRootDir, so instance teardown removes it rather than leaking a tmpdir entry when the before hook throws, and a spawn failure's string `code` (ENOENT and friends) no longer reaches a numeric contract. - Drop a stale fixture comment and several that restated adjacent code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZPy6UYZtwxhzLgimHWbGD * test: remove cross-arm state races from the QA promotion anchors - QA-681 Q3 publishes to its own topic. Sharing Events/stream with Q1 left Q1's last row as that topic's current record, and Harper's subscribe path yields it from a detached iterator that can lose the race to SUBACK — landing a Q1 message in Q3's count on a loaded shard. - QA-681 Q4 converges on the dropped client's socket actually being down before publishing past the drop point, so the boundary assertion is deterministic rather than a race with the next publish. - QA-716 Q2 drops its index assertion: search_by_value goes through the same materialization as the base dump, hiding an expired row and dropping one whose base record is gone, so once the dump is empty the index query is empty too whatever the index holds. It could not fail, and the comment now says why rather than leaving a check that reports coverage it does not have. - QA-627 scrubs NODE_OPTIONS and DOTENV_CONFIG_PATH, which can re-inject HARPER_CLI_* from outside the throwaway cwd after the scrub. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZPy6UYZtwxhzLgimHWbGD * test: make the QA anchors assert what their names claim - QA-716 Q3 now asserts the long-transaction monitor actually reached the abandoned iterator (#1860's release-only branch logs it at warn). Without that the arm was blind: dropping the lingering transaction from write supervision, or renaming storage.maxTransactionOpenTime, would leave it sleeping 6s and re-reading writes Q1 had already proved durable. RocksDB only — LMDB never defers a commit and correctly never logs the line. - QA-681 Q3's $share filter now wraps the topic Q3 publishes to. With the filter still naming Events/stream, a regression that returned the right refusal code while leaving an ordinary subscription installed would never be challenged by a publish. - QA-627's target= URL is built literally instead of through URL setters, which percent-encode userinfo while extractTargetCredentials returns it undecoded; a password containing @ would otherwise report a #1873 regression that did not happen. - All three readiness polls use the existing waitForRouteReady helper, which reports last status and error on timeout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZPy6UYZtwxhzLgimHWbGD * test: isolate the target= URL arm from the ambient admin password Building the URL by hand was only a partial fix: the credential still reaches `new URL(target)` inside bin/cliCredentials.ts, which percent-encodes userinfo that extractTargetCredentials then returns undecoded, so `:` `;` and `=` in a password break the arm too — not just `@`, which the guard caught. The arm now provisions its own super_user with an alphanumeric password, so it pins auth precedence rather than the shape of whatever HDB_ADMIN_PASSWORD happens to be. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZPy6UYZtwxhzLgimHWbGD * test: close MQTT client leak paths in the QA-681 anchor A connect() that throws partway through an arm left the clients established before it open, and Q4 never tore down its deliberately-dropped client at all — so a failure before the drop left a live MQTT socket holding the runner's event loop, which is how a failing shard turns into a hanging one. Each arm now establishes its connections inside the try and endQuiet()s every handle, which already tolerates undefined. The connect() timer handle is hoisted to a declaration ahead of its handlers. No temporal-dead-zone error was reachable — the listeners are attached after the timer is initialized, so neither handler can run before it exists — but declaring it first makes that local rather than an argument about ordering. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZPy6UYZtwxhzLgimHWbGD * test: drop the mqtt.js internal transport access from the QA-681 anchor end(true) already closes the socket without sending a DISCONNECT, so the extra (client as any).stream?.destroy?.() bought nothing and would silently stop working if mqtt.js renamed the property. Removing it is safe to verify rather than assume: the boundary assertion added earlier requires that the dropped client received nothing published after the drop, and it holds across repeated runs with recv-before-drop=15. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZPy6UYZtwxhzLgimHWbGD * test: stop swallowing late MQTT errors, and make QA-716 Q3 self-contained The post-connect `on('error', () => {})` was mandatory — an unhandled 'error' on an EventEmitter throws — but discarding it meant a broker crash or socket reset surfaced only as a downstream waitFor timeout, with the real cause gone. It now logs; Q4 destroys a transport deliberately, so this must not fail the run. Q3 seeds and fulfills its own bucket. Reading Q1's rows made it fail whenever Q1 failed and left it unrunnable in isolation, while testing nothing Q1 had not already established — the same coupling already removed from Q2. No arm in the file shares state now, so the suite-level bindings are gone with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZPy6UYZtwxhzLgimHWbGD --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
What / why
add_user/alter_userreused the sameusername=/password=CLI args as both the HTTP Basic auth credentials and the operation payload, so the CLI tried to log in as the user being created. That made non-interactive (CI/CD)add_user/alter_user— authenticating as an existing admin while creating/altering a different user — impossible. Env-var auth (HARPER_CLI_USERNAME/HARPER_CLI_PASSWORD) was also silently overridden by the payload params.Root cause and fix, entirely in
bin/cliOperations.ts:auth_username=/auth_password=that always win the auth leg, and added them toTRANSPORT_ONLY_FIELDSso they never serialize into the request body.username/passwordfromTRANSPORT_ONLY_FIELDS— they're legitimate operation-payload fields for several ops (add_user,alter_user,create_authentication_tokens), not just transport. They remain the auth fallback for ops where they legitimately are the auth (e.g. plaincreate_table ... username= password=), preserving backward compatibility.HARPER_CLI_USERNAME/HARPER_CLI_PASSWORD,CLI_TARGET_USERNAME/CLI_TARGET_PASSWORD) beats plainusername=/password=when no dedicatedauth_*arg is set — otherwise a configured CI login is silently shadowed by an operation's own payload fields. Confirmed with @kriszyp before implementing (this is a deliberate behavior change, called out in the issue).body = req), which sent the entire raw, unstrippedreqobject as the wire body — now uses the sameoperationFields()stripping the deploy paths already used, soauth_username/auth_password(andtarget/rejectUnauthorized/etc.) never leak into any request body.HELPtext (bin/harper.ts) to document the new args and env-var auth for the non-interactive path. Companion docs: HarperFast/documentation#613.Test plan
dedicated auth args vs operation payload fields (harper#1872)describe block inunitTests/bin/cliOperations.test.js: auth_username/auth_password win the Basic-auth header while add_user payload username/password stay in the body; backward-compat fallback to plain username/password when nothing else is configured; env-var auth beats payload username/password; auth args stripped from both the non-deploy JSON body and the multipart directory-deploy body.unitTests/bin/cliOperations.test.jssuite passes (30/30).test:unit:mainpattern): 3743 passing, 10 pre-existing failures inunitTests/components/globalIsolation.test.jsunrelated to this change (component sandbox path resolution, reproduces identically onmain).oxlintandprettier --checkclean on all three changed files.Notes for reviewers
npm run buildcurrently fails onmainwith a pre-existing, unrelated TypeScript error inresources/DatabaseTransaction.ts(Promise<number | void>not assignable toPromise<void>, from feat(analytics): record write-transaction commit latency #1688) — not touched by this PR,tscstill emits (noEmitOnErroris off) so this didn't block testing.Follow-up: review round 2 (commit
9473f61ba)Three issues from review, plus three found by an outside-model pass on the fix itself:
1. Saved
harper logintoken was still unreachable foradd_user/alter_user(blocker). The payload fallback was folded intotarget.usernamebefore the Authorization mode was chosen, andif (target?.username)selects Basic ahead of the saved-token branch — soharper loginfollowed byadd_user username=newuser password=...authenticated as the user being created and 401'd. Authentication now resolves in this order:auth_*args → target-URL userinfo →HARPER_CLI_*→CLI_TARGET_*)harper loginoperation tokenusername=/password=payload fallback — now requiring both, so a loneusername=(drop_user username=bob) stays payload rather than becoming half a credential2. Credentials are resolved as an atomic pair, never composed across sources.
resolveTransportCredentials()walks the sources and returns the first that supplies anything, soauth_username=admincan no longer be paired with anadd_userpayload password. A source counts as configured once either half is supplied, empty or not, so a blank CI variable doesn't read as "absent". An incomplete explicit source (args, URL userinfo) is a hard error; an incomplete env pair warns on stderr and is skipped, because a loneHARPER_CLI_USERNAME/HARPER_CLI_PASSWORDis a supportedharper loginidiom and shouldn't break every later operation in the same shell or CI job.3. Passwords no longer reach logs or terminal output. The parsed CLI request is redacted before
logger.trace, and a password embedded intarget=https://admin:secret@hostis masked in theConnecting to ...line and anywhere the target is logged. Help text now leads with the env-var form and states that a password passed as an argument is exposed to shell history, process listings and CI logs.Also fixed in
bin/login.ts(found while verifying the above):harper loginmixed env namespaces — username fromCLI_TARGET_USERNAME, password fromHARPER_CLI_PASSWORD— which is the same cross-pairing hazard. Whichever namespace supplies anything now owns both halves; a half it doesn't set is prompted for. This also flips login's namespace preference toHARPER_CLI_*-first, matching the rest of the CLI (only observable if both complete pairs are set to different values).Follow-up test plan
unitTests/bin/cliOperations.test.js: 42 passing. New coverage — saved token wins for bothadd_userandalter_userwhile payload creds stay in the body; lone payloadusername=is not auth;CLI_TARGET_*pair still authenticates; loneauth_username, loneauth_password, emptyauth_passwordand both-empty auth args all fail with a clear error; incomplete env pair warns and falls through to the saved token; URL password masked in the connection log;redactCredentials()unit tests.unitTests/bin/login.test.js: newcredential sourcesblock — no cross-namespace pairing,CLI_TARGET_*-only still works, credentials sent as dedicated transport auth.npm run test:unit:main: 3758 passing, 10 pre-existingunitTests/components/globalIsolation.test.jsfailures (verified identical with these changes stashed).oxlintandprettier --checkclean.Open question for reviewers
The asymmetry in 2 is a judgment call: incomplete explicit credentials are fatal, incomplete env credentials only warn. The alternative (fatal everywhere) would break
export HARPER_CLI_USERNAME=admin; harper login; harper <op>— every operation after the login would fail even though the saved token is perfectly good. Happy to make it uniformly fatal if reviewers prefer that.Note on the extra commit
676990fff676990fffis not part of this change. It's a one-line type widening inresources/DatabaseTransaction.ts(recordCommitLatency's parameter toPromise<unknown>) that fixes the pre-existingnpm run buildfailure from #1688, which was turning the downstream Next.js adapter integration check red on this PR for reasons unrelated to it. The same fix is queued as its own PR againstmain; once that lands, this commit is redundant and can be dropped in the rebase.Refs #1872
🤖 Generated with Claude Code