mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-29 16:37:07 +00:00
feat(admin): self-service account management and TOTP two-factor authentication (#6596)
* feat(madmin): add account and two-factor wire contract Defines the self-service account and MFA API shapes in one place so the console and the `rc` CLI decode identical payloads instead of each carrying its own copy of the contract. `AccountMutability` is part of the contract on purpose: a client needs to know whether the server will accept a password change for this identity before offering the control, rather than discovering it from a rejected request. * feat(s3-types): add IAM identity audit events Adds `iam:Identity:CredentialChanged` and `iam:Identity:AuthChallenge` so account and authentication activity reaches the audit pipeline in its own namespace, the way the KMS events already do. Neither is reachable from a bucket notification config. Two variants for the whole surface rather than one per operation: `mask()` gives every variant its own bit in a `u64`, and the budget is nearly spent (63 of 64 used after this). The per-operation detail lives in `AuditEntry::api.name` and the `iamOperation` tag, which is what a SIEM filters on anyway. Splitting these further needs `mask()` widened first. * feat(iam): add two-factor authentication primitives Implements the state machine behind TOTP enrollment and verification in the IAM domain, so the admin handlers stay HTTP plumbing and the console and CLI drive identical logic. * `totp`: RFC 6238 over the workspace's existing hmac/sha1, pinned to the published Appendix B vectors. SHA-1, 6 digits, 30s: the parameters every mainstream authenticator app implements. Verification returns the matched time step so the caller can burn it. * `recovery`: ten single-use codes, 100 bits each, in a Crockford base32 alphabet without I/L/O/U. Stored as domain-separated SHA-256 digests — a password KDF would have to run once per stored code on every attempt, turning each guess into an attacker-controlled cost, and with uniform 100-bit input there is no dictionary for it to defend against. * `challenge`: stateless HMAC tokens. A TTL cache would be node-local, so a cluster without session affinity would issue on one node and verify on another; nothing here needs replicating. * `record`: two-phase enrollment, replay high-water mark, and lockout. Pending enrollment never gates a login, so a mis-scanned QR cannot lock an operator out, and re-configuring keeps the old factor working until the new one is confirmed. * `store`: one object per identity under `config/mfa/`, a sibling of `config/iam/` so the IAM cache loader's startup walk does not sweep it up. Optimistic `If-Match` writes; deliberately uncached, because a cache would need cluster-wide invalidation to keep the replay mark and the lockout counter honest. * `qr`: server-side rendering, so neither client needs a QR encoder. Enrollment is refused without `RUSTFS_IAM_MASTER_KEY`. A TOTP secret is credential-equivalent, and one written in plaintext could be lifted off a disk — worse than no second factor, because the user believes they have one. IAM identities tolerate a missing master key for backward compatibility; a new feature has no such history to honour. Also adds `IamSys::revoke_sts_sessions_for_parent`, so a credential rotation can invalidate the sessions minted under the old secret. * feat(admin): add self-service account endpoints and the two-factor login gate Adds the account surface (`/v3/account/*`), the second-factor endpoints, the administrative reset (`/v3/user/mfa`), and `PUT /v3/set-user-secret-key`, plus the gate on `AssumeRole`. What the gate covers, and what it deliberately does not: * `AssumeRole` is the only interactive login RustFS has, so it is where a second factor can be enforced. With one enrolled it requires `TokenCode`; without an enrollment the code path is unchanged, so existing deployments are untouched. * A request signed directly with a long-term access key stays ungated. Gating it would break every script and CLI the moment a human enabled 2FA on their own account, and would add no protection: whoever holds the secret key already has full access without presenting a code. This is the division AWS draws; making 2FA meaningful for API access needs an `aws:MultiFactorAuthPresent` policy condition, tracked separately. `SerialNumber`/`TokenCode` are STS's own parameters, so an SDK or script authenticates the same way the console does. `caller_identity` resolves who a request acts as. The console signs with a short-lived STS session, so "the caller" is almost never the key that signed. It reports two separate capabilities: root cannot rotate its secret (a process-wide `OnceLock` that also derives the internode RPC secret) but *can* enroll a second factor — conflating the two would leave the default deployment's console login unprotectable. The self-service routes carry no admin action. Giving them one would be wrong in both directions: it would stop an ordinary user from changing their own password, and let any holder of that action change someone else's. They gate on possession of the credential plus, for the mutations, knowledge of the current secret — a signature only proves a credential was used, so without that a hijacked tab could rewrite the account's credentials or strip its second factor. `set-user-secret-key` exists because the only prior way to change a password was to re-POST the whole user through `add-user`, which rewrote `status` and dropped the policy field — a password reset that silently re-enabled a disabled account. Wrong, replayed and malformed codes are indistinguishable on the wire; the distinction survives only in the audit trail, where no submitted value, secret or code is ever recorded. * test(e2e): cover the two-factor lifecycle and its regressions Unit tests cover the state machine at its edges; only an end-to-end test proves the pieces are wired together and that the existing authentication paths still behave. Asserts, against a real server: enrollment is refused without a master key; the full enroll/activate flow works with a genuine RFC 6238 code; `AssumeRole` refuses without a factor and accepts a valid one; a recovery code works exactly once; a direct SigV4 admin request keeps working with a factor enrolled; `AssumeRole` for an unenrolled identity is unchanged; and a password rotation invalidates the old secret. The test computes TOTP codes itself rather than calling the server's implementation — a shared helper could agree with a bug on both sides. This suite caught a real defect during development: enrollment was refused for root because its *password* is immutable, which would have left the default deployment — an administrator signing into the console as root — unable to protect the one login the feature exists for. * docs(operations): document the two-factor authentication model Records what the second factor protects and what it deliberately does not, because several of the boundaries look like gaps until the alternative is spelled out: why direct SigV4 access stays ungated, why root credentials cannot be rotated at runtime, why secret keys cannot be hashed in an S3 server, and why at-rest protection is mandatory for a TOTP secret but optional for an IAM identity. Also states the limitations plainly, including that GHSA-m77q-r63m-pj89 is unaffected: a holder of the root secret can still forge a session token, 2FA claim included. Placed alongside the other authentication and KMS security documents rather than under a new `docs/security/`, which `.gitignore` excludes. * fix(admin): route the new account handlers through the admin s3 facade Two of the guardrails in the CI "Quick Checks" job rejected the previous commits, so the required check would have gone red as soon as a maintainer approved the workflow run. `check_architecture_migration_rules.sh` requires everything under `rustfs/src/admin` to reach `ECStore` through a domain module rather than the root of `storage_api`. The MFA handler and the two `AssumeRole` signatures now use `storage_api::runtime::ECStore`, which is where the other ten admin handlers already take it from. `check_s3s_footprint.sh` ratchets two counters that new code may not grow: files referencing `s3s` and error-macro invocation lines. This branch added four files and thirty-two lines to them. The ratchet is lower-only and its header forbids raising a baseline to get green, so the construction moves behind the facade instead: `storage_api::s3` now re-exports the request and body types these handlers need and gains an `error` constructor over `S3Error::with_message`. That is the same constructor the macro expands to and the one `handlers/mod.rs`, `rebalance_internal_error` and `invalid_object_lock_configuration` already call, so this is the existing practice rather than a new one, and it keeps the `s3s` dependency in the boundary file the s3gate migration replaces. Every error code and message is carried over unchanged. In `sts.rs` only the call site this branch added is converted; the sixteen that predate it are left alone, because rewriting them would put unrelated churn in a feature PR and push the counter below the baseline it is meant to hold.
This commit is contained in:
@@ -105,6 +105,17 @@ pub enum EventName {
|
||||
KmsServiceConfigured,
|
||||
KmsServiceStarted,
|
||||
KmsServiceStopped,
|
||||
|
||||
// IAM identity management-plane events. Like the KMS block above they reach
|
||||
// the audit sink only, and must keep being appended last.
|
||||
//
|
||||
// Deliberately coarse: only two variants for the whole account/MFA surface,
|
||||
// because `mask()` gives every variant its own bit in a `u64` and the budget
|
||||
// is nearly spent. The specific operation lives in `AuditEntry::api.name`
|
||||
// and the `iamOperation` tag, which is what a SIEM filters on anyway.
|
||||
// Splitting these per-operation would need `mask()` widened first.
|
||||
IamIdentityCredentialChanged,
|
||||
IamIdentityAuthChallenge,
|
||||
}
|
||||
|
||||
// Single event type sequential array for Everything.expand()
|
||||
@@ -159,7 +170,7 @@ const LAST_SINGLE_TYPE_VALUE: u32 = EventName::IntelligentTiering as u32;
|
||||
/// meaningful: `mask()` turns a leaf variant's discriminant `v` into the bit
|
||||
/// `1 << (v - 1)`, so the highest discriminant is also the highest bit index in
|
||||
/// use. Keep this pointing at whatever variant is declared last.
|
||||
const LAST_EVENT_NAME_VALUE: u32 = EventName::KmsServiceStopped as u32;
|
||||
const LAST_EVENT_NAME_VALUE: u32 = EventName::IamIdentityAuthChallenge as u32;
|
||||
|
||||
/// `mask()` returns a `u64`, so discriminants may run from 1 to 64 inclusive.
|
||||
///
|
||||
@@ -244,6 +255,9 @@ impl EventName {
|
||||
"kms:Service:Configured" => Ok(EventName::KmsServiceConfigured),
|
||||
"kms:Service:Started" => Ok(EventName::KmsServiceStarted),
|
||||
"kms:Service:Stopped" => Ok(EventName::KmsServiceStopped),
|
||||
// IAM events use their own namespace for the same reason KMS does.
|
||||
"iam:Identity:CredentialChanged" => Ok(EventName::IamIdentityCredentialChanged),
|
||||
"iam:Identity:AuthChallenge" => Ok(EventName::IamIdentityAuthChallenge),
|
||||
// `Everything` has no string representation (`as_str` yields ""), so it
|
||||
// cannot be parsed back from a string. Every other variant round-trips.
|
||||
_ => Err(ParseEventNameError(s.to_string())),
|
||||
@@ -320,6 +334,8 @@ impl EventName {
|
||||
EventName::KmsServiceConfigured => "kms:Service:Configured",
|
||||
EventName::KmsServiceStarted => "kms:Service:Started",
|
||||
EventName::KmsServiceStopped => "kms:Service:Stopped",
|
||||
EventName::IamIdentityCredentialChanged => "iam:Identity:CredentialChanged",
|
||||
EventName::IamIdentityAuthChallenge => "iam:Identity:AuthChallenge",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,6 +483,14 @@ impl EventName {
|
||||
| EventName::KmsServiceStopped
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether this is an IAM identity management-plane event.
|
||||
///
|
||||
/// Mirrors [`Self::is_kms`]: these reach the audit sink only, so nothing in
|
||||
/// the bucket notification path should ever select them.
|
||||
pub fn is_iam(&self) -> bool {
|
||||
matches!(self, EventName::IamIdentityCredentialChanged | EventName::IamIdentityAuthChallenge)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the S3 notification event schema version for a given event.
|
||||
@@ -702,7 +726,9 @@ mod tests {
|
||||
| EventName::KmsKeyAccessed
|
||||
| EventName::KmsServiceConfigured
|
||||
| EventName::KmsServiceStarted
|
||||
| EventName::KmsServiceStopped => {}
|
||||
| EventName::KmsServiceStopped
|
||||
| EventName::IamIdentityCredentialChanged
|
||||
| EventName::IamIdentityAuthChallenge => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -770,6 +796,8 @@ mod tests {
|
||||
EventName::KmsServiceConfigured,
|
||||
EventName::KmsServiceStarted,
|
||||
EventName::KmsServiceStopped,
|
||||
EventName::IamIdentityCredentialChanged,
|
||||
EventName::IamIdentityAuthChallenge,
|
||||
];
|
||||
|
||||
/// Every KMS management-plane event.
|
||||
@@ -858,7 +886,7 @@ mod tests {
|
||||
/// only as one element of an array someone may forget to extend.
|
||||
#[test]
|
||||
fn test_last_variant_still_gets_its_own_bit() {
|
||||
let last = EventName::KmsServiceStopped;
|
||||
let last = EventName::IamIdentityAuthChallenge;
|
||||
assert_ne!(last.mask(), 0, "the last variant's mask overflowed to zero");
|
||||
assert_eq!(
|
||||
last.mask(),
|
||||
@@ -1006,6 +1034,43 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every IAM identity management-plane event.
|
||||
const IAM_EVENT_NAMES: &[EventName] = &[EventName::IamIdentityCredentialChanged, EventName::IamIdentityAuthChallenge];
|
||||
|
||||
/// IAM event names must live in their own namespace, for the same reason
|
||||
/// KMS ones do: a bucket notification config must not be able to subscribe
|
||||
/// to account or authentication activity.
|
||||
#[test]
|
||||
fn test_iam_event_names_are_outside_the_s3_and_kms_namespaces() {
|
||||
for ev in IAM_EVENT_NAMES {
|
||||
assert!(ev.is_iam(), "{ev} should be classified as an IAM event");
|
||||
assert!(!ev.is_kms(), "{ev} must not also claim the KMS namespace");
|
||||
assert!(ev.as_str().starts_with("iam:"), "unexpected IAM event name {:?}", ev.as_str());
|
||||
assert_eq!(EventName::parse(ev.as_str()).as_ref(), Ok(ev), "IAM event {ev} must round-trip");
|
||||
assert_eq!(ev.expand(), vec![*ev], "IAM event {ev} must expand to itself only");
|
||||
}
|
||||
|
||||
for ev in ALL_EVENT_NAMES.iter().filter(|ev| !ev.is_iam()) {
|
||||
assert!(!ev.as_str().starts_with("iam:"), "{ev} must not claim the IAM namespace");
|
||||
}
|
||||
}
|
||||
|
||||
/// Each IAM event must own a distinct mask bit that no S3 selector shares.
|
||||
#[test]
|
||||
fn test_iam_event_masks_do_not_collide() {
|
||||
let mut seen = 0u64;
|
||||
for ev in IAM_EVENT_NAMES {
|
||||
let mask = ev.mask();
|
||||
assert_ne!(mask, 0, "IAM event {ev} must have a non-zero mask");
|
||||
assert_eq!(seen & mask, 0, "IAM event {ev} mask overlaps another IAM event");
|
||||
seen |= mask;
|
||||
|
||||
for kms in KMS_EVENT_NAMES {
|
||||
assert_eq!(kms.mask() & mask, 0, "IAM event {ev} mask collides with KMS event {kms}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// KMS event names must live in their own namespace so that neither a
|
||||
/// `s3:` prefix filter nor an `s3:...:*` wildcard can select them.
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user