// 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.

use super::{GroupInfo, MappedPolicy, Store, UserType};
use crate::error::{Error, Result, is_err_config_not_found, is_err_no_such_group};
use crate::{
    IAM_CONFIG_ROOT_PREFIX, IamStorageError, IamStore, classify_iam_system_path_failure_reason, delete_iam_config,
    read_iam_config_no_lock, read_iam_config_with_metadata, save_iam_config, save_iam_config_with_opts,
};
use crate::{
    cache::{Cache, CacheEntity},
    error::{is_err_no_such_policy, is_err_no_such_user},
    keyring,
    manager::{extract_jwt_claims, extract_jwt_claims_allow_missing_exp, get_default_policies},
    root_credentials,
};
use futures::future::join_all;
use rustfs_io_metrics::record_system_path_failure;
use rustfs_policy::{auth::UserIdentity, policy::PolicyDoc};
use rustfs_utils::{
    MaskedAccessKey,
    path::{SLASH_SEPARATOR, path_join_buf},
};
use serde::{Serialize, de::DeserializeOwned};
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
use std::{collections::HashMap, sync::Arc};
use tokio::sync::mpsc::{self, Sender};
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, warn};

use crate::storage_api::object_store::{
    HTTPPreconditions, ListOperations, ObjectInfoOrErr as StorageObjectInfoOrErr, ObjectOperations,
};

pub static IAM_CONFIG_PREFIX: LazyLock<String> = LazyLock::new(|| format!("{IAM_CONFIG_ROOT_PREFIX}/iam"));
pub static IAM_CONFIG_USERS_PREFIX: LazyLock<String> = LazyLock::new(|| format!("{IAM_CONFIG_ROOT_PREFIX}/iam/users/"));
pub static IAM_CONFIG_SERVICE_ACCOUNTS_PREFIX: LazyLock<String> =
    LazyLock::new(|| format!("{IAM_CONFIG_ROOT_PREFIX}/iam/service-accounts/"));
pub static IAM_CONFIG_GROUPS_PREFIX: LazyLock<String> = LazyLock::new(|| format!("{IAM_CONFIG_ROOT_PREFIX}/iam/groups/"));
pub static IAM_CONFIG_POLICIES_PREFIX: LazyLock<String> = LazyLock::new(|| format!("{IAM_CONFIG_ROOT_PREFIX}/iam/policies/"));
pub static IAM_CONFIG_STS_PREFIX: LazyLock<String> = LazyLock::new(|| format!("{IAM_CONFIG_ROOT_PREFIX}/iam/sts/"));
pub static IAM_CONFIG_POLICY_DB_PREFIX: LazyLock<String> = LazyLock::new(|| format!("{IAM_CONFIG_ROOT_PREFIX}/iam/policydb/"));
pub static IAM_CONFIG_POLICY_DB_USERS_PREFIX: LazyLock<String> =
    LazyLock::new(|| format!("{IAM_CONFIG_ROOT_PREFIX}/iam/policydb/users/"));
pub static IAM_CONFIG_POLICY_DB_STS_USERS_PREFIX: LazyLock<String> =
    LazyLock::new(|| format!("{IAM_CONFIG_ROOT_PREFIX}/iam/policydb/sts-users/"));
pub static IAM_CONFIG_POLICY_DB_SERVICE_ACCOUNTS_PREFIX: LazyLock<String> =
    LazyLock::new(|| format!("{IAM_CONFIG_ROOT_PREFIX}/iam/policydb/service-accounts/"));
pub static IAM_CONFIG_POLICY_DB_GROUPS_PREFIX: LazyLock<String> =
    LazyLock::new(|| format!("{IAM_CONFIG_ROOT_PREFIX}/iam/policydb/groups/"));

type ObjectInfoOrErr = StorageObjectInfoOrErr<IamObjectInfo, IamStorageError>;
type IamObjectInfo = <IamStore as ObjectOperations>::ObjectInfo;
type IamObjectOptions = <IamStore as ObjectOperations>::ObjectOptions;

const IAM_IDENTITY_FILE: &str = "identity.json";
const IAM_POLICY_FILE: &str = "policy.json";
const IAM_GROUP_MEMBERS_FILE: &str = "members.json";
const BACKGROUND_WALKDIR_TIMEOUT: Duration = Duration::from_secs(60);

fn get_user_identity_path(user: &str, user_type: UserType) -> String {
    let base_path: &str = match user_type {
        UserType::Svc => &IAM_CONFIG_SERVICE_ACCOUNTS_PREFIX,
        UserType::Sts => &IAM_CONFIG_STS_PREFIX,
        _ => &IAM_CONFIG_USERS_PREFIX,
    };

    path_join_buf(&[base_path, user, IAM_IDENTITY_FILE])
}

fn get_group_info_path(group: &str) -> String {
    path_join_buf(&[&IAM_CONFIG_GROUPS_PREFIX, group, IAM_GROUP_MEMBERS_FILE])
}

fn get_policy_doc_path(name: &str) -> String {
    path_join_buf(&[&IAM_CONFIG_POLICIES_PREFIX, name, IAM_POLICY_FILE])
}

fn get_mapped_policy_path(name: &str, user_type: UserType, is_group: bool) -> String {
    if is_group {
        return path_join_buf(&[&IAM_CONFIG_POLICY_DB_GROUPS_PREFIX, format!("{name}.json").as_str()]);
    }
    match user_type {
        UserType::Svc => path_join_buf(&[&IAM_CONFIG_POLICY_DB_SERVICE_ACCOUNTS_PREFIX, format!("{name}.json").as_str()]),
        UserType::Sts => path_join_buf(&[&IAM_CONFIG_POLICY_DB_STS_USERS_PREFIX, format!("{name}.json").as_str()]),
        _ => path_join_buf(&[&IAM_CONFIG_POLICY_DB_USERS_PREFIX, format!("{name}.json").as_str()]),
    }
}

/// Lock semantics for IAM config reads.
///
/// Bootstrap/snapshot loads (`load_all`) must not require distributed
/// namespace locks: lock quorum is counted over cluster nodes, so during a
/// sequential restart the peers are unreachable and every locked read fails
/// with `QuorumNotReached` even though the storage read quorum is already
/// satisfiable (rustfs#4304). This mirrors the startup contract established
/// for pool metadata in rustfs#4056. Config objects are atomic whole-object
/// writes, so a lock-free read only ever observes an old or a new value;
/// staleness is bounded by the periodic IAM reload.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum LoadMode {
    /// On-line load on behalf of a live request: takes namespace locks.
    Locked,
    /// Cache-refresh load (bootstrap/reload `load_all`, or a notification-path
    /// single-object refresh): bypasses namespace locks.
    BootstrapNoLock,
}

impl LoadMode {
    fn no_lock(self) -> bool {
        matches!(self, LoadMode::BootstrapNoLock)
    }

    fn read_opts(self) -> IamObjectOptions {
        IamObjectOptions {
            no_lock: self.no_lock(),
            ..Default::default()
        }
    }
}

#[derive(Debug)]
pub struct StringOrErr {
    pub item: Option<String>,
    pub err: Option<Error>,
}

