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:
Sinan Eldem
2026-08-26 04:35:29 +03:00
committed by GitHub
parent 8f196f2f20
commit b93e7b2355
35 changed files with 6263 additions and 18 deletions
+518
View File
@@ -0,0 +1,518 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Self-service account endpoints.
//!
//! * `GET /rustfs/admin/v3/account/info` — describe the caller to itself
//! * `POST /rustfs/admin/v3/account/password` — rotate the caller's own secret
//!
//! These act on whoever is calling rather than on a target named in the
//! request, so they carry no admin-action gate: every authenticated identity
//! may inspect and manage itself. What they do carry instead is a proof-of-
//! knowledge check, because a signature only proves that a credential was
//! *used* — the Console signs with a short-lived STS session, so a hijacked
//! browser tab could otherwise rewrite the parent identity's password without
//! ever knowing it.
//!
//! Who may rotate a secret at all is decided by
//! [`crate::admin::service::caller_identity`], not here.
use super::account_audit::{
AccountAuditContext, AccountAuditFailure, AccountAuditOperation, AccountAuditRecord, emit as emit_audit,
};
use super::admin_json_response;
use super::iam_error::iam_error_to_s3_error;
use super::supervise_admin_mutation;
use crate::admin::auth::validate_admin_request;
use crate::admin::router::{AdminOperation, Operation, S3Router};
use crate::admin::runtime_sources::{current_action_credentials, current_ready_iam_handle, object_store_from_req};
use crate::admin::service::caller_identity::CallerIdentity;
use crate::admin::storage_api::s3::{self, Body, S3ErrorCode, S3Request, S3Response, S3Result};
use crate::admin::utils::read_compatible_admin_body;
use crate::auth::constant_time_eq;
use crate::server::RemoteAddr;
use http::StatusCode;
use hyper::Method;
use matchit::Params;
use rustfs_config::MAX_ADMIN_REQUEST_BODY_SIZE;
use rustfs_iam::mfa::service as mfa_service;
use rustfs_madmin::account::{AccountMfaSummary, ChangePasswordRequest, IdentityType, SelfAccountInfo, SetUserSecretKeyRequest};
use rustfs_policy::auth::is_secret_key_valid;
use rustfs_policy::policy::action::{Action, AdminAction};
use rustfs_utils::MaskedAccessKey;
use time::OffsetDateTime;
use tracing::{info, warn};
const LOG_COMPONENT_ADMIN: &str = "admin";
const LOG_SUBSYSTEM_ACCOUNT: &str = "account";
const EVENT_ADMIN_ACCOUNT_STATE: &str = "admin_account_state";
pub(crate) const ACCOUNT_INFO_ROUTE: &str = "/rustfs/admin/v3/account/info";
pub(crate) const ACCOUNT_PASSWORD_ROUTE: &str = "/rustfs/admin/v3/account/password";
pub fn register_account_route(r: &mut S3Router<AdminOperation>) -> std::io::Result<()> {
r.insert(Method::GET, ACCOUNT_INFO_ROUTE, AdminOperation(&SelfAccountInfoHandler {}))?;
r.insert(Method::POST, ACCOUNT_PASSWORD_ROUTE, AdminOperation(&ChangeOwnPasswordHandler {}))?;
Ok(())
}
/// `GET /rustfs/admin/v3/account/info`
pub struct SelfAccountInfoHandler {}
#[async_trait::async_trait]
impl Operation for SelfAccountInfoHandler {
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
let caller = CallerIdentity::resolve(&req).await?;
let iam_store =
current_ready_iam_handle().map_err(|_| s3::error(S3ErrorCode::InternalError, "iam is not initialized"))?;
// Root has no IAM record at all — `check_key` special-cases it — so its
// status and memberships are synthesized rather than looked up.
let (status, member_of) = if matches!(caller.identity_type, IdentityType::Root) {
("enabled".to_string(), Vec::new())
} else {
match iam_store.get_user_info(&caller.access_key).await {
Ok(info) => (info.status.as_ref().to_string(), info.member_of.unwrap_or_default()),
// A federated session has no builtin user record; that is not an
// error, it just means there is nothing builtin to report.
Err(_) => ("enabled".to_string(), Vec::new()),
}
};
let policies = iam_store
.policy_db_get(&caller.access_key, &caller.credentials.groups)
.await
.unwrap_or_default();
// Reported inline rather than behind a second round trip, so a client
// can render the whole security surface from one response.
let mfa = match object_store_from_req(&req) {
Some(store) => {
let status = mfa_service::status(store, &caller.access_key, OffsetDateTime::now_utc())
.await
.map_err(|err| s3::error(S3ErrorCode::InternalError, format!("{err}")))?;
AccountMfaSummary {
enabled: status.enabled,
pending: status.pending,
activated_at: status.activated_at,
recovery_codes_remaining: status.recovery_codes_remaining,
last_verified_at: status.last_verified_at,
// Enrollment availability is the MFA capability, not the
// password one: a root identity may protect its console
// login even though its secret key is fixed.
enrollment_available: status.enrollment_available && caller.mfa_denial.is_none(),
enrollment_blocked_reason: match caller.mfa_denial {
Some(denial) => Some(denial.message().to_string()),
None => status.enrollment_blocked_reason,
},
}
}
None => return Err(s3::error(S3ErrorCode::ServiceUnavailable, "the object store is not ready")),
};
let info = SelfAccountInfo {
access_key: caller.access_key.clone(),
identity_type: caller.identity_type,
session_access_key: caller.session_access_key.clone(),
is_admin: caller.is_owner,
status,
member_of,
policies,
credentials_source: caller.credentials_source,
mutable: caller.mutability(),
mfa,
};
admin_json_response(req.uri.path(), &caller.credentials.secret_key, StatusCode::OK, &info)
}
}
/// `POST /rustfs/admin/v3/account/password`
pub struct ChangeOwnPasswordHandler {}
#[async_trait::async_trait]
impl Operation for ChangeOwnPasswordHandler {
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
let caller = CallerIdentity::resolve(&req).await?;
let audit = AccountAuditContext::from_request(&req);
if let Err(err) = caller.ensure_credential_mutation_allowed() {
emit_audit(
&audit,
AccountAuditRecord::failure(
AccountAuditOperation::ChangeOwnPassword,
&caller.access_key,
caller.identity_type,
AccountAuditFailure::NotPermittedForCredential,
)
.with_session_access_key(caller.session_access_key.as_deref()),
);
return Err(err);
}
let path = req.uri.path().to_string();
let body =
read_compatible_admin_body(req.input, MAX_ADMIN_REQUEST_BODY_SIZE, &path, &caller.credentials.secret_key).await?;
let request: ChangePasswordRequest = serde_json::from_slice(&body)
.map_err(|e| s3::error(S3ErrorCode::InvalidRequest, format!("invalid change-password request: {e}")))?;
let iam_store =
current_ready_iam_handle().map_err(|_| s3::error(S3ErrorCode::InternalError, "iam is not initialized"))?;
let Some(stored) = iam_store.get_user(&caller.access_key).await else {
// Reached only if the identity was deleted between authentication
// and this lookup.
emit_audit(
&audit,
AccountAuditRecord::failure(
AccountAuditOperation::ChangeOwnPassword,
&caller.access_key,
caller.identity_type,
AccountAuditFailure::Internal,
),
);
return Err(s3::error(S3ErrorCode::InvalidRequest, "the calling identity no longer exists"));
};
if !constant_time_eq(&request.current_secret_key, &stored.credentials.secret_key) {
warn!(
component = LOG_COMPONENT_ADMIN,
subsystem = LOG_SUBSYSTEM_ACCOUNT,
event = EVENT_ADMIN_ACCOUNT_STATE,
action = "change_own_password",
access_key = %MaskedAccessKey(&caller.access_key),
result = "invalid_current_secret",
"admin account state"
);
emit_audit(
&audit,
AccountAuditRecord::failure(
AccountAuditOperation::ChangeOwnPassword,
&caller.access_key,
caller.identity_type,
AccountAuditFailure::InvalidCurrentSecret,
)
.with_session_access_key(caller.session_access_key.as_deref()),
);
// Deliberately the same message the validation failures below use,
// so a caller cannot distinguish "wrong current password" from
// "new password rejected" by probing.
return Err(s3::error(S3ErrorCode::InvalidRequest, "the current secret key is incorrect"));
}
if let Err(err) = validate_new_secret_key(&request) {
emit_audit(
&audit,
AccountAuditRecord::failure(
AccountAuditOperation::ChangeOwnPassword,
&caller.access_key,
caller.identity_type,
AccountAuditFailure::InvalidNewSecret,
)
.with_session_access_key(caller.session_access_key.as_deref()),
);
return Err(err);
}
let access_key = caller.access_key.clone();
let new_secret_key = request.new_secret_key.clone();
let identity_type = caller.identity_type;
let session_access_key = caller.session_access_key.clone();
let audit_for_task = audit.clone();
// Detached from request cancellation: a client that disconnects between
// the secret write and the session revocation must not leave the old
// sessions alive against a rotated secret.
let sessions_revoked = supervise_admin_mutation("change own password", async move {
let iam_store =
current_ready_iam_handle().map_err(|_| s3::error(S3ErrorCode::InternalError, "iam is not initialized"))?;
iam_store
.set_user_secret_key(&access_key, &new_secret_key)
.await
.map_err(iam_error_to_s3_error)?;
// Sessions minted under the old secret must not outlive it. A
// failure here is reported but does not undo the rotation: the new
// secret is already authoritative, and re-running the revocation is
// safe, whereas rolling the secret back would resurrect it.
let revoked = match iam_store.revoke_sts_sessions_for_parent(&access_key).await {
Ok(revoked) => revoked,
Err(err) => {
warn!(
component = LOG_COMPONENT_ADMIN,
subsystem = LOG_SUBSYSTEM_ACCOUNT,
event = EVENT_ADMIN_ACCOUNT_STATE,
action = "change_own_password",
access_key = %MaskedAccessKey(&access_key),
result = "session_revocation_incomplete",
error = ?err,
"admin account state"
);
0
}
};
emit_audit(
&audit_for_task,
AccountAuditRecord::success(AccountAuditOperation::ChangeOwnPassword, &access_key, identity_type)
.with_session_access_key(session_access_key.as_deref())
.with_sessions_revoked(revoked),
);
info!(
component = LOG_COMPONENT_ADMIN,
subsystem = LOG_SUBSYSTEM_ACCOUNT,
event = EVENT_ADMIN_ACCOUNT_STATE,
action = "change_own_password",
access_key = %MaskedAccessKey(&access_key),
sessions_revoked = revoked,
result = "changed",
"admin account state"
);
Ok(revoked)
})
.await?;
admin_json_response(
&path,
&caller.credentials.secret_key,
StatusCode::OK,
&ChangePasswordResult {
sessions_revoked: sessions_revoked as u32,
},
)
}
}
/// `PUT /rustfs/admin/v3/set-user-secret-key?accessKey=…`
///
/// Administrative reset of another identity's secret key.
///
/// Exists because the only way to change a password before this was to re-POST
/// the whole user through `add-user`, which rewrites `status` and drops the
/// policy field along with it — a password reset that silently re-enabled a
/// disabled account. This touches the secret and nothing else.
pub struct SetUserSecretKeyHandler {}
#[derive(Debug, serde::Deserialize, Default)]
struct SetUserSecretKeyQuery {
#[serde(rename = "accessKey", alias = "access-key")]
access_key: Option<String>,
}
#[async_trait::async_trait]
impl Operation for SetUserSecretKeyHandler {
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
let query: SetUserSecretKeyQuery = match req.uri.query() {
Some(query) => serde_urlencoded::from_str(query)
.map_err(|_| s3::error(S3ErrorCode::InvalidArgument, "failed to decode query"))?,
None => SetUserSecretKeyQuery::default(),
};
let target = query.access_key.unwrap_or_default();
if target.is_empty() {
return Err(s3::error(S3ErrorCode::InvalidArgument, "access key is empty"));
}
let caller = CallerIdentity::resolve(&req).await?;
let audit = AccountAuditContext::from_request(&req);
// The root identity is provisioned from the environment; its secret is a
// process-wide `OnceLock` that also derives the internode RPC secret, so
// there is nothing here that could change it.
if current_action_credentials().is_some_and(|root| constant_time_eq(&root.access_key, &target)) {
return Err(s3::error(
S3ErrorCode::InvalidRequest,
"the root identity is provisioned from the server environment and cannot be changed at runtime",
));
}
// A derived credential must not rewrite the secret of the identity it
// was minted from: the session would otherwise be able to promote
// itself into permanent control of that account.
if caller.session_access_key.is_some() && caller.access_key == target {
return Err(s3::error(
S3ErrorCode::InvalidRequest,
"cannot change the credentials of the parent identity of this session",
));
}
validate_admin_request(
&req.headers,
&caller.credentials,
caller.is_owner,
false,
vec![Action::AdminAction(AdminAction::CreateUserAdminAction)],
req.extensions.get::<Option<RemoteAddr>>().and_then(|opt| opt.map(|a| a.0)),
)
.await
.inspect_err(|_| {
emit_audit(
&audit,
AccountAuditRecord::failure(
AccountAuditOperation::ResetUserPassword,
&target,
caller.identity_type,
AccountAuditFailure::AccessDenied,
)
.with_session_access_key(Some(caller.access_key.as_str())),
);
})?;
let path = req.uri.path().to_string();
let body =
read_compatible_admin_body(req.input, MAX_ADMIN_REQUEST_BODY_SIZE, &path, &caller.credentials.secret_key).await?;
let request: SetUserSecretKeyRequest = serde_json::from_slice(&body)
.map_err(|e| s3::error(S3ErrorCode::InvalidRequest, format!("invalid set-user-secret-key request: {e}")))?;
if !is_secret_key_valid(&request.secret_key) {
return Err(s3::error(S3ErrorCode::InvalidArgument, "the new secret key is too short"));
}
let actor = caller.access_key.clone();
let identity_type = caller.identity_type;
let audit_for_task = audit.clone();
let sessions_revoked = supervise_admin_mutation("set user secret key", async move {
let iam_store =
current_ready_iam_handle().map_err(|_| s3::error(S3ErrorCode::InternalError, "iam is not initialized"))?;
iam_store
.set_user_secret_key(&target, &request.secret_key)
.await
.map_err(iam_error_to_s3_error)?;
let revoked = match iam_store.revoke_sts_sessions_for_parent(&target).await {
Ok(revoked) => revoked,
Err(err) => {
warn!(
component = LOG_COMPONENT_ADMIN,
subsystem = LOG_SUBSYSTEM_ACCOUNT,
event = EVENT_ADMIN_ACCOUNT_STATE,
action = "set_user_secret_key",
access_key = %MaskedAccessKey(&target),
result = "session_revocation_incomplete",
error = ?err,
"admin account state"
);
0
}
};
emit_audit(
&audit_for_task,
AccountAuditRecord::success(AccountAuditOperation::ResetUserPassword, &target, identity_type)
.with_session_access_key(Some(actor.as_str()))
.with_sessions_revoked(revoked),
);
info!(
component = LOG_COMPONENT_ADMIN,
subsystem = LOG_SUBSYSTEM_ACCOUNT,
event = EVENT_ADMIN_ACCOUNT_STATE,
action = "set_user_secret_key",
access_key = %MaskedAccessKey(&target),
actor_access_key = %MaskedAccessKey(&actor),
sessions_revoked = revoked,
result = "changed",
"admin account state"
);
Ok(revoked)
})
.await?;
admin_json_response(
&path,
&caller.credentials.secret_key,
StatusCode::OK,
&ChangePasswordResult {
sessions_revoked: sessions_revoked as u32,
},
)
}
}
/// Number of sessions the rotation invalidated, so a client can tell the user
/// they have been signed out elsewhere.
#[derive(Debug, serde::Serialize)]
struct ChangePasswordResult {
sessions_revoked: u32,
}
/// Reject a new secret that would be useless or a no-op.
fn validate_new_secret_key(request: &ChangePasswordRequest) -> S3Result<()> {
if !is_secret_key_valid(&request.new_secret_key) {
return Err(s3::error(S3ErrorCode::InvalidArgument, "the new secret key is too short"));
}
if constant_time_eq(&request.current_secret_key, &request.new_secret_key) {
return Err(s3::error(
S3ErrorCode::InvalidArgument,
"the new secret key must differ from the current one",
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::server::ADMIN_PREFIX;
fn change_request(current: &str, new: &str) -> ChangePasswordRequest {
ChangePasswordRequest {
current_secret_key: current.to_string(),
new_secret_key: new.to_string(),
}
}
#[test]
fn new_secret_key_must_meet_the_length_floor() {
// Same floor the IAM layer enforces, checked here so the caller gets a
// useful message instead of a generic IAM error.
let err = validate_new_secret_key(&change_request("old-secret-key", "short")).expect_err("must reject");
assert!(err.to_string().contains("too short"), "{err}");
}
#[test]
fn new_secret_key_must_differ_from_the_current_one() {
let err = validate_new_secret_key(&change_request("same-secret-key", "same-secret-key")).expect_err("must reject");
assert!(err.to_string().contains("must differ"), "{err}");
}
#[test]
fn a_valid_rotation_passes_validation() {
validate_new_secret_key(&change_request("old-secret-key", "new-secret-key")).expect("must accept");
}
#[test]
fn route_constants_stay_under_the_admin_prefix() {
// The constants spell the full path so registration has a single source
// of truth; this pins them to the prefix the router canonicalises on.
assert!(ACCOUNT_INFO_ROUTE.starts_with(ADMIN_PREFIX));
assert!(ACCOUNT_PASSWORD_ROUTE.starts_with(ADMIN_PREFIX));
}
#[test]
fn routes_are_registered_under_the_admin_prefix() {
let mut router: S3Router<AdminOperation> = S3Router::new(false);
register_account_route(&mut router).expect("register account routes");
assert!(router.contains_route(Method::GET, ACCOUNT_INFO_ROUTE));
assert!(router.contains_route(Method::POST, ACCOUNT_PASSWORD_ROUTE));
}
}
+426
View File
@@ -0,0 +1,426 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Audit adapter for the self-service account and MFA endpoints.
//!
//! Emits onto the same pipeline as the S3 and KMS entries, so account and
//! authentication activity lands in whatever SIEM a deployment already
//! operates. Modelled on [`super::kms_audit`], which established this shape.
//!
//! # Redaction
//!
//! Nothing carried here can reconstruct a credential. Secret keys, TOTP
//! secrets, provisioning URIs, submitted codes and recovery codes never enter
//! an entry — not even hashed, and not even on the failure paths where the
//! submitted value would be the most tempting thing to record. Failures are
//! described by the [`AccountAuditFailure`] vocabulary, which is a closed set
//! of static strings, so no caller-supplied bytes can reach a log target
//! through this module.
use crate::admin::storage_api::s3::{Body, S3Request};
use crate::server::RemoteAddr;
use crate::storage::access::request_context_from_extensions;
use crate::storage::helper::spawn_background_with_context;
use crate::storage::request_context::RequestContext;
use hashbrown::HashMap;
use rustfs_audit::entity::{ApiDetailsBuilder, AuditEntry, AuditEntryBuilder};
use rustfs_audit::global::AuditLogger;
use rustfs_madmin::account::IdentityType;
use rustfs_s3_types::EventName;
use rustfs_targets::get_request_user_agent;
use serde_json::Value;
/// Audit entry schema version, shared with the S3 and KMS paths so a consumer
/// parses these entries with the parser it already has.
const AUDIT_ENTRY_VERSION: &str = "1.0";
/// `trigger` value marking an entry as produced by the account/MFA API.
const AUDIT_TRIGGER: &str = "account-admin";
/// `type` value letting a consumer separate identity entries from S3 and KMS
/// ones without enumerating operation names.
const AUDIT_ENTRY_TYPE: &str = "iam-identity";
/// The operations audited by this module.
///
/// The [`EventName`] enum is deliberately coarse for IAM (two variants for the
/// whole surface, because `mask()` is nearly out of bits), so this is where the
/// per-operation detail lives. Consumers filter on `api.name` and the
/// `iamOperation` tag, both fed from here.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AccountAuditOperation {
/// The caller rotated its own secret key.
ChangeOwnPassword,
/// An administrator reset another identity's secret key.
ResetUserPassword,
/// A TOTP enrollment was started.
MfaEnroll,
/// A started enrollment was confirmed and the second factor became active.
MfaActivate,
/// The second factor was turned off.
MfaDisable,
/// Recovery codes were replaced.
MfaRecoveryCodesRegenerated,
/// An administrator cleared another identity's second factor.
AdminResetUserMfa,
/// A second factor was presented during session minting.
MfaVerify,
/// A login challenge was issued because the identity requires a second
/// factor.
MfaChallengeIssued,
}
impl AccountAuditOperation {
/// Stable operation name, recorded as `api.name`.
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::ChangeOwnPassword => "AccountChangePassword",
Self::ResetUserPassword => "AdminSetUserSecretKey",
Self::MfaEnroll => "AccountMfaEnroll",
Self::MfaActivate => "AccountMfaActivate",
Self::MfaDisable => "AccountMfaDisable",
Self::MfaRecoveryCodesRegenerated => "AccountMfaRecoveryCodes",
Self::AdminResetUserMfa => "AdminResetUserMfa",
Self::MfaVerify => "MfaVerify",
Self::MfaChallengeIssued => "MfaChallenge",
}
}
/// Which of the two IAM event classes this operation belongs to.
const fn event(self) -> EventName {
match self {
Self::MfaVerify | Self::MfaChallengeIssued => EventName::IamIdentityAuthChallenge,
_ => EventName::IamIdentityCredentialChanged,
}
}
}
/// Closed vocabulary of audited failure reasons.
///
/// A closed set rather than the error message: an error rendered from request
/// data would otherwise carry that data into the audit log, and an audit log is
/// a poor place to discover a leaked code or secret.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AccountAuditFailure {
/// The submitted current secret key did not match.
InvalidCurrentSecret,
/// The submitted TOTP or recovery code did not verify.
InvalidCode,
/// Too many failed attempts; the identity is temporarily locked.
RateLimited,
/// The submitted challenge was malformed, unsigned, or for another identity.
ChallengeInvalid,
/// The authorization gate rejected the request.
AccessDenied,
/// The request was well-formed but not allowed for this credential kind.
NotPermittedForCredential,
/// The new secret key failed validation.
InvalidNewSecret,
/// No enrollment exists to act on.
NotEnrolled,
/// At-rest protection for the TOTP secret is unavailable.
EnrollmentUnavailable,
/// The operation failed for an internal reason.
Internal,
}
impl AccountAuditFailure {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::InvalidCurrentSecret => "invalid_current_secret",
Self::InvalidCode => "invalid_code",
Self::RateLimited => "rate_limited",
Self::ChallengeInvalid => "challenge_invalid",
Self::AccessDenied => "access_denied",
Self::NotPermittedForCredential => "not_permitted_for_credential",
Self::InvalidNewSecret => "invalid_new_secret",
Self::NotEnrolled => "not_enrolled",
Self::EnrollmentUnavailable => "enrollment_unavailable",
Self::Internal => "internal_error",
}
}
}
/// Which second factor satisfied a verification.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MfaMethod {
Totp,
RecoveryCode,
}
impl MfaMethod {
const fn as_str(self) -> &'static str {
match self {
Self::Totp => "totp",
Self::RecoveryCode => "recovery-code",
}
}
}
/// Request-scoped context copied out of a request before it is consumed.
///
/// Handlers take the body by value, so the fields an entry needs are captured
/// up front rather than borrowed at emit time.
#[derive(Debug, Clone, Default)]
pub(crate) struct AccountAuditContext {
remote_host: Option<String>,
request_id: Option<String>,
user_agent: Option<String>,
req_path: Option<String>,
request_context: Option<RequestContext>,
}
impl AccountAuditContext {
pub(crate) fn from_request(req: &S3Request<Body>) -> Self {
let user_agent = get_request_user_agent(&req.headers);
let request_context = request_context_from_extensions(&req.extensions);
Self {
remote_host: req
.extensions
.get::<Option<RemoteAddr>>()
.and_then(|opt| opt.map(|addr| addr.0.ip().to_string())),
request_id: request_context.as_ref().map(|ctx| ctx.request_id.clone()),
user_agent: (!user_agent.is_empty()).then_some(user_agent),
req_path: Some(req.uri.path().to_string()),
request_context,
}
}
}
/// One audited account or MFA operation.
pub(crate) struct AccountAuditRecord<'a> {
pub(crate) operation: AccountAuditOperation,
/// The durable identity the operation acted on.
pub(crate) identity: &'a str,
pub(crate) identity_type: IdentityType,
/// The credential that signed the request, when it differs from `identity`.
pub(crate) session_access_key: Option<&'a str>,
pub(crate) failure: Option<AccountAuditFailure>,
pub(crate) mfa_method: Option<MfaMethod>,
/// Sessions invalidated as a side effect, when the operation revokes any.
pub(crate) sessions_revoked: Option<usize>,
/// Recovery codes left after the operation, when it changes the count.
pub(crate) recovery_codes_remaining: Option<u32>,
}
impl<'a> AccountAuditRecord<'a> {
/// A successful operation on `identity`.
pub(crate) fn success(operation: AccountAuditOperation, identity: &'a str, identity_type: IdentityType) -> Self {
Self {
operation,
identity,
identity_type,
session_access_key: None,
failure: None,
mfa_method: None,
sessions_revoked: None,
recovery_codes_remaining: None,
}
}
/// A rejected operation on `identity`.
pub(crate) fn failure(
operation: AccountAuditOperation,
identity: &'a str,
identity_type: IdentityType,
failure: AccountAuditFailure,
) -> Self {
Self {
failure: Some(failure),
..Self::success(operation, identity, identity_type)
}
}
pub(crate) const fn with_session_access_key(mut self, session_access_key: Option<&'a str>) -> Self {
self.session_access_key = session_access_key;
self
}
pub(crate) const fn with_mfa_method(mut self, method: MfaMethod) -> Self {
self.mfa_method = Some(method);
self
}
pub(crate) const fn with_sessions_revoked(mut self, revoked: usize) -> Self {
self.sessions_revoked = Some(revoked);
self
}
pub(crate) const fn with_recovery_codes_remaining(mut self, remaining: u32) -> Self {
self.recovery_codes_remaining = Some(remaining);
self
}
}
/// Emit one entry, best effort.
///
/// The operation has already completed when this is called and nothing here can
/// change its result, matching the pipeline's established semantics.
pub(crate) fn emit(context: &AccountAuditContext, record: AccountAuditRecord<'_>) {
let entry = build_entry(context, &record);
let request_context = context.request_context.clone();
spawn_background_with_context(request_context, async move {
AuditLogger::log(entry).await;
});
}
fn build_entry(context: &AccountAuditContext, record: &AccountAuditRecord<'_>) -> AuditEntry {
let status = if record.failure.is_some() { "failure" } else { "success" };
let api = ApiDetailsBuilder::new()
.name(record.operation.as_str())
.status(status)
.build();
let mut builder = AuditEntryBuilder::new(AUDIT_ENTRY_VERSION, record.operation.event(), AUDIT_TRIGGER, api)
.entry_type(AUDIT_ENTRY_TYPE)
.access_key(record.identity)
.tags(entry_tags(record));
// The durable identity goes in `access_key`; when a derived credential
// signed the request, `parent_user` records which one, so an investigator
// can tell "root changed its own password" from "an STS session did".
if let Some(session_access_key) = record.session_access_key {
builder = builder.parent_user(session_access_key);
}
if let Some(remote_host) = context.remote_host.as_deref() {
builder = builder.remote_host(remote_host);
}
if let Some(request_id) = context.request_id.as_deref() {
builder = builder.request_id(request_id);
}
if let Some(user_agent) = context.user_agent.as_deref() {
builder = builder.user_agent(user_agent);
}
if let Some(req_path) = context.req_path.as_deref() {
builder = builder.req_path(req_path);
}
if let Some(failure) = record.failure {
builder = builder.error(failure.as_str());
}
builder.build()
}
fn entry_tags(record: &AccountAuditRecord<'_>) -> HashMap<String, Value> {
let mut tags = HashMap::new();
tags.insert("iamOperation".to_string(), Value::String(record.operation.as_str().to_string()));
tags.insert("identityType".to_string(), Value::String(record.identity_type.as_str().to_string()));
if let Some(method) = record.mfa_method {
tags.insert("mfaMethod".to_string(), Value::String(method.as_str().to_string()));
}
if let Some(revoked) = record.sessions_revoked {
tags.insert("sessionsRevoked".to_string(), Value::Number(revoked.into()));
}
if let Some(remaining) = record.recovery_codes_remaining {
tags.insert("recoveryCodesRemaining".to_string(), Value::Number(remaining.into()));
}
tags
}
#[cfg(test)]
mod tests {
use super::*;
fn context() -> AccountAuditContext {
AccountAuditContext {
remote_host: Some("203.0.113.7".to_string()),
request_id: Some("req-1".to_string()),
user_agent: Some("rc/0.1".to_string()),
req_path: Some("/rustfs/admin/v3/account/password".to_string()),
request_context: None,
}
}
#[test]
fn successful_password_change_is_recorded_as_a_credential_change() {
let entry = build_entry(
&context(),
&AccountAuditRecord::success(AccountAuditOperation::ChangeOwnPassword, "sinan", IdentityType::Iam)
.with_session_access_key(Some("TEMPKEY"))
.with_sessions_revoked(3),
);
assert_eq!(entry.event, EventName::IamIdentityCredentialChanged);
assert_eq!(entry.api.name.as_deref(), Some("AccountChangePassword"));
assert_eq!(entry.api.status.as_deref(), Some("success"));
assert_eq!(entry.access_key.as_deref(), Some("sinan"));
assert_eq!(entry.parent_user.as_deref(), Some("TEMPKEY"));
assert!(entry.error.is_none());
let tags = entry.tags.expect("tags");
assert_eq!(tags.get("iamOperation"), Some(&Value::String("AccountChangePassword".into())));
assert_eq!(tags.get("identityType"), Some(&Value::String("iam".into())));
assert_eq!(tags.get("sessionsRevoked"), Some(&Value::Number(3.into())));
}
#[test]
fn mfa_verification_is_recorded_as_an_auth_challenge() {
let entry = build_entry(
&context(),
&AccountAuditRecord::success(AccountAuditOperation::MfaVerify, "sinan", IdentityType::Iam)
.with_mfa_method(MfaMethod::RecoveryCode)
.with_recovery_codes_remaining(9),
);
assert_eq!(entry.event, EventName::IamIdentityAuthChallenge);
let tags = entry.tags.expect("tags");
assert_eq!(tags.get("mfaMethod"), Some(&Value::String("recovery-code".into())));
assert_eq!(tags.get("recoveryCodesRemaining"), Some(&Value::Number(9.into())));
}
#[test]
fn failures_record_the_class_and_never_the_submitted_value() {
let entry = build_entry(
&context(),
&AccountAuditRecord::failure(
AccountAuditOperation::MfaVerify,
"sinan",
IdentityType::Iam,
AccountAuditFailure::InvalidCode,
),
);
assert_eq!(entry.api.status.as_deref(), Some("failure"));
assert_eq!(entry.error.as_deref(), Some("invalid_code"));
// The whole serialized entry must not contain anything code-shaped: the
// point of the closed failure vocabulary is that no submitted value can
// reach a log target through here.
let encoded = serde_json::to_string(&entry).expect("serialize");
assert!(!encoded.contains("123456"), "audit entry must never echo a submitted code");
}
#[test]
fn every_operation_maps_to_an_iam_event() {
for operation in [
AccountAuditOperation::ChangeOwnPassword,
AccountAuditOperation::ResetUserPassword,
AccountAuditOperation::MfaEnroll,
AccountAuditOperation::MfaActivate,
AccountAuditOperation::MfaDisable,
AccountAuditOperation::MfaRecoveryCodesRegenerated,
AccountAuditOperation::AdminResetUserMfa,
AccountAuditOperation::MfaVerify,
AccountAuditOperation::MfaChallengeIssued,
] {
let event = operation.event();
assert!(event.is_iam(), "{} must map to an IAM event, got {event}", operation.as_str());
assert!(!operation.as_str().is_empty());
}
}
}
+3 -10
View File
@@ -46,10 +46,10 @@ use crate::admin::handlers::service_account::AddServiceAccount;
use crate::admin::handlers::user::ImportIam;
use crate::admin::router::{AdminOperation, Operation, S3Router};
use crate::admin::runtime_sources::{current_app_context, current_ready_iam_handle, current_server_config_for_context};
use crate::admin::utils::{encode_compatible_admin_payload, is_compat_admin_request};
use crate::admin::utils::is_compat_admin_request;
use crate::auth::{check_key_valid, get_session_token};
use crate::server::{ADMIN_PREFIX, RemoteAddr};
use http::{HeaderMap, StatusCode};
use http::StatusCode;
use hyper::Method;
use matchit::Params;
use rustfs_config::DEFAULT_DELIMITER;
@@ -60,7 +60,6 @@ use rustfs_madmin::{
};
use rustfs_policy::policy::action::{Action, AdminAction};
use rustfs_utils::MaskedAccessKey;
use s3s::header::CONTENT_TYPE;
use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error};
use serde::Serialize;
use std::{collections::HashMap, sync::LazyLock};
@@ -991,13 +990,7 @@ fn json_response<T: Serialize>(
status: StatusCode,
payload: &T,
) -> S3Result<S3Response<(StatusCode, Body)>> {
let body = serde_json::to_vec(payload)
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize error: {e}")))?;
let (body, content_type) = encode_compatible_admin_payload(path, secret_key, body)?;
let mut header = HeaderMap::new();
header.insert(CONTENT_TYPE, content_type.parse().expect("valid header value"));
Ok(S3Response::with_headers((status, Body::from(body)), header))
super::admin_json_response(path, secret_key, status, payload)
}
#[cfg(test)]
+747
View File
@@ -0,0 +1,747 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Two-factor authentication endpoints.
//!
//! Self-service, acting on the caller:
//!
//! * `GET /rustfs/admin/v3/account/mfa` — current state
//! * `POST /rustfs/admin/v3/account/mfa/enroll` — start enrollment
//! * `POST /rustfs/admin/v3/account/mfa/activate` — confirm enrollment
//! * `POST /rustfs/admin/v3/account/mfa/disable` — turn it off
//! * `POST /rustfs/admin/v3/account/mfa/recovery-codes` — replace the codes
//!
//! Login:
//!
//! * `GET /rustfs/admin/v3/mfa/challenge` — is a factor needed?
//!
//! Administrative, acting on another identity:
//!
//! * `GET /rustfs/admin/v3/user/mfa?accessKey=…` — inspect
//! * `DELETE /rustfs/admin/v3/user/mfa?accessKey=…` — break-glass reset
//!
//! Every handler here is HTTP plumbing: authorization, deserialization,
//! serialization and audit. The state machine lives in
//! [`rustfs_iam::mfa`], so the console and the CLI exercise identical logic.
//!
//! `disable` is a `POST` rather than a `DELETE` because it carries a body — the
//! second factor *and* the account password. A `DELETE` with a signed body is
//! legal but awkward for enough HTTP clients that it is not worth the purity.
use super::account_audit::{
AccountAuditContext, AccountAuditFailure, AccountAuditOperation, AccountAuditRecord, MfaMethod, emit as emit_audit,
};
use super::admin_json_response;
use crate::admin::auth::validate_admin_request;
use crate::admin::router::{AdminOperation, Operation, S3Router};
use crate::admin::runtime_sources::{current_token_signing_key, object_store_from_req};
use crate::admin::service::caller_identity::CallerIdentity;
use crate::admin::storage_api::runtime::ECStore;
use crate::admin::storage_api::s3::{self, Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result};
use crate::admin::utils::read_compatible_admin_body;
use crate::auth::constant_time_eq;
use crate::server::RemoteAddr;
use http::StatusCode;
use hyper::Method;
use matchit::Params;
use rustfs_config::MAX_ADMIN_REQUEST_BODY_SIZE;
use rustfs_iam::mfa::{MfaServiceError, MfaVerification, service as mfa_service};
use rustfs_madmin::account::{MfaChallengeResponse, MfaCodeRequest, MfaDisableRequest, UserMfaStatus};
use rustfs_policy::policy::action::{Action, AdminAction};
use rustfs_utils::MaskedAccessKey;
use serde::Deserialize;
use std::sync::Arc;
use time::OffsetDateTime;
use tracing::{info, warn};
const LOG_COMPONENT_ADMIN: &str = "admin";
const LOG_SUBSYSTEM_MFA: &str = "mfa";
const EVENT_ADMIN_MFA_STATE: &str = "admin_mfa_state";
pub(crate) const ACCOUNT_MFA_ROUTE: &str = "/rustfs/admin/v3/account/mfa";
pub(crate) const ACCOUNT_MFA_ENROLL_ROUTE: &str = "/rustfs/admin/v3/account/mfa/enroll";
pub(crate) const ACCOUNT_MFA_ACTIVATE_ROUTE: &str = "/rustfs/admin/v3/account/mfa/activate";
pub(crate) const ACCOUNT_MFA_DISABLE_ROUTE: &str = "/rustfs/admin/v3/account/mfa/disable";
pub(crate) const ACCOUNT_MFA_RECOVERY_CODES_ROUTE: &str = "/rustfs/admin/v3/account/mfa/recovery-codes";
pub(crate) const MFA_CHALLENGE_ROUTE: &str = "/rustfs/admin/v3/mfa/challenge";
pub(crate) const USER_MFA_ROUTE: &str = "/rustfs/admin/v3/user/mfa";
pub fn register_mfa_route(r: &mut S3Router<AdminOperation>) -> std::io::Result<()> {
r.insert(Method::GET, ACCOUNT_MFA_ROUTE, AdminOperation(&AccountMfaStatusHandler {}))?;
r.insert(Method::POST, ACCOUNT_MFA_ENROLL_ROUTE, AdminOperation(&AccountMfaEnrollHandler {}))?;
r.insert(Method::POST, ACCOUNT_MFA_ACTIVATE_ROUTE, AdminOperation(&AccountMfaActivateHandler {}))?;
r.insert(Method::POST, ACCOUNT_MFA_DISABLE_ROUTE, AdminOperation(&AccountMfaDisableHandler {}))?;
r.insert(
Method::POST,
ACCOUNT_MFA_RECOVERY_CODES_ROUTE,
AdminOperation(&AccountMfaRecoveryCodesHandler {}),
)?;
r.insert(Method::GET, MFA_CHALLENGE_ROUTE, AdminOperation(&MfaChallengeHandler {}))?;
r.insert(Method::GET, USER_MFA_ROUTE, AdminOperation(&UserMfaStatusHandler {}))?;
r.insert(Method::DELETE, USER_MFA_ROUTE, AdminOperation(&UserMfaResetHandler {}))?;
Ok(())
}
/// Map a service failure onto the wire.
///
/// `InvalidCode` becomes `AccessDenied` with a fixed message: the service has
/// already collapsed wrong, replayed and malformed codes into one variant, and
/// the message must not reintroduce the distinction.
fn map_service_error(error: MfaServiceError) -> S3Error {
match error {
MfaServiceError::EnrollmentUnavailable(reason) => S3Error::with_message(S3ErrorCode::NotImplemented, reason.to_string()),
MfaServiceError::NotEnabled => {
S3Error::with_message(S3ErrorCode::InvalidRequest, "two-factor authentication is not enabled".to_string())
}
MfaServiceError::NoPendingEnrollment => S3Error::with_message(
S3ErrorCode::InvalidRequest,
"there is no pending enrollment to confirm; start setup again".to_string(),
),
MfaServiceError::AlreadyEnabled => {
S3Error::with_message(S3ErrorCode::InvalidRequest, "two-factor authentication is already enabled".to_string())
}
MfaServiceError::InvalidCode => {
S3Error::with_message(S3ErrorCode::AccessDenied, "the verification code is invalid".to_string())
}
// `SlowDown` is the S3 vocabulary's closest analogue to 429, and clients
// already treat it as "back off" rather than "retry immediately".
MfaServiceError::Locked { retry_after_seconds } => S3Error::with_message(
S3ErrorCode::SlowDown,
format!("too many failed attempts; try again in {retry_after_seconds} seconds"),
),
MfaServiceError::InvalidChallenge => {
S3Error::with_message(S3ErrorCode::AccessDenied, "the login challenge is invalid or has expired".to_string())
}
MfaServiceError::Internal(message) => S3Error::with_message(S3ErrorCode::InternalError, message),
}
}
/// Audit class for a service failure, so every handler classifies the same way.
fn audit_failure_for(error: &MfaServiceError) -> AccountAuditFailure {
match error {
MfaServiceError::EnrollmentUnavailable(_) => AccountAuditFailure::EnrollmentUnavailable,
MfaServiceError::NotEnabled | MfaServiceError::NoPendingEnrollment => AccountAuditFailure::NotEnrolled,
MfaServiceError::AlreadyEnabled => AccountAuditFailure::NotPermittedForCredential,
MfaServiceError::InvalidCode => AccountAuditFailure::InvalidCode,
MfaServiceError::Locked { .. } => AccountAuditFailure::RateLimited,
MfaServiceError::InvalidChallenge => AccountAuditFailure::ChallengeInvalid,
MfaServiceError::Internal(_) => AccountAuditFailure::Internal,
}
}
fn store_from_req(req: &S3Request<Body>) -> S3Result<Arc<ECStore>> {
object_store_from_req(req).ok_or_else(|| s3::error(S3ErrorCode::ServiceUnavailable, "the object store is not ready"))
}
/// Resolve the caller and confirm this credential kind may manage a second
/// factor for its identity.
async fn resolve_self_service_caller(req: &S3Request<Body>) -> S3Result<CallerIdentity> {
let caller = CallerIdentity::resolve(req).await?;
// The MFA capability, not the password one: a root identity may enroll a
// second factor even though its secret key is fixed for the life of the
// process.
caller.ensure_mfa_management_allowed()?;
Ok(caller)
}
/// `GET /rustfs/admin/v3/account/mfa`
pub struct AccountMfaStatusHandler {}
#[async_trait::async_trait]
impl Operation for AccountMfaStatusHandler {
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
// Reading state is allowed for every credential kind: a service account
// may need to know whether its parent is protected, even though it may
// not change that.
let caller = CallerIdentity::resolve(&req).await?;
let store = store_from_req(&req)?;
let status = mfa_service::status(store, &caller.access_key, OffsetDateTime::now_utc())
.await
.map_err(map_service_error)?;
admin_json_response(req.uri.path(), &caller.credentials.secret_key, StatusCode::OK, &status)
}
}
/// `POST /rustfs/admin/v3/account/mfa/enroll`
pub struct AccountMfaEnrollHandler {}
#[async_trait::async_trait]
impl Operation for AccountMfaEnrollHandler {
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
let caller = resolve_self_service_caller(&req).await?;
let audit = AccountAuditContext::from_request(&req);
let store = store_from_req(&req)?;
let response = match mfa_service::enroll(store, &caller.access_key, OffsetDateTime::now_utc()).await {
Ok(response) => response,
Err(err) => {
emit_audit(
&audit,
AccountAuditRecord::failure(
AccountAuditOperation::MfaEnroll,
&caller.access_key,
caller.identity_type,
audit_failure_for(&err),
)
.with_session_access_key(caller.session_access_key.as_deref()),
);
return Err(map_service_error(err));
}
};
emit_audit(
&audit,
AccountAuditRecord::success(AccountAuditOperation::MfaEnroll, &caller.access_key, caller.identity_type)
.with_session_access_key(caller.session_access_key.as_deref()),
);
info!(
component = LOG_COMPONENT_ADMIN,
subsystem = LOG_SUBSYSTEM_MFA,
event = EVENT_ADMIN_MFA_STATE,
action = "enroll",
access_key = %MaskedAccessKey(&caller.access_key),
result = "pending",
"admin mfa state"
);
admin_json_response(req.uri.path(), &caller.credentials.secret_key, StatusCode::OK, &response)
}
}
/// `POST /rustfs/admin/v3/account/mfa/activate`
pub struct AccountMfaActivateHandler {}
#[async_trait::async_trait]
impl Operation for AccountMfaActivateHandler {
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
let caller = resolve_self_service_caller(&req).await?;
let audit = AccountAuditContext::from_request(&req);
let store = store_from_req(&req)?;
let path = req.uri.path().to_string();
let body =
read_compatible_admin_body(req.input, MAX_ADMIN_REQUEST_BODY_SIZE, &path, &caller.credentials.secret_key).await?;
let request: MfaCodeRequest = serde_json::from_slice(&body)
.map_err(|e| s3::error(S3ErrorCode::InvalidRequest, format!("invalid activation request: {e}")))?;
let response = match mfa_service::activate(store, &caller.access_key, &request.code, OffsetDateTime::now_utc()).await {
Ok(response) => response,
Err(err) => {
warn!(
component = LOG_COMPONENT_ADMIN,
subsystem = LOG_SUBSYSTEM_MFA,
event = EVENT_ADMIN_MFA_STATE,
action = "activate",
access_key = %MaskedAccessKey(&caller.access_key),
result = %err.audit_class(),
"admin mfa state"
);
emit_audit(
&audit,
AccountAuditRecord::failure(
AccountAuditOperation::MfaActivate,
&caller.access_key,
caller.identity_type,
audit_failure_for(&err),
)
.with_session_access_key(caller.session_access_key.as_deref()),
);
return Err(map_service_error(err));
}
};
emit_audit(
&audit,
AccountAuditRecord::success(AccountAuditOperation::MfaActivate, &caller.access_key, caller.identity_type)
.with_session_access_key(caller.session_access_key.as_deref())
.with_recovery_codes_remaining(response.recovery_codes.len() as u32),
);
admin_json_response(&path, &caller.credentials.secret_key, StatusCode::OK, &response)
}
}
/// `POST /rustfs/admin/v3/account/mfa/disable`
pub struct AccountMfaDisableHandler {}
#[async_trait::async_trait]
impl Operation for AccountMfaDisableHandler {
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
let caller = resolve_self_service_caller(&req).await?;
let audit = AccountAuditContext::from_request(&req);
let store = store_from_req(&req)?;
let path = req.uri.path().to_string();
let body =
read_compatible_admin_body(req.input, MAX_ADMIN_REQUEST_BODY_SIZE, &path, &caller.credentials.secret_key).await?;
let request: MfaDisableRequest = serde_json::from_slice(&body)
.map_err(|e| s3::error(S3ErrorCode::InvalidRequest, format!("invalid disable request: {e}")))?;
// Step-up: the second factor alone is not enough to remove the second
// factor. The console signs with a short-lived STS session, so a
// hijacked tab would otherwise be able to strip the protection using
// only a code shoulder-surfed once.
let iam_store = crate::admin::runtime_sources::current_ready_iam_handle()
.map_err(|_| s3::error(S3ErrorCode::InternalError, "iam is not initialized"))?;
let Some(stored) = iam_store.get_user(&caller.access_key).await else {
return Err(s3::error(S3ErrorCode::InvalidRequest, "the calling identity no longer exists"));
};
if !constant_time_eq(&request.current_secret_key, &stored.credentials.secret_key) {
emit_audit(
&audit,
AccountAuditRecord::failure(
AccountAuditOperation::MfaDisable,
&caller.access_key,
caller.identity_type,
AccountAuditFailure::InvalidCurrentSecret,
)
.with_session_access_key(caller.session_access_key.as_deref()),
);
return Err(s3::error(S3ErrorCode::AccessDenied, "the current secret key is incorrect"));
}
if let Err(err) = mfa_service::disable(store, &caller.access_key, &request.code, OffsetDateTime::now_utc()).await {
emit_audit(
&audit,
AccountAuditRecord::failure(
AccountAuditOperation::MfaDisable,
&caller.access_key,
caller.identity_type,
audit_failure_for(&err),
)
.with_session_access_key(caller.session_access_key.as_deref()),
);
return Err(map_service_error(err));
}
emit_audit(
&audit,
AccountAuditRecord::success(AccountAuditOperation::MfaDisable, &caller.access_key, caller.identity_type)
.with_session_access_key(caller.session_access_key.as_deref()),
);
info!(
component = LOG_COMPONENT_ADMIN,
subsystem = LOG_SUBSYSTEM_MFA,
event = EVENT_ADMIN_MFA_STATE,
action = "disable",
access_key = %MaskedAccessKey(&caller.access_key),
result = "disabled",
"admin mfa state"
);
Ok(empty_ok())
}
}
/// `POST /rustfs/admin/v3/account/mfa/recovery-codes`
pub struct AccountMfaRecoveryCodesHandler {}
#[async_trait::async_trait]
impl Operation for AccountMfaRecoveryCodesHandler {
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
let caller = resolve_self_service_caller(&req).await?;
let audit = AccountAuditContext::from_request(&req);
let store = store_from_req(&req)?;
let path = req.uri.path().to_string();
let body =
read_compatible_admin_body(req.input, MAX_ADMIN_REQUEST_BODY_SIZE, &path, &caller.credentials.secret_key).await?;
let request: MfaCodeRequest = serde_json::from_slice(&body)
.map_err(|e| s3::error(S3ErrorCode::InvalidRequest, format!("invalid recovery-code request: {e}")))?;
let response =
match mfa_service::regenerate_recovery_codes(store, &caller.access_key, &request.code, OffsetDateTime::now_utc())
.await
{
Ok(response) => response,
Err(err) => {
emit_audit(
&audit,
AccountAuditRecord::failure(
AccountAuditOperation::MfaRecoveryCodesRegenerated,
&caller.access_key,
caller.identity_type,
audit_failure_for(&err),
)
.with_session_access_key(caller.session_access_key.as_deref()),
);
return Err(map_service_error(err));
}
};
emit_audit(
&audit,
AccountAuditRecord::success(
AccountAuditOperation::MfaRecoveryCodesRegenerated,
&caller.access_key,
caller.identity_type,
)
.with_session_access_key(caller.session_access_key.as_deref())
.with_recovery_codes_remaining(response.recovery_codes.len() as u32),
);
admin_json_response(&path, &caller.credentials.secret_key, StatusCode::OK, &response)
}
}
/// `GET /rustfs/admin/v3/mfa/challenge`
///
/// Answers "does this identity need a second factor?" for a caller that has
/// already proved it holds the identity's secret key, since the request is
/// signed. That signature requirement is what keeps this from being an
/// enumeration oracle: a caller only ever learns about the identity whose
/// credentials it already has.
pub struct MfaChallengeHandler {}
#[async_trait::async_trait]
impl Operation for MfaChallengeHandler {
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
let caller = CallerIdentity::resolve(&req).await?;
let audit = AccountAuditContext::from_request(&req);
let store = store_from_req(&req)?;
let now = OffsetDateTime::now_utc();
let required = mfa_service::is_enabled(store, &caller.access_key, now)
.await
.map_err(map_service_error)?;
let response = if required {
let Some(signing_key) = current_token_signing_key() else {
return Err(s3::error(S3ErrorCode::InternalError, "the session signing key is not initialized"));
};
let challenge = mfa_service::issue_challenge(&caller.access_key, now, signing_key.as_bytes());
emit_audit(
&audit,
AccountAuditRecord::success(AccountAuditOperation::MfaChallengeIssued, &caller.access_key, caller.identity_type)
.with_session_access_key(caller.session_access_key.as_deref()),
);
MfaChallengeResponse {
required: true,
challenge: Some(challenge),
expires_at: Some(now + time::Duration::seconds(mfa_service::challenge_ttl_seconds() as i64)),
}
} else {
MfaChallengeResponse {
required: false,
challenge: None,
expires_at: None,
}
};
admin_json_response(req.uri.path(), &caller.credentials.secret_key, StatusCode::OK, &response)
}
}
#[derive(Debug, Deserialize, Default)]
struct UserMfaQuery {
#[serde(rename = "accessKey", alias = "access-key")]
access_key: Option<String>,
}
fn parse_user_mfa_query(req: &S3Request<Body>) -> S3Result<String> {
let query: UserMfaQuery = match req.uri.query() {
Some(query) => {
serde_urlencoded::from_str(query).map_err(|_| s3::error(S3ErrorCode::InvalidArgument, "failed to decode query"))?
}
None => UserMfaQuery::default(),
};
let access_key = query.access_key.unwrap_or_default();
if access_key.is_empty() {
return Err(s3::error(S3ErrorCode::InvalidArgument, "access key is empty"));
}
Ok(access_key)
}
/// `GET /rustfs/admin/v3/user/mfa?accessKey=…`
pub struct UserMfaStatusHandler {}
#[async_trait::async_trait]
impl Operation for UserMfaStatusHandler {
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
let target = parse_user_mfa_query(&req)?;
let caller = CallerIdentity::resolve(&req).await?;
validate_admin_request(
&req.headers,
&caller.credentials,
caller.is_owner,
false,
vec![Action::AdminAction(AdminAction::GetUserAdminAction)],
req.extensions.get::<Option<RemoteAddr>>().and_then(|opt| opt.map(|a| a.0)),
)
.await?;
let store = store_from_req(&req)?;
let status: UserMfaStatus = mfa_service::admin_status(store, &target, OffsetDateTime::now_utc())
.await
.map_err(map_service_error)?;
admin_json_response(req.uri.path(), &caller.credentials.secret_key, StatusCode::OK, &status)
}
}
/// `DELETE /rustfs/admin/v3/user/mfa?accessKey=…`
///
/// The break-glass path: an administrator clears the second factor for a user
/// who lost both their authenticator and their recovery codes.
///
/// Gated on `EnableUser`, not on a bespoke action, because the capability being
/// exercised is the same one that can already re-enable a disabled account —
/// anyone who can do that can already take over the identity, so a separate
/// action would be a distinction without a security difference.
pub struct UserMfaResetHandler {}
#[async_trait::async_trait]
impl Operation for UserMfaResetHandler {
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
let target = parse_user_mfa_query(&req)?;
let caller = CallerIdentity::resolve(&req).await?;
let audit = AccountAuditContext::from_request(&req);
validate_admin_request(
&req.headers,
&caller.credentials,
caller.is_owner,
false,
vec![Action::AdminAction(AdminAction::EnableUserAdminAction)],
req.extensions.get::<Option<RemoteAddr>>().and_then(|opt| opt.map(|a| a.0)),
)
.await
.inspect_err(|_| {
emit_audit(
&audit,
AccountAuditRecord::failure(
AccountAuditOperation::AdminResetUserMfa,
&target,
caller.identity_type,
AccountAuditFailure::AccessDenied,
)
.with_session_access_key(Some(caller.access_key.as_str())),
);
})?;
let store = store_from_req(&req)?;
mfa_service::admin_reset(store, &target).await.map_err(map_service_error)?;
emit_audit(
&audit,
AccountAuditRecord::success(AccountAuditOperation::AdminResetUserMfa, &target, caller.identity_type)
// The acting administrator, recorded so a reset is always
// attributable to a person and not just to the target.
.with_session_access_key(Some(caller.access_key.as_str())),
);
warn!(
component = LOG_COMPONENT_ADMIN,
subsystem = LOG_SUBSYSTEM_MFA,
event = EVENT_ADMIN_MFA_STATE,
action = "admin_reset",
target_access_key = %MaskedAccessKey(&target),
actor_access_key = %MaskedAccessKey(&caller.access_key),
result = "reset",
"admin mfa state"
);
Ok(empty_ok())
}
}
/// Verify a second factor on behalf of the session-minting path.
///
/// Lives here so the STS handler does not have to know the MFA service, the
/// audit vocabulary, or how a challenge is validated.
pub(crate) async fn verify_for_session(
store: Arc<ECStore>,
audit: &AccountAuditContext,
access_key: &str,
identity_type: rustfs_madmin::account::IdentityType,
challenge: Option<&str>,
code: &str,
) -> S3Result<MfaVerification> {
let now = OffsetDateTime::now_utc();
// The challenge is validated first: it is cheap, and a stale one should not
// consume an attempt against the rate limiter.
if let Some(challenge) = challenge.filter(|value| !value.is_empty()) {
let Some(signing_key) = current_token_signing_key() else {
return Err(s3::error(S3ErrorCode::InternalError, "the session signing key is not initialized"));
};
if let Err(err) = mfa_service::validate_challenge(challenge, access_key, now, signing_key.as_bytes()) {
emit_audit(
audit,
AccountAuditRecord::failure(
AccountAuditOperation::MfaVerify,
access_key,
identity_type,
AccountAuditFailure::ChallengeInvalid,
),
);
return Err(map_service_error(err));
}
}
match mfa_service::verify(store, access_key, code, now).await {
Ok(verification) => {
let method = match verification {
MfaVerification::Totp => MfaMethod::Totp,
MfaVerification::RecoveryCode { .. } => MfaMethod::RecoveryCode,
};
let mut record =
AccountAuditRecord::success(AccountAuditOperation::MfaVerify, access_key, identity_type).with_mfa_method(method);
if let MfaVerification::RecoveryCode { remaining } = verification {
record = record.with_recovery_codes_remaining(remaining);
warn!(
component = LOG_COMPONENT_ADMIN,
subsystem = LOG_SUBSYSTEM_MFA,
event = EVENT_ADMIN_MFA_STATE,
action = "verify",
access_key = %MaskedAccessKey(access_key),
recovery_codes_remaining = remaining,
result = "recovery_code_used",
"admin mfa state"
);
}
emit_audit(audit, record);
Ok(verification)
}
Err(err) => {
emit_audit(
audit,
AccountAuditRecord::failure(AccountAuditOperation::MfaVerify, access_key, identity_type, audit_failure_for(&err)),
);
Err(map_service_error(err))
}
}
}
fn empty_ok() -> S3Response<(StatusCode, Body)> {
let mut header = hyper::HeaderMap::new();
header.insert(s3::header::CONTENT_LENGTH, "0".parse().expect("valid header value"));
S3Response::with_headers((StatusCode::OK, Body::empty()), header)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::server::ADMIN_PREFIX;
#[test]
fn routes_are_registered() {
let mut router: S3Router<AdminOperation> = S3Router::new(false);
register_mfa_route(&mut router).expect("register mfa routes");
assert!(router.contains_route(Method::GET, ACCOUNT_MFA_ROUTE));
assert!(router.contains_route(Method::POST, ACCOUNT_MFA_ENROLL_ROUTE));
assert!(router.contains_route(Method::POST, ACCOUNT_MFA_ACTIVATE_ROUTE));
assert!(router.contains_route(Method::POST, ACCOUNT_MFA_DISABLE_ROUTE));
assert!(router.contains_route(Method::POST, ACCOUNT_MFA_RECOVERY_CODES_ROUTE));
assert!(router.contains_route(Method::GET, MFA_CHALLENGE_ROUTE));
assert!(router.contains_route(Method::GET, USER_MFA_ROUTE));
assert!(router.contains_route(Method::DELETE, USER_MFA_ROUTE));
}
#[test]
fn route_constants_stay_under_the_admin_prefix() {
for route in [
ACCOUNT_MFA_ROUTE,
ACCOUNT_MFA_ENROLL_ROUTE,
ACCOUNT_MFA_ACTIVATE_ROUTE,
ACCOUNT_MFA_DISABLE_ROUTE,
ACCOUNT_MFA_RECOVERY_CODES_ROUTE,
MFA_CHALLENGE_ROUTE,
USER_MFA_ROUTE,
] {
assert!(route.starts_with(ADMIN_PREFIX), "{route} is outside the admin prefix");
}
}
#[test]
fn wrong_and_replayed_codes_produce_the_same_response() {
// The service already collapses them; this pins that the HTTP layer does
// not reintroduce the distinction through its message or status.
let first = map_service_error(MfaServiceError::InvalidCode);
let second = map_service_error(MfaServiceError::InvalidCode);
assert_eq!(first.code(), &S3ErrorCode::AccessDenied);
assert_eq!(first.to_string(), second.to_string());
assert!(!first.to_string().contains("replay"));
}
#[test]
fn a_lockout_is_reported_as_backpressure_with_its_retry_hint() {
let error = map_service_error(MfaServiceError::Locked {
retry_after_seconds: 900,
});
assert_eq!(error.code(), &S3ErrorCode::SlowDown);
assert!(error.to_string().contains("900"), "{error}");
}
#[test]
fn an_unavailable_enrollment_reports_the_remedy() {
let error = map_service_error(MfaServiceError::EnrollmentUnavailable(
rustfs_iam::mfa::store::ENROLLMENT_UNAVAILABLE_REASON,
));
assert_eq!(error.code(), &S3ErrorCode::NotImplemented);
assert!(error.to_string().contains("RUSTFS_IAM_MASTER_KEY"), "{error}");
}
#[test]
fn service_failures_all_have_an_audit_class() {
for error in [
MfaServiceError::NotEnabled,
MfaServiceError::NoPendingEnrollment,
MfaServiceError::InvalidCode,
MfaServiceError::Locked { retry_after_seconds: 1 },
MfaServiceError::InvalidChallenge,
MfaServiceError::Internal("x".to_string()),
MfaServiceError::EnrollmentUnavailable("x"),
] {
// Every variant must map, so a new one cannot silently audit as a
// wrong code.
let class = audit_failure_for(&error);
assert!(!class.as_str().is_empty());
}
}
#[test]
fn the_target_access_key_is_required_for_the_administrative_routes() {
let request = |uri: &str| S3Request {
input: Body::empty(),
method: Method::GET,
uri: uri.parse().expect("uri should parse"),
headers: hyper::HeaderMap::new(),
extensions: http::Extensions::new(),
credentials: None,
region: None,
service: None,
trailing_headers: None,
};
// No query at all, and an explicitly empty value, must both be refused
// rather than resolving to some default identity.
assert!(parse_user_mfa_query(&request("http://localhost/rustfs/admin/v3/user/mfa")).is_err());
assert!(parse_user_mfa_query(&request("http://localhost/rustfs/admin/v3/user/mfa?accessKey=")).is_err());
assert_eq!(
parse_user_mfa_query(&request("http://localhost/rustfs/admin/v3/user/mfa?accessKey=sinan")).expect("parse"),
"sinan"
);
}
}
+24
View File
@@ -12,6 +12,8 @@
// See the License for the specific language governing permissions and
// limitations under the License.
pub mod account;
pub(crate) mod account_audit;
pub mod account_info;
pub mod audit;
mod audit_runtime_config;
@@ -40,6 +42,7 @@ pub mod kms_key_metadata;
pub mod kms_keys;
pub mod kms_management;
pub mod metrics;
pub mod mfa;
pub mod module_switch;
mod notify_runtime_access;
pub mod object_data_cache;
@@ -70,6 +73,27 @@ pub mod user_iam;
pub mod user_lifecycle;
pub mod user_policy_binding;
/// Serialize `payload` as the body of an admin JSON response.
///
/// Routes reached through the `/minio/admin` compat prefix carry an encrypted
/// body; `encode_compatible_admin_payload` decides that from the path, so every
/// handler must go through here rather than serializing directly, or a
/// MinIO client gets plaintext where it expects ciphertext.
pub(crate) fn admin_json_response<T: serde::Serialize>(
path: &str,
secret_key: &str,
status: http::StatusCode,
payload: &T,
) -> s3s::S3Result<s3s::S3Response<(http::StatusCode, s3s::Body)>> {
let body = serde_json::to_vec(payload)
.map_err(|e| s3s::S3Error::with_message(s3s::S3ErrorCode::InternalError, format!("serialize error: {e}")))?;
let (body, content_type) = crate::admin::utils::encode_compatible_admin_payload(path, secret_key, body)?;
let mut header = hyper::HeaderMap::new();
header.insert(s3s::header::CONTENT_TYPE, content_type.parse().expect("valid header value"));
Ok(s3s::S3Response::with_headers((status, s3s::Body::from(body)), header))
}
pub(crate) async fn supervise_admin_mutation<T>(
operation: &'static str,
mutation: impl std::future::Future<Output = s3s::S3Result<T>> + Send + 'static,
+100 -1
View File
@@ -13,6 +13,9 @@
// limitations under the License.
use super::is_admin::IsAdminHandler;
use crate::admin::handlers::account_audit::AccountAuditContext;
use crate::admin::handlers::mfa::verify_for_session as mfa_verify_for_session;
use crate::admin::runtime_sources::object_store_from_req;
use crate::admin::service::federated_identity::DefaultFederatedSessionBinding;
use crate::admin::service::session_policy::populate_session_policy;
use crate::admin::storage_api::bucket::utils::serialize;
@@ -32,6 +35,8 @@ use hyper::Method;
use matchit::Params;
use rustfs_config::MAX_ADMIN_REQUEST_BODY_SIZE;
use rustfs_iam::federation::{FederatedSessionBindingError, FederationError};
use rustfs_iam::mfa::service as mfa_service;
use rustfs_madmin::account::{ERR_MFA_REQUIRED, IdentityType};
use rustfs_madmin::{SITE_REPL_API_VERSION, SR_IAM_ITEM_STS_ACC, SRIAMItem, SRSTSCredential};
use rustfs_policy::{
auth::get_new_credentials_with_metadata,
@@ -40,6 +45,7 @@ use rustfs_policy::{
action::{Action, StsAction},
},
};
use rustfs_utils::MaskedAccessKey;
use s3s::{
Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result,
dto::{AssumeRoleOutput, Credentials, Timestamp},
@@ -144,6 +150,15 @@ pub struct AssumeRoleRequest {
pub policy: String,
pub external_id: String,
pub web_identity_token: String,
/// The login challenge from `GET /v3/mfa/challenge`, echoed back.
///
/// AWS uses `SerialNumber` to name an MFA device; RustFS has one virtual
/// device per identity, so the field carries the challenge instead. It is
/// optional: a client that skips the challenge round trip and sends only a
/// `TokenCode` still authenticates.
pub serial_number: String,
/// A six-digit TOTP code or a recovery code.
pub token_code: String,
}
pub struct AssumeRoleHandle {}
@@ -152,6 +167,11 @@ impl Operation for AssumeRoleHandle {
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
debug!("handle AssumeRoleHandle");
// Captured before the body is consumed: the second-factor gate needs the
// object store, and its audit entries need the request metadata.
let store = object_store_from_req(&req);
let audit = AccountAuditContext::from_request(&req);
let mut input = req.input;
let bytes = match input.store_all_limited(MAX_ADMIN_REQUEST_BODY_SIZE).await {
@@ -167,7 +187,7 @@ impl Operation for AssumeRoleHandle {
match body.action.as_str() {
ASSUME_ROLE_ACTION => {
let remote_addr = req.extensions.get::<Option<RemoteAddr>>().and_then(|opt| opt.map(|a| a.0));
handle_assume_role(req.credentials, req.uri, req.headers, remote_addr, body).await
handle_assume_role(req.credentials, req.uri, req.headers, remote_addr, body, store, &audit).await
}
ASSUME_ROLE_WITH_WEB_IDENTITY_ACTION => handle_assume_role_with_web_identity(body).await,
_ => Err(s3_error!(InvalidArgument, "unsupported Action")),
@@ -182,6 +202,8 @@ async fn handle_assume_role(
headers: http::HeaderMap,
remote_addr: Option<std::net::SocketAddr>,
body: AssumeRoleRequest,
store: Option<std::sync::Arc<crate::admin::storage_api::runtime::ECStore>>,
audit: &AccountAuditContext,
) -> S3Result<S3Response<(StatusCode, Body)>> {
let Some(user) = credentials else {
return Err(s3_error!(InvalidRequest, "get cred failed"));
@@ -223,8 +245,30 @@ async fn handle_assume_role(
return Err(s3_error!(InvalidArgument, "not support version"));
}
// Second-factor gate.
//
// This is the only place a second factor can be enforced, because minting an
// STS session is the only interactive login RustFS has. Note what is
// deliberately *not* gated: a request signed directly with a long-term
// access key. Gating that would break every script and CLI the moment a
// human enabled 2FA on their own account, and it would not add protection —
// whoever holds the secret key already has full access without ever
// presenting a code. Making 2FA meaningful for direct API access needs a
// policy condition on the session, which is tracked separately.
//
// An identity with no enrollment takes no new code path at all, so the
// behaviour of every existing deployment is unchanged.
let mfa_verified = enforce_second_factor(&cred.access_key, &body, store, audit).await?;
let mut claims = cred.claims.unwrap_or_default();
if mfa_verified {
// Recorded on the session so a later policy condition can require it,
// and so an audit consumer can tell a two-factor session from a
// single-factor one.
claims.insert(MFA_VERIFIED_CLAIM.to_string(), Value::Bool(true));
}
populate_session_policy(&mut claims, &body.policy)?;
let exp = clamp_assume_role_duration(body.duration_seconds);
@@ -297,6 +341,61 @@ async fn handle_assume_role(
Ok(S3Response::new((StatusCode::OK, Body::from(output))))
}
/// Session claim marking a session that presented a second factor.
///
/// Namespaced with the `x-rustfs-` prefix so it cannot collide with an OIDC
/// claim of the same name arriving from an identity provider.
pub(crate) const MFA_VERIFIED_CLAIM: &str = "x-rustfs-mfa-verified";
/// Require and verify a second factor when `access_key` has one enrolled.
///
/// Returns whether a factor was actually presented and verified. `false` means
/// the identity has no enrollment, not that verification was skipped.
async fn enforce_second_factor(
access_key: &str,
body: &AssumeRoleRequest,
store: Option<std::sync::Arc<crate::admin::storage_api::runtime::ECStore>>,
audit: &AccountAuditContext,
) -> S3Result<bool> {
let Some(store) = store else {
// Failing closed here would make every login depend on the store being
// reachable, but failing *open* would let a store outage disable the
// second factor. The store is required for the lookup, so an
// unavailable one is reported as unavailable.
return Err(crate::admin::storage_api::s3::error(
S3ErrorCode::ServiceUnavailable,
"the object store is not ready",
));
};
let now = OffsetDateTime::now_utc();
let required = mfa_service::is_enabled(store.clone(), access_key, now)
.await
.map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, err.to_string()))?;
if !required {
return Ok(false);
}
if body.token_code.is_empty() {
debug!(
access_key = %MaskedAccessKey(access_key),
"AssumeRole requires a second factor"
);
// The message carries the sentinel clients match on to decide whether to
// prompt for a code rather than report a failed login.
return Err(S3Error::with_message(
S3ErrorCode::AccessDenied,
format!("{ERR_MFA_REQUIRED}: a second authentication factor is required"),
));
}
let challenge = (!body.serial_number.is_empty()).then_some(body.serial_number.as_str());
mfa_verify_for_session(store, audit, access_key, IdentityType::Iam, challenge, &body.token_code).await?;
Ok(true)
}
/// Handle the AssumeRoleWithWebIdentity action.
/// The JWT (id_token) in the request is the authentication — no SigV4 needed.
async fn handle_assume_role_with_web_identity(body: AssumeRoleRequest) -> S3Result<S3Response<(StatusCode, Body)>> {
+10
View File
@@ -14,6 +14,8 @@
use super::{cluster_snapshot, metrics};
use crate::admin::auth::validate_admin_request;
use crate::admin::handlers::account::{ACCOUNT_INFO_ROUTE, ACCOUNT_PASSWORD_ROUTE};
use crate::admin::handlers::mfa::{ACCOUNT_MFA_ROUTE, MFA_CHALLENGE_ROUTE, USER_MFA_ROUTE};
use crate::admin::route_policy::{
ADMIN_ROUTE_POLICY_SPECS, DEFERRED_ADMIN_ROUTE_POLICIES, DeferredAdminRoutePolicy, DeferredRoutePolicyReason,
};
@@ -1107,6 +1109,14 @@ fn advertised_admin_capabilities() -> Vec<AdvertisedAdminCapability> {
("admin.iam.access-keys-bulk", HttpMethod::Get, IAM_ACCESS_KEYS_BULK_ROUTE),
("admin.iam.access-keys-bulk.ldap", HttpMethod::Get, IAM_ACCESS_KEYS_BULK_LDAP_ROUTE),
("admin.iam.access-keys-bulk.openid", HttpMethod::Get, IAM_ACCESS_KEYS_BULK_OPENID_ROUTE),
// Advertised so the console can hide the profile and 2FA surfaces
// against an older server instead of probing and handling a 404, and so
// `rc admin capabilities` reports them.
("admin.account.info", HttpMethod::Get, ACCOUNT_INFO_ROUTE),
("admin.account.password", HttpMethod::Post, ACCOUNT_PASSWORD_ROUTE),
("admin.account.mfa", HttpMethod::Get, ACCOUNT_MFA_ROUTE),
("admin.mfa.challenge", HttpMethod::Get, MFA_CHALLENGE_ROUTE),
("admin.user.mfa", HttpMethod::Get, USER_MFA_ROUTE),
]
.into_iter()
.map(|(name, method, route)| AdvertisedAdminCapability {
@@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use super::account::SetUserSecretKeyHandler;
use super::user::{AddUser, GetUserInfo, ListUsers, RemoveUser, SetUserStatus};
use crate::{
admin::router::{AdminOperation, S3Router},
@@ -50,5 +51,11 @@ pub fn register_user_lifecycle_route(r: &mut S3Router<AdminOperation>) -> std::i
AdminOperation(&SetUserStatus {}),
)?;
r.insert(
Method::PUT,
format!("{}{}", ADMIN_PREFIX, "/v3/set-user-secret-key").as_str(),
AdminOperation(&SetUserSecretKeyHandler {}),
)?;
Ok(())
}
+5 -3
View File
@@ -36,9 +36,9 @@ mod kms_contract;
mod route_registration_test;
use handlers::{
audit, batch_job, bucket_meta, cluster_snapshot, config_admin, diagnostics, durability as durability_handler, extensions,
heal, health, idp_compat, ilm_transition, inspect_archive, kms, module_switch, object_data_cache, object_zip_download, oidc,
plugins_catalog, plugins_instances, pools, profile_admin, quota as quota_handler, rebalance,
account, audit, batch_job, bucket_meta, cluster_snapshot, config_admin, diagnostics, durability as durability_handler,
extensions, heal, health, idp_compat, ilm_transition, inspect_archive, kms, mfa, module_switch, object_data_cache,
object_zip_download, oidc, plugins_catalog, plugins_instances, pools, profile_admin, quota as quota_handler, rebalance,
replication as replication_handler, scanner, site_replication, sts, system, table_catalog, tier, tls_debug, usage_prefix,
user,
};
@@ -66,6 +66,8 @@ fn register_admin_routes(r: &mut S3Router<AdminOperation>) -> std::io::Result<()
health::register_health_route(r)?;
sts::register_admin_auth_route(r)?;
account::register_account_route(r)?;
mfa::register_mfa_route(r)?;
user::register_user_route(r)?;
system::register_system_route(r)?;
pools::register_pool_route(r)?;
+57
View File
@@ -24,6 +24,7 @@ const CONFIG_UPDATE: AdminActionRef = AdminActionRef::new("ConfigUpdateAdminActi
const CONSOLE_LOG: AdminActionRef = AdminActionRef::new("ConsoleLogAdminAction");
const COMMIT_TABLE: AdminActionRef = AdminActionRef::new("CommitTableAction");
const CREATE_POLICY: AdminActionRef = AdminActionRef::new("CreatePolicyAdminAction");
const CREATE_USER: AdminActionRef = AdminActionRef::new("CreateUserAdminAction");
const CREATE_SERVICE_ACCOUNT: AdminActionRef = AdminActionRef::new("CreateServiceAccountAdminAction");
const CREATE_TABLE: AdminActionRef = AdminActionRef::new("CreateTableAction");
const DECOMMISSION: AdminActionRef = AdminActionRef::new("DecommissionAdminAction");
@@ -38,6 +39,7 @@ const EXPORT_IAM: AdminActionRef = AdminActionRef::new("ExportIAMAction");
const FORCE_UNLOCK: AdminActionRef = AdminActionRef::new("ForceUnlockAdminAction");
const GET_BUCKET_TARGET: AdminActionRef = AdminActionRef::new("GetBucketTargetAction");
const GET_GROUP: AdminActionRef = AdminActionRef::new("GetGroupAdminAction");
const GET_USER: AdminActionRef = AdminActionRef::new("GetUserAdminAction");
const GET_METRICS: AdminActionRef = AdminActionRef::new("GetMetricsAction");
const GET_POLICY: AdminActionRef = AdminActionRef::new("GetPolicyAdminAction");
const GET_REPLICATION_METRICS: AdminActionRef = AdminActionRef::new("GetReplicationMetricsAction");
@@ -153,6 +155,14 @@ pub const ADMIN_ROUTE_POLICY_SPECS: &[AdminRouteSpec] = &[
admin(HttpMethod::Get, "/rustfs/admin/v3/list-users", LIST_USERS, RouteRiskLevel::Sensitive),
admin(HttpMethod::Delete, "/rustfs/admin/v3/remove-user", DELETE_USER, RouteRiskLevel::High),
admin(HttpMethod::Put, "/rustfs/admin/v3/set-user-status", ENABLE_USER, RouteRiskLevel::High),
// Resetting somebody else's secret key is the same capability as creating
// them, so it carries the same action.
admin(HttpMethod::Put, "/rustfs/admin/v3/set-user-secret-key", CREATE_USER, RouteRiskLevel::High),
admin(HttpMethod::Get, "/rustfs/admin/v3/user/mfa", GET_USER, RouteRiskLevel::Sensitive),
// Clearing another identity's second factor is break-glass: whoever can
// re-enable a disabled account can already take the identity over, so this
// shares that action rather than inventing a weaker one.
admin(HttpMethod::Delete, "/rustfs/admin/v3/user/mfa", ENABLE_USER, RouteRiskLevel::High),
admin(HttpMethod::Get, "/rustfs/admin/v3/groups", LIST_GROUPS, RouteRiskLevel::Sensitive),
admin(HttpMethod::Get, "/rustfs/admin/v3/group", GET_GROUP, RouteRiskLevel::Sensitive),
admin(
@@ -1478,6 +1488,53 @@ pub const ADMIN_ROUTE_POLICY_SPECS: &[AdminRouteSpec] = &[
pub const DEFERRED_ADMIN_ROUTE_POLICIES: &[DeferredAdminRoutePolicy] = &[
deferred(HttpMethod::Get, "/rustfs/admin/v3/accountinfo", DeferredRoutePolicyReason::S3Action),
// The self-service account routes act on the caller, never on a target
// named in the request, so they gate on possession of the credential (plus,
// for the mutation, knowledge of the current secret) rather than on an
// admin action. Giving them one would be wrong in both directions: it would
// stop an ordinary user from managing their own password, and it would let
// any holder of that action manage somebody else's.
deferred(
HttpMethod::Get,
"/rustfs/admin/v3/account/info",
DeferredRoutePolicyReason::CredentialOnly,
),
deferred(
HttpMethod::Post,
"/rustfs/admin/v3/account/password",
DeferredRoutePolicyReason::CredentialOnly,
),
// The MFA self-service family gates the same way, plus a proof of the
// second factor (and, for disable, of the account password) inside the
// handler.
deferred(HttpMethod::Get, "/rustfs/admin/v3/account/mfa", DeferredRoutePolicyReason::CredentialOnly),
deferred(
HttpMethod::Post,
"/rustfs/admin/v3/account/mfa/enroll",
DeferredRoutePolicyReason::CredentialOnly,
),
deferred(
HttpMethod::Post,
"/rustfs/admin/v3/account/mfa/activate",
DeferredRoutePolicyReason::CredentialOnly,
),
deferred(
HttpMethod::Post,
"/rustfs/admin/v3/account/mfa/disable",
DeferredRoutePolicyReason::CredentialOnly,
),
deferred(
HttpMethod::Post,
"/rustfs/admin/v3/account/mfa/recovery-codes",
DeferredRoutePolicyReason::CredentialOnly,
),
// The login challenge is signed with the identity's own credentials, so
// possession of them is the whole authorization.
deferred(
HttpMethod::Get,
"/rustfs/admin/v3/mfa/challenge",
DeferredRoutePolicyReason::CredentialOnly,
),
deferred(
HttpMethod::Get,
"/rustfs/admin/v3/user-info",
@@ -125,11 +125,22 @@ fn expected_admin_route_matrix() -> Vec<RouteMatrixEntry> {
route(Method::POST, "/"),
admin_route(Method::GET, "/v3/is-admin"),
admin_route(Method::GET, "/v3/accountinfo"),
admin_route(Method::GET, "/v3/account/info"),
admin_route(Method::POST, "/v3/account/password"),
admin_route(Method::GET, "/v3/list-users"),
admin_route(Method::GET, "/v3/user-info"),
admin_route(Method::DELETE, "/v3/remove-user"),
admin_route(Method::PUT, "/v3/add-user"),
admin_route(Method::PUT, "/v3/set-user-status"),
admin_route(Method::PUT, "/v3/set-user-secret-key"),
admin_route(Method::GET, "/v3/account/mfa"),
admin_route(Method::POST, "/v3/account/mfa/enroll"),
admin_route(Method::POST, "/v3/account/mfa/activate"),
admin_route(Method::POST, "/v3/account/mfa/disable"),
admin_route(Method::POST, "/v3/account/mfa/recovery-codes"),
admin_route(Method::GET, "/v3/mfa/challenge"),
admin_route(Method::GET, "/v3/user/mfa"),
admin_route(Method::DELETE, "/v3/user/mfa"),
admin_route(Method::GET, "/v3/groups"),
admin_route(Method::GET, "/v3/group"),
admin_route_sample(Method::DELETE, "/v3/group/{group}", "/v3/group/test-group"),
+432
View File
@@ -0,0 +1,432 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Resolves the long-term identity behind an authenticated admin request.
//!
//! Self-service endpoints (`/v3/account/*`, `/v3/mfa/*`) act on "whoever is
//! calling" rather than on a target named in the request, so they all need the
//! same answer to two questions: which durable identity owns this credential,
//! and may that credential mutate the identity's own authentication material?
//!
//! Both answers are subtle. The Console operates entirely with STS session
//! credentials, so "the caller" is almost never the access key that signed the
//! request. Service accounts and OIDC sessions also present as derived
//! credentials but must *not* be allowed to rewrite the parent's secret. This
//! module is the single place those distinctions are made.
use crate::admin::auth::authenticate_request;
use crate::admin::runtime_sources::current_action_credentials;
use crate::admin::storage_api::s3::{self, Body, S3ErrorCode, S3Request, S3Result};
use crate::auth::constant_time_eq;
use rustfs_credentials::Credentials;
use rustfs_iam::federation::OIDC_VIRTUAL_PARENT_CLAIM;
use rustfs_iam::sys::is_rustfs_oidc_claims;
use rustfs_madmin::account::{AccountMutability, CredentialsSource, IdentityType};
/// Claim written by the Keystone middleware onto its synthesized credentials.
const KEYSTONE_ROLES_CLAIM: &str = "keystone_roles";
/// The durable identity that owns a session credential.
///
/// Prefers the `parent_user` field and falls back to the JWT `parent` claim:
/// some stores persist the parent only inside the session token, so checking
/// just one of the two silently misidentifies the caller. This mirrors the
/// resolution order used by the user-management handlers.
pub(crate) fn session_parent_identity(credentials: &Credentials) -> Option<&str> {
if !credentials.parent_user.is_empty() {
return Some(credentials.parent_user.as_str());
}
credentials
.claims
.as_ref()
.and_then(|claims| claims.get("parent"))
.and_then(|value| value.as_str())
}
/// Why a credential may not change its own authentication material.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CredentialMutationDenial {
/// Root credentials are pinned by a process-wide `OnceLock` and also feed
/// the derived internode RPC secret, so they cannot be rotated at runtime.
RootIsEnvironmentProvisioned,
/// The identity lives in an external IdP; RustFS holds no secret to change
/// and no TOTP enrollment of its own would be authoritative.
FederatedIdentity,
/// Machine credentials must not be able to take over the human identity
/// they were minted from.
ServiceAccount,
/// A derived credential whose parent could not be determined.
UnresolvedParent,
}
impl CredentialMutationDenial {
pub(crate) const fn message(self) -> &'static str {
match self {
Self::RootIsEnvironmentProvisioned => {
"the root identity is provisioned from the server environment and cannot be changed at runtime"
}
Self::FederatedIdentity => "federated identities are managed by their identity provider",
Self::ServiceAccount => "service account credentials cannot change the credentials of their parent identity",
Self::UnresolvedParent => "the parent identity of this session could not be resolved",
}
}
}
/// An authenticated caller, resolved to the identity it acts as.
#[derive(Debug, Clone)]
pub(crate) struct CallerIdentity {
/// The durable identity. For STS and service-account credentials this is
/// the parent, not the ephemeral access key that signed the request.
pub(crate) access_key: String,
pub(crate) identity_type: IdentityType,
/// The access key actually presented, when it differs from `access_key`.
pub(crate) session_access_key: Option<String>,
pub(crate) credentials_source: CredentialsSource,
/// The verified credentials of the presented key.
pub(crate) credentials: Credentials,
pub(crate) is_owner: bool,
/// Set when this credential kind may not rotate its own secret.
pub(crate) mutation_denial: Option<CredentialMutationDenial>,
/// Set when this credential kind may not manage its own second factor.
///
/// Distinct from [`Self::mutation_denial`], because the two questions have
/// different answers for the root identity: its secret key is pinned by a
/// process-wide `OnceLock`, but its *second factor* is an ordinary record
/// keyed on its access key. Conflating them would leave the default
/// deployment — a root administrator signing into the console — unable to
/// protect the one login that matters most.
pub(crate) mfa_denial: Option<CredentialMutationDenial>,
}
impl CallerIdentity {
/// Authenticate the request and resolve who it acts as.
///
/// Authentication only: callers that need an authorization decision must
/// still gate on an admin action. Self-service endpoints deliberately do
/// not, because every authenticated identity may inspect and manage itself.
pub(crate) async fn resolve(req: &S3Request<Body>) -> S3Result<Self> {
let Some(input_cred) = req.credentials.as_ref() else {
return Err(s3::error(S3ErrorCode::InvalidRequest, "authentication required"));
};
let (credentials, is_owner) = authenticate_request(&req.headers, &req.uri, input_cred).await?;
Ok(Self::from_credentials(credentials, is_owner))
}
fn from_credentials(credentials: Credentials, is_owner: bool) -> Self {
let presented_access_key = credentials.access_key.clone();
let is_service_account = credentials.is_service_account();
let is_temp = credentials.is_temp();
let federated = is_federated_session(&credentials);
let parent = session_parent_identity(&credentials).map(str::to_owned);
// A derived credential acts as its parent; a long-term one acts as
// itself. `is_service_account` is checked first because service-account
// credentials also carry a session token and would otherwise look
// temporary.
let (identity_type, access_key, unresolved_parent) = if is_service_account {
match parent {
Some(parent) => (IdentityType::ServiceAccount, parent, false),
None => (IdentityType::ServiceAccount, presented_access_key.clone(), true),
}
} else if is_temp {
match parent {
Some(parent) => (IdentityType::Sts, parent, false),
None => (IdentityType::Sts, presented_access_key.clone(), true),
}
} else {
(IdentityType::Iam, presented_access_key.clone(), false)
};
let is_root = current_action_credentials().is_some_and(|root| constant_time_eq(&root.access_key, &access_key));
let identity_type = if is_root && matches!(identity_type, IdentityType::Iam) {
IdentityType::Root
} else {
identity_type
};
let credentials_source = if is_root {
CredentialsSource::Env
} else {
CredentialsSource::Iam
};
// Order matters: report the most specific reason a caller will act on.
// "You are a service account" is more actionable than "your parent is
// root", and an unresolved parent must never fall through to a
// permissive answer.
//
// These three denials apply to both capabilities: a machine credential
// must not take over its parent, a federated identity is owned by its
// IdP, and an unresolvable parent fails closed.
let shared_denial = if unresolved_parent {
Some(CredentialMutationDenial::UnresolvedParent)
} else if is_service_account {
Some(CredentialMutationDenial::ServiceAccount)
} else if federated {
Some(CredentialMutationDenial::FederatedIdentity)
} else {
None
};
// Root additionally cannot rotate its secret — but it can still enroll a
// second factor, which is the whole point of the feature for a default
// deployment.
let mutation_denial = shared_denial.or(if is_root {
Some(CredentialMutationDenial::RootIsEnvironmentProvisioned)
} else {
None
});
let mfa_denial = shared_denial;
let session_access_key = (presented_access_key != access_key).then_some(presented_access_key);
Self {
access_key,
identity_type,
session_access_key,
credentials_source,
credentials,
is_owner,
mutation_denial,
mfa_denial,
}
}
/// Which self-service mutations the server will accept for this caller.
///
/// Reported to clients so they can disable a control instead of offering a
/// request that is guaranteed to fail.
pub(crate) const fn mutability(&self) -> AccountMutability {
match self.mutation_denial {
Some(_) => AccountMutability {
password: false,
username: false,
},
// Renaming an identity is not a supported mutation for anyone yet:
// the access key is the primary key for policy mappings, group
// membership, service-account parents and bucket-policy principals,
// so a rename is a migration rather than an edit.
None => AccountMutability {
password: true,
username: false,
},
}
}
/// `Ok(())` when this caller may rotate its own secret.
pub(crate) fn ensure_credential_mutation_allowed(&self) -> S3Result<()> {
match self.mutation_denial {
None => Ok(()),
Some(denial) => Err(s3::error(S3ErrorCode::InvalidRequest, denial.message())),
}
}
/// `Ok(())` when this caller may manage its own second factor.
///
/// Deliberately more permissive than [`Self::ensure_credential_mutation_allowed`]:
/// a root identity may enroll even though it cannot change its password.
pub(crate) fn ensure_mfa_management_allowed(&self) -> S3Result<()> {
match self.mfa_denial {
None => Ok(()),
Some(denial) => Err(s3::error(S3ErrorCode::InvalidRequest, denial.message())),
}
}
}
/// Whether the session was minted by an external identity provider.
///
/// Such sessions have no RustFS-held long-term secret, so a password change has
/// nothing to change and a TOTP enrollment would not be consulted at login —
/// the IdP owns both.
fn is_federated_session(credentials: &Credentials) -> bool {
let Some(claims) = credentials.claims.as_ref() else {
return false;
};
is_rustfs_oidc_claims(claims) || claims.contains_key(OIDC_VIRTUAL_PARENT_CLAIM) || claims.contains_key(KEYSTONE_ROLES_CLAIM)
}
#[cfg(test)]
mod tests {
use super::*;
use rustfs_credentials::IAM_POLICY_CLAIM_NAME_SA;
use serde_json::Value;
use std::collections::HashMap;
fn long_term(access_key: &str) -> Credentials {
Credentials {
access_key: access_key.to_string(),
secret_key: "secret-key-value".to_string(),
..Default::default()
}
}
fn sts_session(access_key: &str, parent: &str) -> Credentials {
Credentials {
access_key: access_key.to_string(),
secret_key: "session-secret".to_string(),
session_token: "token".to_string(),
parent_user: parent.to_string(),
..Default::default()
}
}
fn service_account(access_key: &str, parent: &str) -> Credentials {
let mut claims = HashMap::new();
claims.insert(IAM_POLICY_CLAIM_NAME_SA.to_string(), Value::String("inherited".to_string()));
Credentials {
access_key: access_key.to_string(),
secret_key: "svc-secret".to_string(),
session_token: "token".to_string(),
parent_user: parent.to_string(),
claims: Some(claims),
..Default::default()
}
}
#[test]
fn long_term_iam_user_acts_as_itself_and_may_change_its_password() {
let caller = CallerIdentity::from_credentials(long_term("sinan"), false);
assert_eq!(caller.access_key, "sinan");
assert_eq!(caller.identity_type, IdentityType::Iam);
assert!(caller.session_access_key.is_none());
assert_eq!(caller.credentials_source, CredentialsSource::Iam);
assert!(caller.mutation_denial.is_none());
assert!(caller.mutability().password);
// Rename stays unsupported even for the cases password change allows.
assert!(!caller.mutability().username);
assert!(caller.ensure_credential_mutation_allowed().is_ok());
}
#[test]
fn sts_session_acts_as_its_parent() {
// The Console only ever holds STS credentials, so this is the path that
// every real "change my password" request takes.
let caller = CallerIdentity::from_credentials(sts_session("TEMPKEY", "sinan"), false);
assert_eq!(caller.access_key, "sinan");
assert_eq!(caller.identity_type, IdentityType::Sts);
assert_eq!(caller.session_access_key.as_deref(), Some("TEMPKEY"));
assert!(caller.mutation_denial.is_none());
assert!(caller.mutability().password);
}
#[test]
fn sts_session_falls_back_to_the_jwt_parent_claim() {
let mut credentials = sts_session("TEMPKEY", "");
let mut claims = HashMap::new();
claims.insert("parent".to_string(), Value::String("sinan".to_string()));
credentials.claims = Some(claims);
let caller = CallerIdentity::from_credentials(credentials, false);
assert_eq!(caller.access_key, "sinan");
assert_eq!(caller.identity_type, IdentityType::Sts);
assert!(caller.mutation_denial.is_none());
}
#[test]
fn a_root_identity_may_enroll_a_second_factor_even_though_its_password_is_fixed() {
// The case that matters most: the default deployment signs into the
// console as root, so refusing enrollment here would leave the one login
// the feature exists to protect unprotected.
let mut caller = CallerIdentity::from_credentials(long_term("rustfsadmin"), true);
caller.identity_type = IdentityType::Root;
caller.credentials_source = CredentialsSource::Env;
caller.mutation_denial = Some(CredentialMutationDenial::RootIsEnvironmentProvisioned);
caller.mfa_denial = None;
assert!(caller.ensure_credential_mutation_allowed().is_err());
assert!(caller.ensure_mfa_management_allowed().is_ok());
assert!(!caller.mutability().password);
}
#[test]
fn a_service_account_may_manage_neither() {
let caller = CallerIdentity::from_credentials(service_account("SVCKEY", "sinan"), false);
assert!(caller.ensure_credential_mutation_allowed().is_err());
assert!(caller.ensure_mfa_management_allowed().is_err());
}
#[test]
fn an_ordinary_iam_user_may_manage_both() {
let caller = CallerIdentity::from_credentials(long_term("sinan"), false);
assert!(caller.ensure_credential_mutation_allowed().is_ok());
assert!(caller.ensure_mfa_management_allowed().is_ok());
}
#[test]
fn service_account_may_not_mutate_its_parent() {
let caller = CallerIdentity::from_credentials(service_account("SVCKEY", "sinan"), false);
assert_eq!(caller.access_key, "sinan");
assert_eq!(caller.identity_type, IdentityType::ServiceAccount);
assert_eq!(caller.mutation_denial, Some(CredentialMutationDenial::ServiceAccount));
assert!(!caller.mutability().password);
assert!(caller.ensure_credential_mutation_allowed().is_err());
}
#[test]
fn oidc_session_is_reported_as_federated() {
let mut credentials = sts_session("TEMPKEY", "oidc-parent");
let mut claims = HashMap::new();
claims.insert("iss".to_string(), Value::String("rustfs-oidc".to_string()));
claims.insert("oidc_provider".to_string(), Value::String("keycloak".to_string()));
claims.insert("sub".to_string(), Value::String("user-123".to_string()));
credentials.claims = Some(claims);
let caller = CallerIdentity::from_credentials(credentials, false);
assert_eq!(caller.mutation_denial, Some(CredentialMutationDenial::FederatedIdentity));
assert!(!caller.mutability().password);
}
#[test]
fn keystone_session_is_reported_as_federated() {
let mut credentials = sts_session("TEMPKEY", "keystone-parent");
let mut claims = HashMap::new();
claims.insert(KEYSTONE_ROLES_CLAIM.to_string(), Value::Array(vec![]));
credentials.claims = Some(claims);
let caller = CallerIdentity::from_credentials(credentials, false);
assert_eq!(caller.mutation_denial, Some(CredentialMutationDenial::FederatedIdentity));
}
#[test]
fn derived_credential_without_a_parent_is_denied_rather_than_allowed() {
// Fail closed: an unresolvable parent must not be treated as a
// long-term identity acting on itself.
let caller = CallerIdentity::from_credentials(sts_session("TEMPKEY", ""), false);
assert_eq!(caller.mutation_denial, Some(CredentialMutationDenial::UnresolvedParent));
assert!(!caller.mutability().password);
}
#[test]
fn session_parent_identity_prefers_the_parent_user_field() {
let mut credentials = sts_session("TEMPKEY", "field-parent");
let mut claims = HashMap::new();
claims.insert("parent".to_string(), Value::String("claim-parent".to_string()));
credentials.claims = Some(claims);
assert_eq!(session_parent_identity(&credentials), Some("field-parent"));
}
}
+1
View File
@@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
pub(crate) mod caller_identity;
pub mod config;
pub(crate) mod federated_identity;
pub(crate) mod session_policy;
+12 -1
View File
@@ -958,7 +958,18 @@ pub(crate) mod runtime {
}
pub(crate) mod s3 {
pub(crate) use s3s::{S3Error, S3ErrorCode, S3Result};
pub(crate) use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, header};
/// Build an `S3Error` without reaching for the `s3s` error macro.
///
/// The macro expands to the very constructor this calls, but it puts an
/// `s3s` dependency in every file that reports an error. Routing the
/// construction through here keeps that dependency in this facade, which is
/// the boundary the s3gate migration replaces
/// (`scripts/check_s3s_footprint.sh`, rustfs/backlog#1677 F1).
pub(crate) fn error(code: S3ErrorCode, message: impl Into<std::borrow::Cow<'static, str>>) -> S3Error {
S3Error::with_message(code, message)
}
}
pub(crate) mod tier {