Files
rustfs/crates/keystone/src/client.rs
T
Zhengchao An a9691b6797 chore: adjudicate 19 bare dead_code allows across six leaf crates (#6161)
backlog#1823 step 10, batch 1 of the repo-wide item-allow sweep. 227 bare #[allow(dead_code)] remain across 83 files; this takes the 19 in utils, notify, checksums, policy, keystone and trusted-proxies, which are small enough to verify end to end.

Removing all 19 first, before writing any reason, matters: 8 of them suppress nothing. Every allow in utils, one in policy and three in notify sit on items that are publicly reachable, so dead_code never applied to them — the same shape as the swift module and kms's dek.rs. Writing a reason onto a no-op allow would dress noise up as considered judgement, so those are simply deleted.

Three items are genuinely dead and go with their allows: notify's new_target_id_set, the AWS metadata fetcher's get_metadata_token, and policy's empty `pub struct Value;`, none of which is referenced anywhere in the tree.

The remaining eight keep an allow, now saying why the item survives rather than who calls it. Two are exercised only by their own crate's tests (checksums' MD5_HEADER_NAME, policy's is_match_as_pattern_prefix). Four are fields written but never read back: keystone's verify_ssl, parsed from config after the reqwest client is already built; keystone's client handle, which keeps the Keystone client alive for the mapper's lifetime; the AWS IMDS endpoint, kept beside the client while requests build their own URLs; and notify's rules_map, whose own comment retains it for snapshot-time judgements no code performs.

checksums' Md5 needed the most care. Crc32, Sha256 and seven others each have an arm in ChecksumAlgorithm::into_impl, and Md5 has none, which reads like a missing algorithm. It is not: ChecksumAlgorithm has no Md5 variant at all. S3 carries Content-MD5 as its own header, separate from the x-amz-checksum-* family, and this impl exists so both paths share the Checksum trait. The reason records that, so the next reader does not re-derive it.

One measurement note for anyone continuing this sweep: cargo does not re-emit warnings for cached compilations, so a per-crate loop of `cargo check -p <crate>` under-reports. checksums showed zero that way while actually carrying three. Touch the sources and check the crates in one invocation, then attribute by path.

Verification: the six crates are warning-free under cargo check --tests; clippy --lib --tests -D warnings clean; cargo nextest run 1096 passed; make pre-commit exit 0.

Ref rustfs/backlog#1823 (step 10).
2026-08-17 11:34:47 +08:00

430 lines
14 KiB
Rust

// 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 crate::{EC2Credential, KeystoneError, KeystoneToken, KeystoneVersion, Result};
use reqwest::{Client, StatusCode};
use serde_json::json;
use std::sync::Arc;
use time::OffsetDateTime;
use tokio::sync::RwLock;
use tracing::{debug, error, info, warn};
/// Keystone client for API interactions
#[derive(Clone)]
pub struct KeystoneClient {
client: Client,
auth_url: String,
version: KeystoneVersion,
admin_token: Arc<RwLock<Option<AdminToken>>>,
admin_user: Option<String>,
admin_password: Option<String>,
admin_project: Option<String>,
admin_domain: String,
#[allow(
dead_code,
reason = "TLS verification flag parsed from config; the reqwest client is built before it is consulted, so nothing reads it back (backlog#1823)"
)]
verify_ssl: bool,
/// Request timeout applied to the underlying HTTP client.
timeout: std::time::Duration,
}
#[derive(Clone)]
struct AdminToken {
token: String,
expires_at: OffsetDateTime,
}
impl AdminToken {
fn is_expired(&self) -> bool {
OffsetDateTime::now_utc() >= self.expires_at
}
}
impl KeystoneClient {
/// Create new Keystone client
#[allow(clippy::too_many_arguments)]
pub fn new(
auth_url: String,
version: KeystoneVersion,
admin_user: Option<String>,
admin_password: Option<String>,
admin_project: Option<String>,
admin_domain: String,
verify_ssl: bool,
timeout: std::time::Duration,
) -> Self {
if !verify_ssl {
warn!(
"Keystone client for '{}' is configured to skip TLS certificate verification. This permits MITM attacks and should not be used in production.",
auth_url
);
}
let client = Client::builder()
.danger_accept_invalid_certs(!verify_ssl)
.timeout(timeout)
.build()
.unwrap();
Self {
client,
auth_url,
version,
admin_token: Arc::new(RwLock::new(None)),
admin_user,
admin_password,
admin_project,
admin_domain,
verify_ssl,
timeout,
}
}
/// Request timeout applied to the underlying HTTP client.
pub fn timeout(&self) -> std::time::Duration {
self.timeout
}
/// Validate a Keystone token
#[hotpath::measure]
pub async fn validate_token(&self, token: &str) -> Result<KeystoneToken> {
match self.version {
KeystoneVersion::V3 => self.validate_token_v3(token).await,
KeystoneVersion::V2_0 => self.validate_token_v2(token).await,
}
}
/// Validate token using Keystone v3 API
async fn validate_token_v3(&self, token: &str) -> Result<KeystoneToken> {
let url = format!("{}/v3/auth/tokens", self.auth_url);
debug!("Validating token with Keystone v3: {}", url);
let response = self
.client
.get(&url)
.header("X-Auth-Token", token)
.header("X-Subject-Token", token)
.send()
.await
.map_err(|e| {
error!("Failed to send token validation request: {}", e);
KeystoneError::HttpError(e.to_string())
})?;
let status = response.status();
debug!("Token validation response status: {}", status);
if status == StatusCode::NOT_FOUND || status == StatusCode::UNAUTHORIZED {
return Err(KeystoneError::InvalidToken);
}
if !status.is_success() {
return Err(KeystoneError::AuthenticationFailed(format!(
"Token validation failed with status: {}",
status
)));
}
let body: serde_json::Value = response.json().await.map_err(|e| KeystoneError::ParseError(e.to_string()))?;
self.parse_token_v3(&body)
}
fn parse_token_v3(&self, body: &serde_json::Value) -> Result<KeystoneToken> {
let token_data = body
.get("token")
.ok_or_else(|| KeystoneError::ParseError("Missing token field".to_string()))?;
let user = token_data
.get("user")
.ok_or_else(|| KeystoneError::ParseError("Missing user field".to_string()))?;
let user_id = user
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| KeystoneError::ParseError("Missing user id".to_string()))?
.to_string();
let username = user.get("name").and_then(|v| v.as_str()).unwrap_or("unknown").to_string();
let project = token_data.get("project");
let (project_id, project_name) = if let Some(proj) = project {
(
proj.get("id").and_then(|v| v.as_str()).map(String::from),
proj.get("name").and_then(|v| v.as_str()).map(String::from),
)
} else {
(None, None)
};
let domain = user.get("domain");
let (domain_id, domain_name) = if let Some(dom) = domain {
(
dom.get("id").and_then(|v| v.as_str()).map(String::from),
dom.get("name").and_then(|v| v.as_str()).map(String::from),
)
} else {
(None, None)
};
let roles = token_data
.get("roles")
.and_then(|v| v.as_array())
.map(|roles| {
roles
.iter()
.filter_map(|r| r.get("name").and_then(|n| n.as_str()).map(String::from))
.collect()
})
.unwrap_or_default();
let expires_at = token_data
.get("expires_at")
.and_then(|v| v.as_str())
.and_then(|s| OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).ok())
.ok_or_else(|| KeystoneError::ParseError("Invalid expires_at".to_string()))?;
let issued_at = token_data
.get("issued_at")
.and_then(|v| v.as_str())
.and_then(|s| OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).ok())
.unwrap_or_else(OffsetDateTime::now_utc);
Ok(KeystoneToken {
token: String::new(),
user_id,
username,
project_id,
project_name,
domain_id,
domain_name,
roles,
expires_at,
issued_at,
})
}
async fn validate_token_v2(&self, _token: &str) -> Result<KeystoneToken> {
warn!("Keystone v2.0 support is deprecated");
Err(KeystoneError::UnsupportedVersion)
}
/// Validate EC2 credentials
///
/// This path is intentionally not implemented and fails closed. A correct
/// implementation must derive the identity (user, project, roles, expiry)
/// from the Keystone `/v3/ec2tokens` response, not from client-supplied
/// input. The previous implementation discarded the response body and
/// fabricated an identity by splitting the client access key on `:`, which
/// let a caller shape their own user_id/project_id. Until the response
/// schema is modeled against a live server, refuse to produce credentials.
pub async fn validate_ec2_credentials(
&self,
_access_key: &str,
_signature: &str,
_string_to_sign: &str,
) -> Result<EC2Credential> {
warn!("EC2/SigV4 credential authentication is not supported; refusing to fabricate identity from client input");
Err(KeystoneError::Ec2AuthUnsupported)
}
/// Get EC2 credentials for a user
#[hotpath::measure]
pub async fn get_ec2_credentials(&self, user_id: &str, project_id: Option<&str>) -> Result<Vec<EC2Credential>> {
let admin_token = self.get_admin_token().await?;
let url = if let Some(proj_id) = project_id {
format!("{}/v3/users/{}/credentials/OS-EC2?project_id={}", self.auth_url, user_id, proj_id)
} else {
format!("{}/v3/users/{}/credentials/OS-EC2", self.auth_url, user_id)
};
debug!("Fetching EC2 credentials for user: {}", user_id);
let response = self
.client
.get(&url)
.header("X-Auth-Token", admin_token)
.send()
.await
.map_err(|e| KeystoneError::HttpError(e.to_string()))?;
if !response.status().is_success() {
return Ok(vec![]);
}
let body: serde_json::Value = response.json().await.map_err(|e| KeystoneError::ParseError(e.to_string()))?;
let credentials = body
.get("credentials")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|cred| self.parse_ec2_credential(cred).ok()).collect())
.unwrap_or_default();
Ok(credentials)
}
fn parse_ec2_credential(&self, cred: &serde_json::Value) -> Result<EC2Credential> {
let access = cred
.get("access")
.and_then(|v| v.as_str())
.ok_or_else(|| KeystoneError::ParseError("Missing access key".to_string()))?
.to_string();
let secret = cred
.get("secret")
.and_then(|v| v.as_str())
.ok_or_else(|| KeystoneError::ParseError("Missing secret key".to_string()))?
.to_string();
let user_id = cred
.get("user_id")
.and_then(|v| v.as_str())
.ok_or_else(|| KeystoneError::ParseError("Missing user_id".to_string()))?
.to_string();
let project_id = cred.get("project_id").and_then(|v| v.as_str()).map(String::from);
let trust_id = cred.get("trust_id").and_then(|v| v.as_str()).map(String::from);
Ok(EC2Credential {
access,
secret,
user_id,
project_id,
trust_id,
})
}
/// Get admin token for privileged operations
async fn get_admin_token(&self) -> Result<String> {
// Check if we have a valid cached token
{
let guard = self.admin_token.read().await;
if let Some(token) = guard.as_ref()
&& !token.is_expired()
{
return Ok(token.token.clone());
}
}
// Need to authenticate as admin
let admin_user = self
.admin_user
.as_ref()
.ok_or_else(|| KeystoneError::ConfigError("Missing admin user".to_string()))?;
let admin_password = self
.admin_password
.as_ref()
.ok_or_else(|| KeystoneError::ConfigError("Missing admin password".to_string()))?;
let url = format!("{}/v3/auth/tokens", self.auth_url);
debug!("Authenticating as admin user: {}", admin_user);
let mut auth_payload = json!({
"auth": {
"identity": {
"methods": ["password"],
"password": {
"user": {
"name": admin_user,
"password": admin_password,
"domain": {"name": self.admin_domain}
}
}
}
}
});
if let Some(proj) = &self.admin_project {
auth_payload["auth"]["scope"] = json!({
"project": {
"name": proj,
"domain": {"name": self.admin_domain}
}
});
}
let response = self
.client
.post(&url)
.json(&auth_payload)
.send()
.await
.map_err(|e| KeystoneError::HttpError(e.to_string()))?;
if !response.status().is_success() {
return Err(KeystoneError::AuthenticationFailed("Admin authentication failed".to_string()));
}
let token = response
.headers()
.get("X-Subject-Token")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| KeystoneError::ParseError("Missing X-Subject-Token header".to_string()))?
.to_string();
// Parse expiration from response body
let body: serde_json::Value = response.json().await.map_err(|e| KeystoneError::ParseError(e.to_string()))?;
let expires_at = body
.get("token")
.and_then(|t| t.get("expires_at"))
.and_then(|v| v.as_str())
.and_then(|s| OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).ok())
.unwrap_or_else(|| OffsetDateTime::now_utc() + time::Duration::hours(1));
// Cache the token
let mut guard = self.admin_token.write().await;
*guard = Some(AdminToken {
token: token.clone(),
expires_at,
});
info!("Admin token obtained successfully");
Ok(token)
}
/// Clear cached admin token
pub async fn clear_admin_token(&self) {
let mut guard = self.admin_token.write().await;
*guard = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_creation() {
let client = KeystoneClient::new(
"http://keystone:5000".to_string(),
KeystoneVersion::V3,
Some("admin".to_string()),
Some("secret".to_string()),
Some("admin".to_string()),
"Default".to_string(),
true,
std::time::Duration::from_secs(30),
);
assert_eq!(client.auth_url, "http://keystone:5000");
assert_eq!(client.version, KeystoneVersion::V3);
}
}