const USERS_LIST_KEY: &str = "users/";
const SVC_ACC_LIST_KEY: &str = "service-accounts/";
const GROUPS_LIST_KEY: &str = "groups/";
const POLICIES_LIST_KEY: &str = "policies/";
const STS_LIST_KEY: &str = "sts/";
const POLICY_DB_PREFIX: &str = "policydb/";
const POLICY_DB_USERS_LIST_KEY: &str = "policydb/users/";
const POLICY_DB_STS_USERS_LIST_KEY: &str = "policydb/sts-users/";
const POLICY_DB_GROUPS_LIST_KEY: &str = "policydb/groups/";
const IAM_LAZY_REWRITE_COOLDOWN: Duration = Duration::from_secs(60);

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum DecryptSource {
    Plaintext,
    CurrentMasterKey,
    OldMasterKey,
    LegacySecretKey,
    LegacyAccessSecretKey,
}

#[derive(Debug)]
struct DecryptOutcome {
    plain: Vec<u8>,
    source: DecryptSource,
}

#[derive(Default, Clone, Copy, Debug)]
struct LazyRewriteEntry {
    in_flight: bool,
    cooldown_until: Option<Instant>,
}

static IAM_LAZY_REWRITE_TRACKER: LazyLock<Mutex<HashMap<String, LazyRewriteEntry>>> =
    LazyLock::new(|| Mutex::new(HashMap::new()));

// split_path splits a path into a top-level directory and a child item. The
// parent directory retains the trailing slash.
fn split_path(s: &str, last_index: bool) -> (&str, &str) {
    let i = if last_index { s.rfind('/') } else { s.find('/') };

    match i {
        Some(index) => (&s[..index + 1], &s[index + 1..]),
        None => (s, ""),
    }
}

/// Attempts to decrypt an at-rest IAM config blob using the same key sources as
/// the IAM load path: the RustFS master keys (`RUSTFS_IAM_MASTER_KEY[_OLD_KEYS]`)
/// and the MinIO-compatible legacy keys derived from the root credentials
/// (`secret_key` and `access_key:secret_key`).
///
/// Returns the plaintext when a key succeeds. When `data` is already plaintext
/// JSON it is returned unchanged. Returns `None` if no configured key can decrypt
/// it, so the caller can fall back to treating `data` as-is.
///
/// This is used by the MinIO -> RustFS migration path to decrypt IAM
/// identity/service-account files that MinIO encrypts at rest before normalizing
/// and re-persisting them under the RustFS system bucket.
pub fn try_decrypt_iam_blob(data: &[u8]) -> Option<Vec<u8>> {
    ObjectStore::decrypt_data_with_source(data).ok().map(|outcome| outcome.plain)
}

/// Encrypt a blob for at-rest storage using the IAM master key.
///
/// Shared with [`crate::mfa`], which stores TOTP secrets under the same key so
/// there is one at-rest scheme for every credential-equivalent secret the IAM
/// domain owns, and one key to rotate.
pub(crate) fn encrypt_iam_blob(plain: &[u8]) -> Result<Vec<u8>> {
    ObjectStore::encrypt_data_with_master_key(plain)
}

/// Decrypt a blob written by [`encrypt_iam_blob`], or read a legacy plaintext
/// one, using the same key sources as the IAM load path.
pub(crate) fn decrypt_iam_blob(data: &[u8]) -> Result<Vec<u8>> {
    ObjectStore::decrypt_data_with_source(data).map(|outcome| outcome.plain)
}

#[derive(Clone)]
pub struct ObjectStore {
    object_api: Arc<IamStore>,
}

impl ObjectStore {
    const BUCKET_NAME: &'static str = ".rustfs.sys";

    pub fn new(object_api: Arc<IamStore>) -> Self {
        Self { object_api }
    }

    fn decrypt_data_with_source(data: &[u8]) -> Result<DecryptOutcome> {
        if Self::is_plaintext_json(data) {
            return Ok(DecryptOutcome {
                plain: data.to_vec(),
                source: DecryptSource::Plaintext,
            });
        }

        const STREAM_IO_HEADER_LEN: usize = 41;
        let cred = root_credentials::credentials_or_default();
        let mut last_err = None;

        let mut try_decrypt_with_key = |key: &[u8], source: DecryptSource| -> Option<DecryptOutcome> {
            if data.len() >= STREAM_IO_HEADER_LEN
                && let Ok(plain) = rustfs_crypto::decrypt_stream_io(key, data)
            {
                return Some(DecryptOutcome { plain, source });
            }

            match rustfs_crypto::decrypt_data(key, data) {
                Ok(plain) => Some(DecryptOutcome { plain, source }),
                Err(err) => {
                    last_err = Some(err);
                    None
                }
            }
        };

        let (current_key, old_keys) = keyring::current_key_and_old_keys();
        if let Some(key) = current_key
            && let Some(outcome) = try_decrypt_with_key(&key, DecryptSource::CurrentMasterKey)
        {
            return Ok(outcome);
        }

        for key in old_keys {
            if let Some(outcome) = try_decrypt_with_key(&key, DecryptSource::OldMasterKey) {
                return Ok(outcome);
            }
        }

        let secret_key = cred.secret_key;
        let mut legacy_keys = Vec::new();
        if !secret_key.is_empty() {
            legacy_keys.push((secret_key.clone().into_bytes(), DecryptSource::LegacySecretKey));
        }
        if !cred.access_key.is_empty() && !secret_key.is_empty() {
            legacy_keys.push((
                format!("{}:{secret_key}", cred.access_key).into_bytes(),
                DecryptSource::LegacyAccessSecretKey,
            ));
        }

        for (key, source) in legacy_keys {
            if let Some(outcome) = try_decrypt_with_key(&key, source) {
                return Ok(outcome);
            }
        }

        Err(last_err.unwrap_or(rustfs_crypto::Error::ErrUnexpectedHeader).into())
    }

    fn encrypt_data_with_master_key(data: &[u8]) -> Result<Vec<u8>> {
        let Some(master_key) = keyring::encrypt_key() else {
            return Err(Error::other("iam master key is not configured"));
        };

        let encrypted = rustfs_crypto::encrypt_stream_io(&master_key, data)?;
        Ok(encrypted)
    }

    fn prepare_data_for_storage(data: &[u8]) -> Result<Vec<u8>> {
        if keyring::encrypt_key().is_some() {
            let encrypted = Self::encrypt_data_with_master_key(data)?;
            return Ok(encrypted);
        }

        Ok(data.to_vec())
    }

    fn should_lazy_rewrite(source: DecryptSource) -> bool {
        matches!(
            source,
            DecryptSource::Plaintext
                | DecryptSource::OldMasterKey
                | DecryptSource::LegacySecretKey
                | DecryptSource::LegacyAccessSecretKey
        )
    }

    fn begin_lazy_rewrite(path: &str) -> bool {
        let Ok(mut tracker) = IAM_LAZY_REWRITE_TRACKER.lock() else {
            return false;
        };

        let entry = tracker.entry(path.to_owned()).or_default();
        if entry.in_flight {
            return false;
        }
        if entry.cooldown_until.is_some_and(|deadline| deadline > Instant::now()) {
            return false;
        }

        entry.in_flight = true;
        entry.cooldown_until = None;
        true
    }

