feat(admin): rc admin account and user two-factor commands - #358
Conversation
Defines the self-service account and MFA operations as `rc-core` traits with a bounded `rc-s3` transport, mirroring the server's contract. None of them takes a target identity, so they cannot be used to act on another account; managing someone else's credentials goes through the user-management API instead. `SecretValue` wraps every password and code so it is zeroed on drop and cannot be printed into a log or a panic message. The JSON and empty-body request paths are separate helpers: treating an empty response as a default value would report "two-factor authentication is off" for a response that never arrived.
Adds `rc admin account info|passwd|mfa {status,enroll,activate,disable,
recovery-codes}` for the identity the alias authenticates as, and
`rc admin user passwd|mfa {status,reset}` for another identity. The split
mirrors the server, where `/account/*` never takes a target and the user
endpoints always do, so a command cannot act on the wrong account.
Every command works non-interactively. No password is accepted on the
command line, where it would be captured by shell history and visible in
`ps`: passwords come from `--*-from-env` or `--*-file`, or from a prompt
with echo off when stdin is a terminal. In `--json` mode, or without a
terminal, a command that would need to prompt exits with a usage error
naming the flag to pass — it never blocks on input it cannot receive.
The server renders the QR code and `rc` prints the block art it returns,
so there is no QR encoder here and the console shows the same symbol from
the same source. It is skipped below 33 columns, where a wrapped symbol
cannot be scanned.
`--output-file` writes recovery codes with mode 0600 and refuses to
overwrite an existing file, which may hold the only copy of a previous
set. `user mfa reset` names its target and needs `--yes` when scripted.
Two-factor authentication does not gate `rc` itself, and the reference
documents why: gating signed requests would break every script the moment
a human enabled it, while adding no protection.
|
+1 |
f5b8d33 to
45524fd
Compare
|
+1 |
overtrue
left a comment
There was a problem hiding this comment.
Took this for a proper spin — and since rustfs/rustfs#6596 is merged now, I checked the wire contract against the actual server handlers instead of guessing. The design writeup matches the code, and the account/user split is clean. That said, I found two things I'd consider blockers, plus a handful of smaller ones.
Blockers
1. --*-file silently truncates secrets to 33 bytes.
SecretSource::load goes through read_protected_key_file, and read_exact_key does reader.take(33) (secret_input.rs:131) — that bound is SSE-C-specific. Feed --password-file a 40-char AWS-style secret and the server receives the first 33 bytes with no error anywhere; the operator has now set a password they don't actually know. The same reuse also drags in the symlink rejection (Kubernetes secret mounts are symlinks) and error strings that tell someone changing a password about an "SSE-C key file". I think the file reader needs to be parameterized (length bound + error wording), not reused as-is.
2. Recovery codes can be lost for good.
emit_recovery_codes writes to --output-file after the server has already activated/rotated, and write_recovery_codes uses create_new. So if the path exists — say, left over from a previous attempt, which is exactly the case the create_new comment worries about — the command exits nonzero and the one-time set is dropped unprinted. For recovery-codes the previous set was just invalidated too, so the user ends up with no working codes at all. Printing the codes as a fallback when the write fails (loudly), or checking the path before making the API call, would close this.
Should fix
account.rs:351— whenmutable.passwordis false, the explanation printed isinfo.mfa.enrollment_blocked_reason. That's the MFA field: for the env-root case it's null so the user gets a bare refusal (the docs promise a message naming the env var), and when it is set, an MFA restriction gets attributed to the password.print_enrollmentprints "Scan this QR code with your authenticator app:" unconditionally and discardsprint_qr's return value — with--no-qr, a narrow terminal, or an empty payload, the user is told to scan something that was never printed. The bool exists for exactly this.- Server-controlled strings go to the TTY through plain
printlnwithoutsanitize_text—qr_utf8especially (print_qrwrites it raw, line by line), but alsootpauth_uri, timestamps, blocked reasons, and the recovery codes themselves. Everywhere else in the tree escapes server text before printing; a hostile endpoint could repaint the setup-key line with a substituted secret. execute_passwddoes an unconditionalaccount_info()round-trip just to fill the JSONaccess_key, and on failure falls back to the alias name — so--jsoncan report"access_key": "prod"whereprodis the alias, not an access key. I'd rather the field be absent than wrong; and the call could live inside the JSON branch so the human path doesn't pay for it.mfa disableresolves the code (possibly prompting) before validating the password flags, so conflicting--password-*flags burn a live TOTP code before the usage error appears.execute_passwdalready sequences this the right way.- The AGENTS.md checklist asks for 2 exit-code test scenarios per new command, but everything here is clap-parse or helper-level — nothing asserts an
ExitCode, and thefrom_i32(...).unwrap_or(GeneralError)mappings are untested.tests/admin_service_account.rsis the existing pattern.
Take or leave
request_account_bytes/json/emptyre-derive whatrequest_bounded_json+request_no_responsealready do, and the three user endpoints hand-build?accessKey=into the path instead of passing the helpers' query param. Worth folding in so the sign/bound path doesn't fork and query encoding can't drift from SigV4 canonicalization.- The local
fail/usage_failureroute throughformatter.error(i.e.from_message), so the--jsonerror envelope carriescode: nullwhile commands onformatter.fail*carry the real code.Formatter::failalready exists and does both halves. confirm_mfa_resetis the third copy of the confirm prompt (idp.rs::confirm_delete,replicate.rs), andwrite_recovery_codesre-doesconfig.rs::write_private_file. Fine to punt, but a shared helper is overdue at three copies.- Two small zeroization gaps at odds with the writeup: the
Filearm doesString::from_utf8(bytes.to_vec()), leaving two unzeroized copies of the password (std::str::from_utf8(&bytes)borrows instead), and the enroll JSON branch clonessecret_base32/otpauth_uriwhere it could move them.
Checked and fine
In case anyone else wonders: both password endpoints return a sessions_revoked JSON body, so request_account_json rejecting an empty body is safe; the server DTOs for this family are snake_case, matching yours; and the server always serializes algorithm and qr_utf8, so the non-defaulted serde fields are OK against this server.
Things I genuinely liked: create_new + 0600 on the codes file (modulo the ordering issue above), the const assert pinning the QR floor to the symbol geometry, and the "2FA doesn't gate rc" writeup — that last one is exactly the doc a future operator will need.
Two of these could lose or corrupt a secret. `--*-file` went through the SSE-C key reader, which stops at 33 bytes. A 40-character secret key handed to `--new-password-file` therefore set a password made of its first 33 bytes, with nothing anywhere reporting it. The reader is now parameterized: the read bound, the error wording and the hardening rules come from the caller. The SSE-C path keeps its exact behaviour, including reading 33 so its own "exactly 32 bytes" check still speaks; the account path reports an oversized file instead of returning a prefix, because nothing downstream verifies that length. It also accepts a symlink and a group-readable mode, which is how Kubernetes projects a secret into a container and something an SSE-C key has no reason to allow. Recovery codes could be lost outright. The write happened after the server had already activated or rotated, and refused to clobber an existing file, so a leftover path meant the only copy of the new set was dropped unprinted — with the previous set already invalid. The path is now checked before the request, and if the write fails anyway the codes are printed rather than discarded, with a non-zero exit saying the file was not written. Under `--json` they go to stdout and the error to stderr, so a script gets both. Also, from the same review: - The reason printed for an immutable password was `enrollment_blocked_reason`, which belongs to enrollment: it attributed a two-factor restriction to the password, and printed nothing in the environment-root case where the field is absent. It is now derived from `credentials_source` and `identity_type`. - `print_enrollment` told the user to scan a QR code it had not printed. It now uses `print_qr`'s return value, which exists for this. - Server-controlled text reached the terminal through `println`, which does not escape: the rendered QR above all, but also the setup URI, timestamps and blocked reasons. These go through `sanitize_text` now. JSON output does not, deliberately — `serde_json` escapes correctly and a consumer needs the value the server sent. - `passwd` spent a round-trip on `account_info` for every run and fell back to the alias name, so `--json` could report an alias as an access key. The call moved into the JSON branch and the field is omitted when unknown. - `mfa disable` resolved the code, possibly prompting, before validating the password flags, so a conflicting pair burned a live TOTP code before the usage error appeared. - The local `fail`/`usage_failure` used `Formatter::error`, so the `--json` error envelope carried `code: null` while the rest of the CLI carried the real one. - The account request helpers re-derived the sign, bound and send sequence. They now sit on `request_bounded_bytes`, extracted from `request_bounded_json`, and the user endpoints pass `accessKey` as a query parameter instead of building it into the path. - The `File` secret arm copied out of its zeroizing buffer, leaving two unzeroized copies of the password; the enroll JSON branch cloned what it could move. `crates/cli/tests/admin_account.rs` covers two exit-code scenarios per new command, per the PR checklist. Two of them pin the failures above: that a 40-byte secret arrives whole, and that an occupied output path is refused before any request leaves.
Both had reached a third copy, which is where the review asked for them to be folded together. Deleting an OIDC provider, running a replication check that writes to every configured target, and clearing somebody's second factor each carried their own confirmation. They differed only in three strings, and agreed on the parts that matter: `--yes` skips the question, a run with nobody to ask fails rather than assuming consent, and anything other than `y`/`yes` is a decline. Those are exactly the rules a fourth copy would get subtly wrong, so they now live in `crate::confirm` and each caller supplies its own wording. `admin config export` and the recovery-code output both create a file that only its owner may read. The mechanics — created rather than opened, `0600` set in the open flags so it is never briefly readable by anyone else, never a silent overwrite — move to `crate::private_file`. How the refusal is read stays with the callers, because it genuinely differs: an export that will not clobber a file is an ordinary I/O failure, while an occupied recovery-code path is a conflict the operator has to resolve before there is anywhere to put the only copy of a set the server has already issued. `config.rs` has a test pinning the first of those, and this change keeps it passing. No behaviour changes. The refactor left three `std::io` imports unused, which `-D warnings` rejects, and one `rc_core::Error` import used only from a test module; that assertion now uses the fully qualified path its neighbours in the same file already use.
|
Thank you — checking the wire contract against the merged handlers rather than the writeup caught two things I would not have found from the CLI side alone. Everything is addressed except one item I left, called out at the end. Pushed as two commits: Blockers1.
The SSE-C path is byte-identical to before, deliberately. It still reads 33 rather than rejecting at 32, because The account spec accepts a symlink and a group-readable mode. You are right that a projected Kubernetes secret is a symlink into Bound is 4096 bytes, and a longer file is reported rather than truncated. 2. Recovery codes lost. Fixed at both ends, because either alone leaves a hole. The output path is now checked before the request — via Both are covered: The pre-flight reports Should fix
Take or leave — taken
Not doneNothing. I had intended to punt the third-copy item since you marked it fine to, but it turned out to be small once the strings were the only difference, so it is in the second commit.
One thing I did not verify: I have no way to test the projected-Kubernetes-secret path end to end, only the file shape it produces. If you know of a mount layout that differs from symlink-plus- |
|
Heads-up on the one red check, since it needs a nudge rather than a fix.
The check will not pick that up on its own. So: happy to push a trivial commit to re-trigger, but I did not want to put a no-op in the history of your repo unasked. If there is another round of changes coming, that push will clear it by itself. Everything else is green — Format Check, Clippy, Documentation, MSRV, advisories, and the Linux/macOS/Windows test jobs. Worth saying too: the check fires on any diff under |
Summary
Adds CLI coverage for the self-service account and two-factor surface introduced in rustfs/rustfs#6596.
accountacts on the identity the alias authenticates as;useracts on another identity. The split mirrors the server, where/account/*never takes a target and the user endpoints always do, so a command cannot act on the wrong account.Merge order
rustfs/rustfs#6596 has merged, so the endpoints this PR calls now exist on
main. This PR and rustfs/console#208 are independent of each other and can landin either order.
Design notes for reviewers
Two-factor authentication does not gate
rc, on purpose.rcsigns every request with the alias access key, and that path is not gated. Gating it would break every script the moment a human enabled the second factor on their own account, and would add no protection — whoever holds the secret key already has full access without presenting a code. The second factor guards session minting (AssumeRole), whichrcdoes not use. Documented indocs/reference/rc/admin.mdso operators don't read the absence as an oversight.No secret on the command line. Passwords come from
--*-from-env,--*-file, or a prompt with echo off — never an argument, where they would land in shell history andpsoutput. Verification codes additionally accept--codebecause a TOTP code is valid for at most 90 seconds;--codeand--code-from-envare mutually exclusive.Nothing blocks on input it cannot receive. In
--jsonmode, or when stdin is not a terminal, a command that would need to prompt exits withUSAGE_ERRORnaming the flag to pass instead.No QR encoder here. The server renders the code and
rcprints the Unicode block art it returns, so the console and the CLI show the same symbol from one implementation. Skipped below 33 columns, where a wrapped symbol cannot be scanned;--no-qrprints only the setup key and URI.--jsonomits the QR entirely.Recovery codes.
--output-filewrites with mode0600and refuses to overwrite an existing file, which may hold the only copy of a previous set.SecretValuezeroes every password and code on drop and cannot be printed into a log or a panic message.user mfa resetis break-glass for a user who lost both their authenticator and their recovery codes. It names the target and requires--yeswhen scripted.Testing
New:
crates/cli/tests/admin_account.rsgives each new command at least twoexit-code scenarios, per
AGENTS.md. Two of them pin specific failures found inreview rather than only an exit status — that a 40-byte secret reaches the server
whole, and that an occupied recovery-code output path is refused before any
request leaves. Plus unit tests for the parameterized file reader, the shared
confirmation prompt and the private-file writer.
Contracts
schemas/output_v3.json— unchanged. The new commands reuse the existing envelopes, so this is not a breaking output change.crates/cli/src/exit_code.rs— unchanged.crates/core/src/config.rs— unchanged; noschema_versionbump needed.docs/reference/rc/admin.md— updated.docs/reference/rc/is a protected path, so this carries the BREAKING markerthat
AGENTS.md§ Breaking Change Process step 3 requires for any change to theCLI behaviour contract. To be clear about what that marker does and does not mean
here: no released behaviour changes. The reference edits document commands
this PR introduces, and clarify how
--*-filereads a secret — a flag that alsoarrives in this PR. Steps 1 and 2 of that process are satisfied without work:
there is no
schema_versionto bump and no new output schema, and the commandreference sections are exactly what was updated. The SSE-C key reader shares code
with the new secret reader and its behaviour, including its error strings, is
deliberately left byte-identical;
tests/sse_customer.rsholds it there.If you would rather this marker not ride along on an additive PR, the check in
ci.ymlfires on any diff underdocs/reference/rc/, including a pure addition —happy to follow whatever you prefer instead.
Additional Notes
rc admin account passwdprints a reminder to update the alias withrc alias setafterwards, since rotating the secret invalidates the stored one. Automatically rewriting the alias felt like the wrong call — the command would then mutate local config as a side effect of a remote operation — but I'll change it if you'd prefer that.