    fn complete_lazy_rewrite(path: &str, success: bool) {
        let Ok(mut tracker) = IAM_LAZY_REWRITE_TRACKER.lock() else {
            return;
        };

        if success {
            tracker.remove(path);
            return;
        }

        let entry = tracker.entry(path.to_owned()).or_default();
        entry.in_flight = false;
        entry.cooldown_until = Some(Instant::now() + IAM_LAZY_REWRITE_COOLDOWN);
    }

    fn maybe_schedule_lazy_rewrite(&self, path: &str, outcome: &DecryptOutcome, object_info: &IamObjectInfo) {
        if !Self::should_lazy_rewrite(outcome.source) {
            return;
        }
        if keyring::encrypt_key().is_none() {
            return;
        }

        let Some(etag) = object_info.etag.clone() else {
            return;
        };

        if !Self::begin_lazy_rewrite(path) {
            return;
        }

        let path = path.to_owned();
        let plain = outcome.plain.clone();
        let store = self.clone();
        tokio::spawn(async move {
            let result = store.lazy_rewrite_iam_config(path.as_str(), &plain, etag.as_str()).await;
            match result {
                Ok(_) => {
                    Self::complete_lazy_rewrite(path.as_str(), true);
                }
                Err(IamStorageError::PreconditionFailed) => {
                    Self::complete_lazy_rewrite(path.as_str(), false);
                    debug!(path = %path, state = "stale_etag", "IAM lazy rewrite skipped");
                }
                Err(err) => {
                    Self::complete_lazy_rewrite(path.as_str(), false);
                    warn!(path = %path, error = %err, state = "rewrite_failed", "IAM lazy rewrite failed");
                }
            }
        });
    }

    async fn lazy_rewrite_iam_config(&self, path: &str, plain: &[u8], etag: &str) -> std::result::Result<(), IamStorageError> {
        let encrypted = Self::encrypt_data_with_master_key(plain).map_err(IamStorageError::other)?;

        let mut opts = IamObjectOptions {
            max_parity: true,
            ..Default::default()
        };
        opts.http_preconditions = Some(HTTPPreconditions {
            if_match: Some(etag.to_owned()),
            ..Default::default()
        });

        save_iam_config_with_opts(self.object_api.clone(), path, encrypted, &opts).await
    }

    fn is_plaintext_json(data: &[u8]) -> bool {
        std::str::from_utf8(data).is_ok() && serde_json::from_slice::<serde_json::Value>(data).is_ok()
    }

    #[cfg(test)]
    fn prepare_data_for_storage_for_test(data: &[u8]) -> Result<Vec<u8>> {
        Self::prepare_data_for_storage(data)
    }

    async fn load_iamconfig_bytes_with_metadata(
        &self,
        path: impl AsRef<str> + Send,
        mode: LoadMode,
    ) -> Result<(Vec<u8>, IamObjectInfo)> {
        let path_ref = path.as_ref();
        let (data, obj) = read_iam_config_with_metadata(self.object_api.clone(), path_ref, &mode.read_opts()).await?;
        let outcome = Self::decrypt_data_with_source(&data)?;
        self.maybe_schedule_lazy_rewrite(path_ref, &outcome, &obj);

        Ok((outcome.plain, obj))
    }

    async fn list_iam_config_items(&self, prefix: &str, ctx: CancellationToken, sender: Sender<StringOrErr>) {
        // debug!("list iam config items, prefix: {}", &prefix);

        let store = self.object_api.clone();

        let (tx, mut rx) = mpsc::channel::<ObjectInfoOrErr>(100);

        let path = prefix.to_owned();
        let sender_on_error = sender.clone();
        tokio::spawn(async move {
            let walk_opts =
                <IamStore as ListOperations>::WalkOptions::default().with_walkdir_timeouts(BACKGROUND_WALKDIR_TIMEOUT);
            if let Err(err) = store.walk(ctx.clone(), Self::BUCKET_NAME, &path, tx, walk_opts).await {
                let reason = classify_iam_system_path_failure_reason(&err);
                record_system_path_failure("iam_config", "walk", reason);
                error!(
                    path_kind = "iam_config",
                    operation = "walk",
                    reason,
                    bucket = Self::BUCKET_NAME,
                    prefix = %path,
                    error = %err,
                    "IAM config walk failed"
                );
                let _ = sender_on_error
                    .send(StringOrErr {
                        item: None,
                        err: Some(err.into()),
                    })
                    .await;
            }
        });

        let prefix = prefix.to_owned();
        tokio::spawn(async move {
            while let Some(v) = rx.recv().await {
                if let Some(err) = v.err {
                    let _ = sender
                        .send(StringOrErr {
                            item: None,
                            err: Some(err.into()),
                        })
                        .await;
                    return;
                }

                if let Some(info) = v.item {
                    let object_name = if cfg!(target_os = "windows") {
                        info.name.replace('\\', "/")
                    } else {
                        info.name
                    };
                    let name = object_name.trim_start_matches(&prefix).trim_end_matches(SLASH_SEPARATOR);
                    let _ = sender
                        .send(StringOrErr {
                            item: Some(name.to_owned()),
                            err: None,
                        })
                        .await;
                }
            }
        });
    }

    #[hotpath::measure]
    async fn list_all_iamconfig_items(&self) -> Result<HashMap<String, Vec<String>>> {
        let (tx, mut rx) = mpsc::channel::<StringOrErr>(100);

        let ctx = CancellationToken::new();

        self.list_iam_config_items(format!("{}/", *IAM_CONFIG_PREFIX).as_str(), ctx.clone(), tx)
            .await;

        let mut res = HashMap::new();

        while let Some(v) = rx.recv().await {
            if let Some(err) = v.err {
                warn!(
                    path_kind = "iam_config",
                    operation = "list_items",
                    reason = "walk_result",
                    error = %err,
                    "system path list failed"
                );
                record_system_path_failure("iam_config", "list_items", "walk_result");
                ctx.cancel();

                return Err(err);
            }

            if let Some(item) = v.item {
                let last_index = item.starts_with(POLICY_DB_PREFIX);
                let (list_key, trimmed_item) = split_path(&item, last_index);
                res.entry(list_key.to_owned())
                    .or_insert_with(Vec::new)
                    .push(trimmed_item.to_owned());
            }
        }

        ctx.cancel();

        Ok(res)
    }

    #[hotpath::measure]
    async fn load_policy_doc_concurrent(&self, names: &[String], mode: LoadMode) -> Result<Vec<PolicyDoc>> {
        let mut futures = Vec::with_capacity(names.len());

        for name in names {
            let policy_name = rustfs_utils::path::dir(name);
            futures.push(async move {
                match self.load_policy_with(&policy_name, mode).await {
                    Ok(p) => Ok(p),
                    Err(err) => {
                        if !is_err_no_such_policy(&err) {
                            Err(Error::other(format!("load policy doc failed: {err}")))
                        } else {
                            Ok(PolicyDoc::default())
                        }
                    }
                }
            });
        }

        let results = join_all(futures).await;

        let mut policies = Vec::with_capacity(results.len());
        for r in results {
            match r {
                Ok(p) => policies.push(p),
                Err(e) => return Err(e),
            }
        }

        Ok(policies)
    }

    async fn load_user_concurrent(&self, names: &[String], user_type: UserType, mode: LoadMode) -> Result<Vec<UserIdentity>> {
        let mut futures = Vec::with_capacity(names.len());

        for name in names {
            let user_name = rustfs_utils::path::dir(name);
            futures.push(async move {
                match self.load_user_identity_with(&user_name, user_type, mode).await {
                    Ok(res) => Ok(res),
                    Err(err) => {
                        if !is_err_no_such_user(&err) {
                            Err(Error::other(format!("load user failed: {err}")))
                        } else {
                            Ok(UserIdentity::default())
                        }
                    }
                }
            });
        }

        let results = join_all(futures).await;

        let mut users = Vec::with_capacity(results.len());
        for r in results {
            match r {
                Ok(u) => users.push(u),
                Err(e) => return Err(e),
            }
        }
        Ok(users)
    }

    /// Parameterized core of [`Store::load_iam_config`]. `LoadMode::BootstrapNoLock`
    /// keeps the bootstrap snapshot load free of namespace locks (rustfs#4304).
    async fn load_iam_config_with<Item: DeserializeOwned>(&self, path: impl AsRef<str> + Send, mode: LoadMode) -> Result<Item> {
        let path_ref = path.as_ref();
        let (data, obj) = read_iam_config_with_metadata(self.object_api.clone(), path_ref, &mode.read_opts()).await?;

        let outcome = match Self::decrypt_data_with_source(&data) {
            Ok(v) => v,
            Err(err) => {
                warn!(path = %path_ref, error = %err, "IAM config decrypt failed; keeping file");
                // keep the config file when decrypt failed - do not delete
                return Err(Error::ConfigNotFound);
            }
        };

        self.maybe_schedule_lazy_rewrite(path_ref, &outcome, &obj);

        Ok(serde_json::from_slice(&outcome.plain)?)
    }

    /// Parameterized core of [`Store::load_user_identity`].
    async fn load_user_identity_with(&self, name: &str, user_type: UserType, mode: LoadMode) -> Result<UserIdentity> {
        let mut u: UserIdentity = self
            .load_iam_config_with(get_user_identity_path(name, user_type), mode)
            .await
            .map_err(|err| {
                if is_err_config_not_found(&err) {
                    debug!(name = %MaskedAccessKey(name), user_type = ?user_type, "IAM user identity missing");
                    Error::NoSuchUser(name.to_owned())
                } else {
                    warn!(name = %MaskedAccessKey(name), user_type = ?user_type, error = ?err, "IAM user identity load failed");
                    err
                }
            })?;

        if u.credentials.is_expired() {
            let _ = self.delete_iam_config(get_user_identity_path(name, user_type)).await;
            let _ = self.delete_iam_config(get_mapped_policy_path(name, user_type, false)).await;
            warn!(name = %MaskedAccessKey(name), user_type = ?user_type, "IAM user identity expired and was removed");
            return Err(Error::NoSuchUser(name.to_owned()));
        }

        if u.credentials.access_key.is_empty() {
            u.credentials.access_key = name.to_owned();
        }

        if !u.credentials.session_token.is_empty() {
            let claims_result = if user_type == UserType::Svc && u.credentials.expiration.is_none() {
                extract_jwt_claims_allow_missing_exp(&u)
            } else {
                extract_jwt_claims(&u)
            };

            match claims_result {
                Ok(claims) => {
                    u.credentials.claims = Some(claims);
                }
                Err(err) => {
                    if u.credentials.is_temp() {
                        let _ = self.delete_iam_config(get_user_identity_path(name, user_type)).await;
                        let _ = self.delete_iam_config(get_mapped_policy_path(name, user_type, false)).await;
                    }
                    warn!(name = %MaskedAccessKey(name), user_type = ?user_type, error = ?err, "IAM JWT claim extraction failed");
                    return Err(Error::NoSuchUser(name.to_owned()));
                }
            }
        }

        Ok(u)
    }

    /// Parameterized core of [`Store::load_user`].
    async fn load_user_with(
        &self,
        name: &str,
        user_type: UserType,
        m: &mut HashMap<String, UserIdentity>,
        mode: LoadMode,
    ) -> Result<()> {
        self.load_user_identity_with(name, user_type, mode).await.map(|u| {
            m.insert(name.to_owned(), u);
        })
    }

    /// Parameterized core of [`Store::load_group`].
    async fn load_group_with(&self, name: &str, m: &mut HashMap<String, GroupInfo>, mode: LoadMode) -> Result<()> {
        let u: GroupInfo = self
            .load_iam_config_with(get_group_info_path(name), mode)
            .await
            .map_err(|err| {
                if is_err_config_not_found(&err) {
                    Error::NoSuchGroup(name.to_string())
                } else {
                    err
                }
            })?;

        m.insert(name.to_owned(), u);
        Ok(())
    }

    /// Parameterized core of [`Store::load_policy`].
    async fn load_policy_with(&self, name: &str, mode: LoadMode) -> Result<PolicyDoc> {
        let (data, obj) = self
            .load_iamconfig_bytes_with_metadata(get_policy_doc_path(name), mode)
            .await
            .map_err(|err| {
                if is_err_config_not_found(&err) {
                    Error::NoSuchPolicy
                } else {
                    err
                }
            })?;

        let mut info = PolicyDoc::try_from(data)?;

        if info.version == 0 {
            info.create_date = obj.mod_time;
            info.update_date = obj.mod_time;
        }

        Ok(info)
    }

    /// Parameterized core of [`Store::load_mapped_policy`].
    async fn load_mapped_policy_with(
        &self,
        name: &str,
        user_type: UserType,
        is_group: bool,
        m: &mut HashMap<String, MappedPolicy>,
        mode: LoadMode,
    ) -> Result<()> {
        let info = self.load_mapped_policy_internal(name, user_type, is_group, mode).await?;

        m.insert(name.to_owned(), info);

        Ok(())
    }

    async fn load_mapped_policy_internal(
        &self,
        name: &str,
        user_type: UserType,
        is_group: bool,
        mode: LoadMode,
    ) -> Result<MappedPolicy> {
        let info: MappedPolicy = self
            .load_iam_config_with(get_mapped_policy_path(name, user_type, is_group), mode)
            .await
            .map_err(|err| {
                if is_err_config_not_found(&err) {
                    Error::NoSuchPolicy
                } else {
                    err
                }
            })?;

        Ok(info)
    }

    async fn load_mapped_policy_concurrent(
        &self,
        names: &[String],
        user_type: UserType,
        is_group: bool,
        mode: LoadMode,
    ) -> Result<Vec<MappedPolicy>> {
        let mut futures = Vec::with_capacity(names.len());

        for name in names {
            let policy_name = name.trim_end_matches(".json");
            futures.push(async move {
                match self.load_mapped_policy_internal(policy_name, user_type, is_group, mode).await {
                    Ok(p) => Ok(p),
                    Err(err) => {
                        if !is_err_no_such_policy(&err) {
                            Err(Error::other(format!("load mapped policy failed: {err}")))
                        } else {
                            Ok(MappedPolicy::default())
                        }
                    }
                }
            });
        }

        let results = join_all(futures).await;

        let mut policies = Vec::with_capacity(results.len());
        for r in results {
            match r {
                Ok(p) => policies.push(p),
                Err(e) => return Err(e),
            }
        }

        Ok(policies)
    }

    /// Checks if the underlying ECStore is ready for metadata operations.
    /// This prevents silent failures during the storage boot-up phase.
    ///
    /// Performs a lightweight probe by attempting to read a known configuration object.
    /// If the object is not found, it indicates the storage metadata is not ready.
    /// The upper-level caller should handle retries if needed.
    async fn check_storage_readiness(&self) -> Result<()> {
        // Probe path for a fixed object under the IAM root prefix.
        // If it doesn't exist, the system bucket or metadata is not ready.
        let probe_path = format!("{}/format.json", *IAM_CONFIG_PREFIX);

        match read_iam_config_no_lock(self.object_api.clone(), &probe_path).await {
            Ok(_) => Ok(()),
            Err(IamStorageError::ConfigNotFound) => Err(Error::other(format!(
                "Storage metadata not ready: probe object '{}' not found (expected IAM config to be initialized)",
                probe_path
            ))),
            Err(e) => Err(e.into()),
        }
    }
}

#[async_trait::async_trait]
impl Store for ObjectStore {
    fn has_watcher(&self) -> bool {
        false
    }
    async fn load_iam_config<Item: DeserializeOwned>(&self, path: impl AsRef<str> + Send) -> Result<Item> {
        self.load_iam_config_with(path, LoadMode::Locked).await
    }
    /// Saves IAM configuration with a retry mechanism on failure.
    ///
    /// Attempts to save the IAM configuration up to 5 times if the storage layer is not ready,
    /// using exponential backoff between attempts (starting at 200ms, doubling each retry).
    ///
    /// # Arguments
    ///
    /// * `item` - The IAM configuration item to save, must implement `Serialize` and `Send`.
    /// * `path` - The path where the configuration will be saved.
    ///
    /// # Returns
    ///
    /// * `Result<()>` - `Ok(())` on success, or an `Error` if all attempts fail.
    #[tracing::instrument(skip(self, item, path))]
    async fn save_iam_config<Item: Serialize + Send>(&self, item: Item, path: impl AsRef<str> + Send) -> Result<()> {
        let mut data = serde_json::to_vec(&item)?;
        data = Self::prepare_data_for_storage(&data)?;

        let mut attempts = 0;
        let max_attempts = 5;
        let path_ref = path.as_ref();

        loop {
            match save_iam_config(self.object_api.clone(), path_ref, data.clone()).await {
                Ok(_) => {
                    debug!("Successfully saved IAM config to {}", path_ref);
                    return Ok(());
                }
                Err(e) if attempts < max_attempts => {
                    attempts += 1;
                    // Exponential backoff: 200ms, 400ms, 800ms...
                    let wait_ms = 200 * (1 << attempts);
                    warn!(
                        "Storage layer not ready for IAM write (attempt {}/{}). Retrying in {}ms. Path: {}, Error: {:?}",
                        attempts, max_attempts, wait_ms, path_ref, e
                    );
                    tokio::time::sleep(std::time::Duration::from_millis(wait_ms)).await;
                }
                Err(e) => {
                    error!(path = %path_ref, error = ?e, "IAM config save failed");
                    return Err(e.into());
                }
            }
        }
    }
    async fn delete_iam_config(&self, path: impl AsRef<str> + Send) -> Result<()> {
        delete_iam_config(self.object_api.clone(), path.as_ref()).await?;
        Ok(())
    }

    async fn save_user_identity(
        &self,
        name: &str,
        user_type: UserType,
        user_identity: UserIdentity,
        _ttl: Option<usize>,
    ) -> Result<()> {
        // Pre-check storage health
        self.check_storage_readiness().await?;

        let path = get_user_identity_path(name, user_type);
        debug!("Saving IAM identity to path: {}", path);

        self.save_iam_config(user_identity, path).await.map_err(|e| {
            error!(name, error = ?e, "IAM identity save failed");
            e
        })
    }
    async fn delete_user_identity(&self, name: &str, user_type: UserType) -> Result<()> {
        self.delete_iam_config(get_user_identity_path(name, user_type))
            .await
            .map_err(|err| map_delete_user_identity_error(name, err))?;
        Ok(())
    }
    async fn load_user_identity(&self, name: &str, user_type: UserType) -> Result<UserIdentity> {
        self.load_user_identity_with(name, user_type, LoadMode::Locked).await
    }
    async fn load_user(&self, name: &str, user_type: UserType, m: &mut HashMap<String, UserIdentity>) -> Result<()> {
        self.load_user_with(name, user_type, m, LoadMode::Locked).await
    }
    async fn load_users(&self, user_type: UserType, m: &mut HashMap<String, UserIdentity>) -> Result<()> {
        let base_prefix = match user_type {
            UserType::Reg => IAM_CONFIG_USERS_PREFIX.as_str(),
            UserType::Svc => IAM_CONFIG_SERVICE_ACCOUNTS_PREFIX.as_str(),
            UserType::Sts => IAM_CONFIG_STS_PREFIX.as_str(),
            UserType::None => "",
        };

        let ctx = CancellationToken::new();
        let (tx, mut rx) = mpsc::channel::<StringOrErr>(100);

        self.list_iam_config_items(base_prefix, ctx.clone(), tx).await;

        while let Some(v) = rx.recv().await {
            if let Some(err) = v.err {
                warn!(error = ?err, "IAM config item listing failed");
                let _ = ctx.cancel();

                return Err(err);
            }

            if let Some(item) = v.item {
                let name = rustfs_utils::path::dir(&item);
                self.load_user(&name, user_type, m).await?;
            }
        }
        let _ = ctx.cancel();
        Ok(())
    }
    async fn load_secret_key(&self, name: &str, user_type: UserType) -> Result<String> {
        let u: UserIdentity = self
            .load_iam_config(get_user_identity_path(name, user_type))
            .await
            .map_err(|err| {
                if is_err_config_not_found(&err) {
                    Error::NoSuchUser(name.to_owned())
                } else {
                    err
                }
            })?;

        Ok(u.credentials.secret_key)
    }

    async fn save_group_info(&self, name: &str, item: GroupInfo) -> Result<()> {
        self.save_iam_config(item, get_group_info_path(name)).await
    }
    async fn delete_group_info(&self, name: &str) -> Result<()> {
        self.delete_iam_config(get_group_info_path(name)).await.map_err(|err| {
            if is_err_config_not_found(&err) {
                Error::NoSuchGroup(name.to_string())
            } else {
                err
            }
        })?;
        Ok(())
    }
    async fn load_group(&self, name: &str, m: &mut HashMap<String, GroupInfo>) -> Result<()> {
        self.load_group_with(name, m, LoadMode::Locked).await
    }
    async fn load_groups(&self, m: &mut HashMap<String, GroupInfo>) -> Result<()> {
        let ctx = CancellationToken::new();
        let (tx, mut rx) = mpsc::channel::<StringOrErr>(100);

        self.list_iam_config_items(&IAM_CONFIG_GROUPS_PREFIX, ctx.clone(), tx).await;

        while let Some(v) = rx.recv().await {
            if let Some(err) = v.err {
                warn!(error = ?err, "IAM config item listing failed");
                let _ = ctx.cancel();

                return Err(err);
            }

            if let Some(item) = v.item {
                let name = rustfs_utils::path::dir(&item);
                if let Err(err) = self.load_group(&name, m).await
                    && !is_err_no_such_group(&err)
                {
                    return Err(err);
                }
            }
        }
        let _ = ctx.cancel();
        Ok(())
    }

    async fn save_policy_doc(&self, name: &str, policy_doc: PolicyDoc) -> Result<()> {
        self.save_iam_config(policy_doc, get_policy_doc_path(name)).await
    }
    async fn delete_policy_doc(&self, name: &str) -> Result<()> {
        self.delete_iam_config(get_policy_doc_path(name)).await.map_err(|err| {
            if is_err_config_not_found(&err) {
                Error::NoSuchPolicy
            } else {
                err
            }
        })?;
        Ok(())
    }
    async fn load_policy(&self, name: &str) -> Result<PolicyDoc> {
        self.load_policy_with(name, LoadMode::Locked).await
    }

    async fn load_policy_doc(&self, name: &str, m: &mut HashMap<String, PolicyDoc>) -> Result<()> {
        let info = self.load_policy(name).await?;
        m.insert(name.to_owned(), info);

        Ok(())
    }
    async fn load_policy_docs(&self, m: &mut HashMap<String, PolicyDoc>) -> Result<()> {
        let ctx = CancellationToken::new();
        let (tx, mut rx) = mpsc::channel::<StringOrErr>(100);

        self.list_iam_config_items(&IAM_CONFIG_POLICIES_PREFIX, ctx.clone(), tx).await;

        while let Some(v) = rx.recv().await {
            if let Some(err) = v.err {
                warn!(error = ?err, "IAM config item listing failed");
                let _ = ctx.cancel();

                return Err(err);
            }

            if let Some(item) = v.item {
                let name = rustfs_utils::path::dir(&item);
                self.load_policy_doc(&name, m).await?;
            }
        }
        let _ = ctx.cancel();
        Ok(())
    }

    async fn save_mapped_policy(
        &self,
        name: &str,
        user_type: UserType,
        is_group: bool,
        mapped_policy: MappedPolicy,
        _ttl: Option<usize>,
    ) -> Result<()> {
        self.save_iam_config(mapped_policy, get_mapped_policy_path(name, user_type, is_group))
            .await
    }
    async fn delete_mapped_policy(&self, name: &str, user_type: UserType, is_group: bool) -> Result<()> {
        self.delete_iam_config(get_mapped_policy_path(name, user_type, is_group))
            .await
            .map_err(|err| {
                if is_err_config_not_found(&err) {
                    Error::NoSuchPolicy
                } else {
                    err
                }
            })?;
        Ok(())
    }
    async fn load_mapped_policy(
        &self,
        name: &str,
        user_type: UserType,
        is_group: bool,
        m: &mut HashMap<String, MappedPolicy>,
    ) -> Result<()> {
        self.load_mapped_policy_with(name, user_type, is_group, m, LoadMode::Locked)
            .await
    }
    async fn load_mapped_policies(
        &self,
        user_type: UserType,
        is_group: bool,
        m: &mut HashMap<String, MappedPolicy>,
    ) -> Result<()> {
        let base_path = {
            if is_group {
                IAM_CONFIG_POLICY_DB_GROUPS_PREFIX.as_str()
            } else {
                match user_type {
                    UserType::Svc => IAM_CONFIG_POLICY_DB_SERVICE_ACCOUNTS_PREFIX.as_str(),
                    UserType::Sts => IAM_CONFIG_POLICY_DB_STS_USERS_PREFIX.as_str(),
                    _ => IAM_CONFIG_POLICY_DB_USERS_PREFIX.as_str(),
                }
            }
        };
        let ctx = CancellationToken::new();
        let (tx, mut rx) = mpsc::channel::<StringOrErr>(100);

        self.list_iam_config_items(base_path, ctx.clone(), tx).await;

        while let Some(v) = rx.recv().await {
            if let Some(err) = v.err {
                warn!(error = ?err, "IAM config item listing failed");
                let _ = ctx.cancel();

                return Err(err);
            }

            if let Some(item) = v.item {
                let name = item.trim_end_matches(".json");
                self.load_mapped_policy(name, user_type, is_group, m).await?;
            }
        }
        let _ = ctx.cancel(); // TODO: check if this is needed
        Ok(())
    }

    // Notification-path cache refreshes read lock-free (see the trait docs):
    // they are best-effort and stale-tolerant, so they must not fail on the
    // node-counted lock quorum. Deletions triggered by these refreshes keep
    // their locked write semantics.
    async fn load_user_no_lock(&self, name: &str, user_type: UserType, m: &mut HashMap<String, UserIdentity>) -> Result<()> {
        self.load_user_with(name, user_type, m, LoadMode::BootstrapNoLock).await
    }
    async fn load_group_no_lock(&self, name: &str, m: &mut HashMap<String, GroupInfo>) -> Result<()> {
        self.load_group_with(name, m, LoadMode::BootstrapNoLock).await
    }
    async fn load_policy_doc_no_lock(&self, name: &str, m: &mut HashMap<String, PolicyDoc>) -> Result<()> {
        let info = self.load_policy_with(name, LoadMode::BootstrapNoLock).await?;
        m.insert(name.to_owned(), info);

        Ok(())
    }
    async fn load_mapped_policy_no_lock(
        &self,
        name: &str,
        user_type: UserType,
        is_group: bool,
        m: &mut HashMap<String, MappedPolicy>,
    ) -> Result<()> {
        self.load_mapped_policy_with(name, user_type, is_group, m, LoadMode::BootstrapNoLock)
            .await
    }

    async fn load_all(&self, cache: &Cache) -> Result<()> {
        // Bulk snapshot load: must stay free of namespace locks so IAM
        // bootstrap only depends on the storage read quorum, never on the
        // node-counted lock quorum (rustfs#4304; startup contract rustfs#4056).
        const LOAD_ALL_MODE: LoadMode = LoadMode::BootstrapNoLock;

        let cache_snapshot = cache.snapshot();
        let listed_config_items = self.list_all_iamconfig_items().await?;

        let mut policy_docs_cache = CacheEntity::new(get_default_policies());

        if let Some(policies_list) = listed_config_items.get(POLICIES_LIST_KEY) {
            // Load in fixed-size chunks so each policy is fetched exactly once.
            // The previous split_off loop re-passed the full remaining list to
            // load_policy_doc_concurrent every iteration — an O(n^2) redundant
            // load (backlog#806).
            for chunk in policies_list.chunks(32) {
                let policy_docs = self.load_policy_doc_concurrent(chunk, LOAD_ALL_MODE).await?;

                for (idx, p) in policy_docs.into_iter().enumerate() {
                    if p.policy.version.is_empty() {
                        continue;
                    }

                    let policy_name = rustfs_utils::path::dir(&chunk[idx]);
                    debug!(policy = %policy_name, "IAM policy loaded");
                    policy_docs_cache.insert(policy_name, p);
                }
            }
        }

        let mut user_items_cache = CacheEntity::default();

        // users
        if let Some(item_name_list) = listed_config_items.get(USERS_LIST_KEY) {
            // Load in fixed-size chunks so each user is fetched exactly once
            // (backlog#806 — the split_off loop was O(n^2)).
            for chunk in item_name_list.chunks(32) {
                let items = self.load_user_concurrent(chunk, UserType::Reg, LOAD_ALL_MODE).await?;

                for (idx, p) in items.into_iter().enumerate() {
                    if p.credentials.access_key.is_empty() {
                        continue;
                    }

                    let name = rustfs_utils::path::dir(&chunk[idx]);
                    debug!(user = %name, "IAM regular user loaded");
                    user_items_cache.insert(name, p);
                }
            }
        }

        // groups
        let mut groups_cache = None;
        if let Some(item_name_list) = listed_config_items.get(GROUPS_LIST_KEY) {
            let mut items_cache = CacheEntity::default();

            for item in item_name_list.iter() {
                let name = rustfs_utils::path::dir(item);
                debug!(group = %name, "IAM group loaded");
                if let Err(err) = self.load_group_with(&name, &mut items_cache, LOAD_ALL_MODE).await {
                    return Err(Error::other(format!("load group failed: {err}")));
                };
            }

            groups_cache = Some(items_cache);
        }

        // user policies
        let mut user_policies_cache = None;
        if let Some(item_name_list) = listed_config_items.get(POLICY_DB_USERS_LIST_KEY) {
            let mut items_cache = CacheEntity::default();

            // Load in fixed-size chunks so each mapping is fetched exactly once
            // (backlog#806 — the split_off loop was O(n^2)).
            for chunk in item_name_list.chunks(32) {
                let items = self
                    .load_mapped_policy_concurrent(chunk, UserType::Reg, false, LOAD_ALL_MODE)
                    .await?;

                for (idx, p) in items.into_iter().enumerate() {
                    if p.policies.is_empty() {
                        continue;
                    }

                    let name = chunk[idx].trim_end_matches(".json").to_owned();
                    debug!(user = %name, "IAM user policy loaded");
                    items_cache.insert(name, p);
                }
            }

            user_policies_cache = Some(items_cache);
        }

        // group policy
        let mut group_policies_cache = None;
        if let Some(item_name_list) = listed_config_items.get(POLICY_DB_GROUPS_LIST_KEY) {
            let mut items_cache = CacheEntity::default();

            for item in item_name_list.iter() {
                let name = item.trim_end_matches(".json");

                debug!(group = %name, "IAM group policy loaded");
                if let Err(err) = self
                    .load_mapped_policy_with(name, UserType::Reg, true, &mut items_cache, LOAD_ALL_MODE)
                    .await
                    && !is_err_no_such_policy(&err)
                {
                    return Err(Error::other(format!("load group policy failed: {err}")));
                };
            }

            group_policies_cache = Some(items_cache);
        }

        let mut sts_policies_cache = CacheEntity::default();

        // svc users
        if let Some(item_name_list) = listed_config_items.get(SVC_ACC_LIST_KEY) {
            let mut items_cache = HashMap::default();

            for item in item_name_list.iter() {
                let name = rustfs_utils::path::dir(item);
                debug!(user = %name, "IAM service user loaded");
                if let Err(err) = self
                    .load_user_with(&name, UserType::Svc, &mut items_cache, LOAD_ALL_MODE)
                    .await
                    && !is_err_no_such_user(&err)
                {
                    return Err(Error::other(format!("load svc user failed: {err}")));
                };
            }

            for v in items_cache.values() {
                let parent = v.credentials.parent_user.clone();
                if !user_items_cache.contains_key(&parent) {
                    debug!(user = %parent, "IAM STS parent policy loaded");
                    if let Err(err) = self
                        .load_mapped_policy_with(&parent, UserType::Sts, false, &mut sts_policies_cache, LOAD_ALL_MODE)
                        .await
                        && !is_err_no_such_policy(&err)
                    {
                        return Err(Error::other(format!("load_mapped_policy failed: {err}")));
                    }
                }
            }

            // Merge items_cache to user_items_cache
            user_items_cache.extend(items_cache);
        }

        let mut sts_items_cache = CacheEntity::default();
        // sts users
        if let Some(item_name_list) = listed_config_items.get(STS_LIST_KEY) {
            for item in item_name_list.iter() {
                debug!(path = %item, "IAM STS user path discovered");

                let name = rustfs_utils::path::dir(item);
                debug!(user = %name, "IAM STS user loaded");
                if let Err(err) = self
                    .load_user_with(&name, UserType::Sts, &mut sts_items_cache, LOAD_ALL_MODE)
                    .await
                {
                    debug!(user = %name, error = %err, "IAM STS user load failed");
                };
            }
        }

        // sts user policy
        if let Some(item_name_list) = listed_config_items.get(POLICY_DB_STS_USERS_LIST_KEY) {
            for item in item_name_list.iter() {
                let name = item.trim_end_matches(".json");
                debug!(user = %name, "IAM STS user policy loaded");
                if let Err(err) = self
                    .load_mapped_policy_with(name, UserType::Sts, false, &mut sts_policies_cache, LOAD_ALL_MODE)
                    .await
                {
                    debug!(user = %name, error = %err, "IAM STS user policy load failed");
                };
            }
        }

        cache.with_write_lock(|cache| {
            if cache.matches_snapshot(&cache_snapshot) {
                cache.replace_policy_docs(policy_docs_cache);
                if let Some(groups_cache) = groups_cache {
                    cache.replace_groups(groups_cache);
                }
                if let Some(user_policies_cache) = user_policies_cache {
                    cache.replace_user_policies(user_policies_cache);
                }
                if let Some(group_policies_cache) = group_policies_cache {
                    cache.replace_group_policies(group_policies_cache);
                }
                cache.replace_users(user_items_cache);
                cache.replace_sts_accounts(sts_items_cache);
                cache.replace_sts_policies(sts_policies_cache);
                cache.build_user_group_memberships();
            } else {
                warn!("IAM full reload cache commit skipped due to concurrent cache changes");
            }
        });

        Ok(())
    }
}

fn map_delete_user_identity_error(name: &str, err: Error) -> Error {
    if is_err_config_not_found(&err) {
        Error::NoSuchUser(name.to_owned())
    } else {
        err
    }
}

#[cfg(test)]
mod tests {
    use super::{DecryptSource, LoadMode, ObjectStore, map_delete_user_identity_error};
    use crate::error::Error;
    use crate::keyring;
    use rustfs_credentials::{Credentials, init_global_action_credentials};
    use serial_test::serial;
    use temp_env::with_vars;

    /// rustfs#4304 / startup contract rustfs#4056: the bulk snapshot load
    /// (`load_all`) must never depend on the node-counted namespace lock
    /// quorum, so its reads have to carry `no_lock = true` down to the
    /// storage layer.
    #[test]
    fn test_bootstrap_load_mode_bypasses_namespace_locks() {
        assert!(LoadMode::BootstrapNoLock.no_lock());
        assert!(LoadMode::BootstrapNoLock.read_opts().no_lock);
    }

    /// On-line single-object loads keep the locked read semantics.
    #[test]
    fn test_locked_load_mode_keeps_namespace_locks() {
        assert!(!LoadMode::Locked.no_lock());
        assert!(!LoadMode::Locked.read_opts().no_lock);
    }

    #[test]
    fn missing_user_identity_delete_maps_to_no_such_user() {
        let err = map_delete_user_identity_error("missing-sts", Error::ConfigNotFound);
        assert!(matches!(err, Error::NoSuchUser(name) if name == "missing-sts"));
    }

    fn test_cred() -> Credentials {
        if let Some(cred) = crate::root_credentials::credentials() {
            return cred;
        }
        let _ = init_global_action_credentials(Some("COMPATTESTAK".to_string()), Some("COMPATTESTSK1234567890".to_string()));
        crate::root_credentials::credentials_or_default()
    }

    #[test]
    fn test_decrypt_data_accepts_plaintext_json() {
        let raw = br#"{"Version":1,"policy":"readonly"}"#;
        let out = ObjectStore::decrypt_data_with_source(raw).expect("plaintext json should pass through");
        assert_eq!(out.plain, raw);
        assert_eq!(out.source, DecryptSource::Plaintext);
    }

    #[test]
    fn test_decrypt_data_accepts_rustfs_legacy_secret_encryption() {
        let cred = test_cred();
        let plain = br#"{"accessKey":"ak","secretKey":"sk"}"#;
        let encrypted = rustfs_crypto::encrypt_data(cred.secret_key.as_bytes(), plain).expect("encrypt with rustfs secret");
        let out = ObjectStore::decrypt_data_with_source(&encrypted).expect("decrypt rustfs legacy encryption");
        assert_eq!(out.plain, plain);
    }

    #[test]
    fn test_decrypt_data_accepts_access_secret_encryption() {
        let cred = test_cred();
        let plain = br#"{"Version":1,"updatedAt":"2025-03-07T12:00:00Z"}"#;
        let root_cred = format!("{}:{}", cred.access_key, cred.secret_key);
        let encrypted = rustfs_crypto::encrypt_stream_io(root_cred.as_bytes(), plain).expect("encrypt with stream_io");
        let out = ObjectStore::decrypt_data_with_source(&encrypted).expect("decrypt stream_io");
        assert_eq!(out.plain, plain);
    }

    // Public helper used by the MinIO -> RustFS migration path (see
    // ecstore::bucket::migration::try_migrate_iam_config).
    #[test]
    fn test_try_decrypt_iam_blob_passes_plaintext_through() {
        let raw = br#"{"Version":1,"policy":"readonly"}"#;
        let out = super::try_decrypt_iam_blob(raw).expect("plaintext json should pass through");
        assert_eq!(out, raw);
    }

    #[test]
    fn test_try_decrypt_iam_blob_decrypts_legacy_secret_encryption() {
        // Mirrors a MinIO IAM identity file encrypted at rest with the root secret key.
        let cred = test_cred();
        let plain = br#"{"accessKey":"ak","secretKey":"sk"}"#;
        let encrypted = rustfs_crypto::encrypt_data(cred.secret_key.as_bytes(), plain).expect("encrypt with rustfs secret");
        let out = super::try_decrypt_iam_blob(&encrypted).expect("legacy-encrypted blob should decrypt");
        assert_eq!(out, plain);
    }

    #[test]
    fn test_try_decrypt_iam_blob_returns_none_on_undecryptable() {
        // A blob that is neither plaintext JSON nor decryptable with any known key
        // must return None so the migration falls back to the raw bytes (skip).
        let garbage = [0x00u8, 0x01, 0x02, 0x03, 0x04, 0x05];
        assert!(super::try_decrypt_iam_blob(&garbage).is_none());
    }

    #[test]
    fn test_decrypt_data_corrupt_stream_io_fails() {
        let cred = test_cred();
        let plain = br#"{"Version":1}"#;
        let root_cred = format!("{}:{}", cred.access_key, cred.secret_key);
        let mut encrypted = rustfs_crypto::encrypt_stream_io(root_cred.as_bytes(), plain).expect("encrypt with stream_io");
        if encrypted.len() > 50 {
            encrypted[50] ^= 0xFF; // corrupt one byte
        }
        let result = ObjectStore::decrypt_data_with_source(&encrypted);
        assert!(result.is_err(), "corrupt stream_io data should fail decrypt");
    }

    #[test]
    fn test_decrypt_data_short_data_fails() {
        let short = &[0x00u8; 40]; // less than 41-byte stream_io header, not valid JSON
        let result = ObjectStore::decrypt_data_with_source(short);
        assert!(result.is_err(), "short non-JSON data should fail decrypt");
    }

    #[test]
    #[serial]
    fn test_prepare_data_defaults_to_plaintext_without_iam_master_key() {
        let plain = br#"{"Version":1,"policy":"readonly"}"#;

        with_vars(
            [
                (keyring::ENV_IAM_MASTER_KEY, None::<&str>),
                (keyring::ENV_IAM_MASTER_KEY_OLD_KEYS, None::<&str>),
            ],
            || {
                let stored = ObjectStore::prepare_data_for_storage_for_test(plain).expect("store bytes should build");
                assert_eq!(stored, plain);

                let decrypted = ObjectStore::decrypt_data_with_source(&stored).expect("plaintext should load");
                assert_eq!(plain, decrypted.plain.as_slice());
                assert_eq!(decrypted.source, DecryptSource::Plaintext);
            },
        );
    }

    #[test]
    #[serial]
    fn test_prepare_data_uses_iam_master_key_roundtrip() {
        let _ = test_cred();
        let plain = br#"{"Version":1,"policy":"master-key"}"#;
        let master_key = "iam-master-key-roundtrip";

        with_vars(
            [
                (keyring::ENV_IAM_MASTER_KEY, Some(master_key)),
                (keyring::ENV_IAM_MASTER_KEY_OLD_KEYS, None),
            ],
            || {
                let encrypted = ObjectStore::prepare_data_for_storage_for_test(plain).expect("encrypt with iam master key");

                let by_master =
                    rustfs_crypto::decrypt_stream_io(master_key.as_bytes(), &encrypted).expect("decrypt via master key");
                assert_eq!(by_master, plain);

                let by_object_store = ObjectStore::decrypt_data_with_source(&encrypted).expect("decrypt via object store");
                assert_eq!(by_object_store.plain, plain);
                assert_eq!(by_object_store.source, DecryptSource::CurrentMasterKey);
            },
        );
    }

    #[test]
    #[serial]
    fn test_decrypt_data_uses_iam_old_keys_fallback_during_rotation() {
        let _ = test_cred();
        let plain = br#"{"Version":1,"policy":"rotation-fallback"}"#;
        let current_key = "iam-master-key-new";
        let old_key_used_for_data = "iam-master-key-old-2";
        let old_keys = format!("iam-master-key-old-1,{old_key_used_for_data}");

        let encrypted = rustfs_crypto::encrypt_stream_io(old_key_used_for_data.as_bytes(), plain)
            .expect("encrypt with old iam key for rotation simulation");

        assert!(
            rustfs_crypto::decrypt_stream_io(current_key.as_bytes(), &encrypted).is_err(),
            "current master key should not decrypt old-key encrypted data in this test"
        );

        with_vars(
            [
                (keyring::ENV_IAM_MASTER_KEY, Some(current_key)),
                (keyring::ENV_IAM_MASTER_KEY_OLD_KEYS, Some(old_keys.as_str())),
            ],
            || {
                let decrypted = ObjectStore::decrypt_data_with_source(&encrypted).expect("decrypt via old-key fallback");
                assert_eq!(decrypted.plain, plain);
                assert_eq!(decrypted.source, DecryptSource::OldMasterKey);
            },
        );
    }
}
