Openstack Keystone integration - v1 keeps the same mechanism as (#1961)

Co-authored-by: loverustfs <hello@rustfs.com>
Co-authored-by: 安正超 <anzhengchao@gmail.com>
This commit is contained in:
Senol Colak
2026-02-27 15:23:35 +01:00
committed by GitHub
parent d17d2083d4
commit b69183aadf
20 changed files with 3732 additions and 0 deletions
+294
View File
@@ -0,0 +1,294 @@
// 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, KeystoneClient, KeystoneError, KeystoneToken, Result, TokenCache};
use rustfs_credentials::Credentials;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tracing::{debug, info};
/// Keystone authentication provider
///
/// This provider validates credentials against OpenStack Keystone
/// and maps Keystone identities to RustFS credentials.
pub struct KeystoneAuthProvider {
client: Arc<KeystoneClient>,
token_cache: TokenCache,
ec2_cache: TokenCache,
enable_cache: bool,
}
impl KeystoneAuthProvider {
/// Create new authentication provider
pub fn new(client: KeystoneClient, cache_size: u64, cache_ttl: Duration, enable_cache: bool) -> Self {
Self {
client: Arc::new(client),
token_cache: TokenCache::new(cache_size, cache_ttl),
ec2_cache: TokenCache::new(cache_size, cache_ttl),
enable_cache,
}
}
/// Disable caching (for testing)
pub fn without_cache(mut self) -> Self {
self.enable_cache = false;
self
}
/// Authenticate using Keystone token (X-Auth-Token header)
pub async fn authenticate_with_token(&self, token: &str) -> Result<Credentials> {
// Check cache first
if self.enable_cache
&& let Some(cached_token) = self.token_cache.get(token).await
&& !cached_token.is_expired()
{
debug!("Token cache hit: user_id={}", cached_token.user_id);
return Ok(self.keystone_token_to_credentials(&cached_token));
}
if self.enable_cache {
debug!("Cached token expired or not found, re-validating");
}
// Validate token with Keystone
let keystone_token = self.client.validate_token(token).await?;
// Check expiration
if keystone_token.is_expired() {
return Err(KeystoneError::TokenExpired);
}
// Cache token
if self.enable_cache {
self.token_cache
.insert(token.to_string(), Arc::new(keystone_token.clone()))
.await;
}
info!(
"Keystone authentication successful: user={}, project={:?}",
keystone_token.username, keystone_token.project_name
);
Ok(self.keystone_token_to_credentials(&keystone_token))
}
/// Authenticate using EC2 credentials (S3 API with AWS SigV4)
pub async fn authenticate_with_ec2(&self, access_key: &str, signature: &str, string_to_sign: &str) -> Result<Credentials> {
// Check cache
let cache_key = format!("{}:{}", access_key, signature);
if self.enable_cache
&& let Some(cached) = self.ec2_cache.get(&cache_key).await
&& !cached.is_expired()
{
debug!("EC2 credential cache hit: access_key={}", access_key);
return Ok(self.keystone_token_to_credentials(&cached));
}
// Validate EC2 credentials with Keystone
let ec2_cred = self
.client
.validate_ec2_credentials(access_key, signature, string_to_sign)
.await?;
// Convert to Keystone token (need to get full token info)
let keystone_token = self.ec2_to_keystone_token(&ec2_cred).await?;
// Cache
if self.enable_cache {
self.ec2_cache.insert(cache_key, Arc::new(keystone_token.clone())).await;
}
info!(
"EC2 credential authentication successful: user={}, access_key={}",
ec2_cred.user_id, access_key
);
Ok(self.keystone_token_to_credentials(&keystone_token))
}
/// Convert EC2 credential to Keystone token
async fn ec2_to_keystone_token(&self, ec2_cred: &EC2Credential) -> Result<KeystoneToken> {
// In a real implementation, you'd need to:
// 1. Use admin credentials to get user/project details
// 2. Or maintain a mapping table
// For simplicity, construct a minimal token
Ok(KeystoneToken {
token: String::new(),
user_id: ec2_cred.user_id.clone(),
username: ec2_cred.user_id.clone(), // Use user_id as username
project_id: ec2_cred.project_id.clone(),
project_name: ec2_cred.project_id.clone(),
domain_id: None,
domain_name: None,
roles: vec!["Member".to_string()], // Default role
expires_at: time::OffsetDateTime::now_utc() + time::Duration::hours(24),
issued_at: time::OffsetDateTime::now_utc(),
})
}
/// Convert Keystone token to RustFS credentials
fn keystone_token_to_credentials(&self, token: &KeystoneToken) -> Credentials {
use serde_json::json;
// Map Keystone roles to RustFS groups
let groups = Some(token.roles.clone());
// Add Keystone-specific claims
let mut claims = HashMap::new();
claims.insert("keystone_user_id".to_string(), json!(token.user_id));
claims.insert("keystone_username".to_string(), json!(token.username));
if let Some(ref proj_id) = token.project_id {
claims.insert("keystone_project_id".to_string(), json!(proj_id));
}
if let Some(ref proj_name) = token.project_name {
claims.insert("keystone_project_name".to_string(), json!(proj_name));
}
if let Some(ref dom_id) = token.domain_id {
claims.insert("keystone_domain_id".to_string(), json!(dom_id));
}
if let Some(ref dom_name) = token.domain_name {
claims.insert("keystone_domain_name".to_string(), json!(dom_name));
}
claims.insert("keystone_roles".to_string(), json!(token.roles));
claims.insert("auth_source".to_string(), json!("keystone"));
Credentials {
access_key: format!("keystone:{}", token.user_id),
secret_key: String::new(), // Not used for token auth
session_token: token.token.clone(),
expiration: Some(token.expires_at),
status: "active".to_string(),
parent_user: token.username.clone(),
groups,
claims: Some(claims),
name: Some(token.username.clone()),
description: Some(format!("Keystone user: {}", token.username)),
}
}
/// Invalidate cached token
pub async fn invalidate_token(&self, token: &str) {
self.token_cache.invalidate(token).await;
}
/// Clear all caches
pub async fn clear_caches(&self) {
self.token_cache.clear().await;
self.ec2_cache.clear().await;
}
/// Check if user has admin privileges
pub fn is_admin(&self, cred: &Credentials) -> bool {
cred.groups
.as_ref()
.map(|groups| {
groups
.iter()
.any(|g| g.eq_ignore_ascii_case("admin") || g.eq_ignore_ascii_case("reseller_admin"))
})
.unwrap_or(false)
}
/// Extract project ID from credentials
pub fn get_project_id(&self, cred: &Credentials) -> Option<String> {
cred.claims
.as_ref()
.and_then(|claims| claims.get("keystone_project_id"))
.and_then(|v| v.as_str())
.map(String::from)
}
/// Extract user ID from credentials
pub fn get_user_id(&self, cred: &Credentials) -> Option<String> {
cred.claims
.as_ref()
.and_then(|claims| claims.get("keystone_user_id"))
.and_then(|v| v.as_str())
.map(String::from)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_keystone_token_to_credentials() {
let client = KeystoneClient::new(
"http://localhost:5000".to_string(),
crate::KeystoneVersion::V3,
None,
None,
None,
"Default".to_string(),
true,
);
let provider = KeystoneAuthProvider::new(client, 100, Duration::from_secs(60), true);
let token = KeystoneToken {
token: "test-token".to_string(),
user_id: "user123".to_string(),
username: "testuser".to_string(),
project_id: Some("proj456".to_string()),
project_name: Some("testproject".to_string()),
domain_id: Some("default".to_string()),
domain_name: Some("Default".to_string()),
roles: vec!["Member".to_string(), "admin".to_string()],
expires_at: time::OffsetDateTime::now_utc() + time::Duration::hours(1),
issued_at: time::OffsetDateTime::now_utc(),
};
let cred = provider.keystone_token_to_credentials(&token);
assert_eq!(cred.access_key, "keystone:user123");
assert_eq!(cred.parent_user, "testuser");
assert_eq!(cred.groups, Some(vec!["Member".to_string(), "admin".to_string()]));
assert!(cred.claims.is_some());
let claims = cred.claims.unwrap();
assert_eq!(claims.get("keystone_user_id").unwrap().as_str().unwrap(), "user123");
assert_eq!(claims.get("keystone_project_id").unwrap().as_str().unwrap(), "proj456");
}
#[test]
fn test_is_admin() {
let client = KeystoneClient::new(
"http://localhost:5000".to_string(),
crate::KeystoneVersion::V3,
None,
None,
None,
"Default".to_string(),
true,
);
let provider = KeystoneAuthProvider::new(client, 100, Duration::from_secs(60), true);
let mut cred = Credentials {
groups: Some(vec!["Member".to_string()]),
..Default::default()
};
assert!(!provider.is_admin(&cred));
cred.groups = Some(vec!["admin".to_string()]);
assert!(provider.is_admin(&cred));
cred.groups = Some(vec!["Admin".to_string()]);
assert!(provider.is_admin(&cred));
}
}
+437
View File
@@ -0,0 +1,437 @@
// 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)]
verify_ssl: bool,
}
#[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
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,
) -> Self {
let client = Client::builder()
.danger_accept_invalid_certs(!verify_ssl)
.timeout(std::time::Duration::from_secs(30))
.build()
.unwrap();
Self {
client,
auth_url,
version,
admin_token: Arc::new(RwLock::new(None)),
admin_user,
admin_password,
admin_project,
admin_domain,
verify_ssl,
}
}
/// Validate a Keystone token
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
pub async fn validate_ec2_credentials(
&self,
access_key: &str,
signature: &str,
string_to_sign: &str,
) -> Result<EC2Credential> {
let url = format!("{}/v3/ec2tokens", self.auth_url);
debug!("Validating EC2 credentials: access_key={}", access_key);
let payload = json!({
"auth": {
"identity": {
"methods": ["ec2"],
"ec2": {
"access": access_key,
"signature": signature,
"data": string_to_sign
}
}
}
});
let response = self
.client
.post(&url)
.json(&payload)
.send()
.await
.map_err(|e| KeystoneError::HttpError(e.to_string()))?;
if !response.status().is_success() {
return Err(KeystoneError::InvalidCredentials);
}
let _body: serde_json::Value = response.json().await.map_err(|e| KeystoneError::ParseError(e.to_string()))?;
// Parse access key to extract user_id and project_id
let (user_id, project_id) = EC2Credential::parse_access_key(access_key).unwrap_or((access_key.to_string(), None));
Ok(EC2Credential {
access: access_key.to_string(),
secret: String::new(), // Secret not returned in validation
user_id,
project_id,
trust_id: None,
})
}
/// Get EC2 credentials for a user
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,
);
assert_eq!(client.auth_url, "http://keystone:5000");
assert_eq!(client.version, KeystoneVersion::V3);
}
}
+251
View File
@@ -0,0 +1,251 @@
// 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::{KeystoneError, KeystoneVersion, Result};
use serde::{Deserialize, Serialize};
use std::time::Duration;
/// Keystone integration configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeystoneConfig {
/// Enable Keystone authentication
pub enable: bool,
/// Keystone auth URL (e.g., http://keystone:5000)
pub auth_url: String,
/// Keystone API version ("v3" or "v2.0")
pub version: String,
/// Admin user for privileged operations
pub admin_user: Option<String>,
/// Admin password
pub admin_password: Option<String>,
/// Admin project/tenant
pub admin_project: Option<String>,
/// Admin domain (default: "Default")
pub admin_domain: Option<String>,
/// Verify SSL certificates
pub verify_ssl: bool,
/// Enable token caching
pub enable_cache: bool,
/// Token cache size (number of entries)
pub cache_size: u64,
/// Token cache TTL (seconds)
pub cache_ttl_seconds: u64,
/// Enable tenant/project prefixing for buckets
/// When true, buckets are prefixed with project_id: "project_id:bucket_name"
pub enable_tenant_prefix: bool,
/// Enable implicit tenant creation
/// When true, automatically create tenants on first access
pub implicit_tenants: bool,
/// Request timeout (seconds)
pub timeout_seconds: u64,
/// Role-to-policy mappings
/// Maps Keystone roles to RustFS policy names
pub role_mappings: Option<Vec<RoleMapping>>,
}
/// Role to policy mapping
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoleMapping {
/// Keystone role name
pub keystone_role: String,
/// RustFS policy name
pub rustfs_policy: String,
}
impl KeystoneConfig {
/// Load configuration from environment variables
pub fn from_env() -> Result<Self> {
let enable = std::env::var("RUSTFS_KEYSTONE_ENABLE")
.unwrap_or_else(|_| "false".to_string())
.parse()
.unwrap_or(false);
if !enable {
return Ok(Self::default());
}
let auth_url = std::env::var("RUSTFS_KEYSTONE_AUTH_URL")
.map_err(|_| KeystoneError::ConfigError("RUSTFS_KEYSTONE_AUTH_URL not set".to_string()))?;
let version = std::env::var("RUSTFS_KEYSTONE_VERSION").unwrap_or_else(|_| "v3".to_string());
let admin_user = std::env::var("RUSTFS_KEYSTONE_ADMIN_USER").ok();
let admin_password = std::env::var("RUSTFS_KEYSTONE_ADMIN_PASSWORD").ok();
let admin_project = std::env::var("RUSTFS_KEYSTONE_ADMIN_PROJECT").ok();
let admin_domain = std::env::var("RUSTFS_KEYSTONE_ADMIN_DOMAIN").ok();
let verify_ssl = std::env::var("RUSTFS_KEYSTONE_VERIFY_SSL")
.unwrap_or_else(|_| "true".to_string())
.parse()
.unwrap_or(true);
let enable_cache = std::env::var("RUSTFS_KEYSTONE_ENABLE_CACHE")
.unwrap_or_else(|_| "true".to_string())
.parse()
.unwrap_or(true);
let cache_size = std::env::var("RUSTFS_KEYSTONE_CACHE_SIZE")
.unwrap_or_else(|_| "10000".to_string())
.parse()
.unwrap_or(10000);
let cache_ttl_seconds = std::env::var("RUSTFS_KEYSTONE_CACHE_TTL")
.unwrap_or_else(|_| "300".to_string())
.parse()
.unwrap_or(300);
let enable_tenant_prefix = std::env::var("RUSTFS_KEYSTONE_TENANT_PREFIX")
.unwrap_or_else(|_| "true".to_string())
.parse()
.unwrap_or(true);
let implicit_tenants = std::env::var("RUSTFS_KEYSTONE_IMPLICIT_TENANTS")
.unwrap_or_else(|_| "true".to_string())
.parse()
.unwrap_or(true);
let timeout_seconds = std::env::var("RUSTFS_KEYSTONE_TIMEOUT")
.unwrap_or_else(|_| "30".to_string())
.parse()
.unwrap_or(30);
Ok(Self {
enable,
auth_url,
version,
admin_user,
admin_password,
admin_project,
admin_domain,
verify_ssl,
enable_cache,
cache_size,
cache_ttl_seconds,
enable_tenant_prefix,
implicit_tenants,
timeout_seconds,
role_mappings: None,
})
}
/// Get Keystone API version
pub fn get_version(&self) -> Result<KeystoneVersion> {
match self.version.as_str() {
"v3" | "3" => Ok(KeystoneVersion::V3),
"v2.0" | "v2" | "2.0" | "2" => Ok(KeystoneVersion::V2_0),
_ => Err(KeystoneError::ConfigError(format!("Invalid Keystone version: {}", self.version))),
}
}
/// Get cache TTL duration
pub fn get_cache_ttl(&self) -> Duration {
Duration::from_secs(self.cache_ttl_seconds)
}
/// Get request timeout duration
pub fn get_timeout(&self) -> Duration {
Duration::from_secs(self.timeout_seconds)
}
/// Get admin domain (defaults to "Default")
pub fn get_admin_domain(&self) -> String {
self.admin_domain.clone().unwrap_or_else(|| "Default".to_string())
}
/// Validate configuration
pub fn validate(&self) -> Result<()> {
if !self.enable {
return Ok(());
}
if self.auth_url.is_empty() {
return Err(KeystoneError::ConfigError("auth_url is required".to_string()));
}
// Validate version
self.get_version()?;
// Warn if admin credentials are missing (needed for some operations)
if self.admin_user.is_none() || self.admin_password.is_none() {
tracing::warn!("Keystone admin credentials not configured - some operations may fail");
}
Ok(())
}
}
impl Default for KeystoneConfig {
fn default() -> Self {
Self {
enable: false,
auth_url: String::new(),
version: "v3".to_string(),
admin_user: None,
admin_password: None,
admin_project: None,
admin_domain: None,
verify_ssl: true,
enable_cache: true,
cache_size: 10000,
cache_ttl_seconds: 300,
enable_tenant_prefix: true,
implicit_tenants: true,
timeout_seconds: 30,
role_mappings: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = KeystoneConfig::default();
assert!(!config.enable);
assert_eq!(config.version, "v3");
assert!(config.verify_ssl);
assert!(config.enable_cache);
}
#[test]
fn test_get_version() {
let mut config = KeystoneConfig {
version: "v3".to_string(),
..Default::default()
};
assert_eq!(config.get_version().unwrap(), KeystoneVersion::V3);
config.version = "v2.0".to_string();
assert_eq!(config.get_version().unwrap(), KeystoneVersion::V2_0);
config.version = "invalid".to_string();
assert!(config.get_version().is_err());
}
}
+98
View File
@@ -0,0 +1,98 @@
// 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 thiserror::Error;
pub type Result<T> = std::result::Result<T, KeystoneError>;
/// Keystone integration errors
#[derive(Debug, Error)]
pub enum KeystoneError {
/// Invalid or malformed token
#[error("Invalid token")]
InvalidToken,
/// Token has expired
#[error("Token expired")]
TokenExpired,
/// Invalid EC2 credentials
#[error("Invalid credentials")]
InvalidCredentials,
/// Authentication failed
#[error("Authentication failed: {0}")]
AuthenticationFailed(String),
/// HTTP request error
#[error("HTTP error: {0}")]
HttpError(String),
/// Response parsing error
#[error("Parse error: {0}")]
ParseError(String),
/// Configuration error
#[error("Configuration error: {0}")]
ConfigError(String),
/// Unsupported Keystone version
#[error("Unsupported Keystone version")]
UnsupportedVersion,
/// Project not found
#[error("Project not found")]
ProjectNotFound,
/// User not found
#[error("User not found")]
UserNotFound,
/// Insufficient permissions
#[error("Insufficient permissions: {0}")]
InsufficientPermissions(String),
/// Internal error
#[error("Internal error: {0}")]
InternalError(String),
/// Network timeout
#[error("Request timeout")]
Timeout,
/// Service unavailable
#[error("Keystone service unavailable")]
ServiceUnavailable,
}
impl KeystoneError {
/// Check if error is retryable
pub fn is_retryable(&self) -> bool {
matches!(
self,
KeystoneError::Timeout | KeystoneError::ServiceUnavailable | KeystoneError::HttpError(_)
)
}
/// Check if error is authentication related
pub fn is_auth_error(&self) -> bool {
matches!(
self,
KeystoneError::InvalidToken
| KeystoneError::TokenExpired
| KeystoneError::InvalidCredentials
| KeystoneError::AuthenticationFailed(_)
)
}
}
+323
View File
@@ -0,0 +1,323 @@
// 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::KeystoneClient;
use rustfs_policy::policy::Policy;
use std::collections::HashMap;
use std::sync::Arc;
use tracing::{debug, info};
/// Maps Keystone identities to RustFS concepts
pub struct KeystoneIdentityMapper {
#[allow(dead_code)]
client: Arc<KeystoneClient>,
role_policy_map: HashMap<String, String>,
enable_tenant_prefix: bool,
}
impl KeystoneIdentityMapper {
/// Create new identity mapper
pub fn new(client: Arc<KeystoneClient>, enable_tenant_prefix: bool) -> Self {
let mut role_policy_map = HashMap::new();
// Default Keystone role mappings
role_policy_map.insert("admin".to_string(), "AdminPolicy".to_string());
role_policy_map.insert("Admin".to_string(), "AdminPolicy".to_string());
role_policy_map.insert("Member".to_string(), "ReadWritePolicy".to_string());
role_policy_map.insert("_member_".to_string(), "ReadOnlyPolicy".to_string());
role_policy_map.insert("ResellerAdmin".to_string(), "AdminPolicy".to_string());
role_policy_map.insert("SwiftOperator".to_string(), "ReadWritePolicy".to_string());
role_policy_map.insert("objectstore:admin".to_string(), "AdminPolicy".to_string());
role_policy_map.insert("objectstore:creator".to_string(), "ReadWritePolicy".to_string());
Self {
client,
role_policy_map,
enable_tenant_prefix,
}
}
/// Add custom role-to-policy mapping
pub fn add_role_mapping(&mut self, keystone_role: String, rustfs_policy: String) {
info!("Adding role mapping: {} -> {}", keystone_role, rustfs_policy);
self.role_policy_map.insert(keystone_role, rustfs_policy);
}
/// Add multiple role mappings
pub fn add_role_mappings(&mut self, mappings: Vec<(String, String)>) {
for (role, policy) in mappings {
self.add_role_mapping(role, policy);
}
}
/// Map Keystone roles to RustFS policy names
pub fn map_roles_to_policies(&self, roles: &[String]) -> Vec<String> {
let policies: Vec<String> = roles
.iter()
.filter_map(|role| self.role_policy_map.get(role).cloned())
.collect();
debug!("Mapped roles {:?} to policies {:?}", roles, policies);
policies
}
/// Generate tenant-prefixed bucket name
/// Format: <project_id>:<bucket_name>
pub fn apply_tenant_prefix(&self, bucket: &str, project_id: Option<&str>) -> String {
if !self.enable_tenant_prefix {
return bucket.to_string();
}
if let Some(proj_id) = project_id {
let prefixed = format!("{}:{}", proj_id, bucket);
debug!("Applied tenant prefix: {} -> {}", bucket, prefixed);
prefixed
} else {
bucket.to_string()
}
}
/// Remove tenant prefix from bucket name
pub fn remove_tenant_prefix(&self, prefixed_bucket: &str, project_id: Option<&str>) -> String {
if !self.enable_tenant_prefix {
return prefixed_bucket.to_string();
}
if let Some(proj_id) = project_id {
let prefix = format!("{}:", proj_id);
if prefixed_bucket.starts_with(&prefix) {
let unprefixed = prefixed_bucket[prefix.len()..].to_string();
debug!("Removed tenant prefix: {} -> {}", prefixed_bucket, unprefixed);
return unprefixed;
}
}
prefixed_bucket.to_string()
}
/// Check if bucket belongs to project
pub fn is_project_bucket(&self, bucket: &str, project_id: Option<&str>) -> bool {
if !self.enable_tenant_prefix {
return true; // No multi-tenancy, all buckets accessible
}
if let Some(proj_id) = project_id {
let prefix = format!("{}:", proj_id);
bucket.starts_with(&prefix)
} else {
!bucket.contains(':') // No project ID, only unprefixed buckets
}
}
/// Extract project ID from prefixed bucket name
pub fn extract_project_id(&self, bucket: &str) -> Option<String> {
if !self.enable_tenant_prefix {
return None;
}
bucket.find(':').map(|pos| bucket[..pos].to_string())
}
/// Create default policies for Keystone roles
pub fn create_default_policies(&self) -> HashMap<String, Policy> {
let mut policies = HashMap::new();
// Admin policy - full access
let admin_json = r#"{
"Version": "2012-10-17",
"ID": "AdminPolicy",
"Statement": [{
"Sid": "AdminFullAccess",
"Effect": "Allow",
"Action": ["s3:*"],
"Resource": ["arn:aws:s3:::*"]
}]
}"#;
if let Ok(policy) = serde_json::from_str::<Policy>(admin_json) {
policies.insert("AdminPolicy".to_string(), policy);
}
// ReadWrite policy - read/write access
let readwrite_json = r#"{
"Version": "2012-10-17",
"ID": "ReadWritePolicy",
"Statement": [{
"Sid": "ReadWriteAccess",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:ListBucket",
"s3:GetBucketLocation",
"s3:ListBucketMultipartUploads",
"s3:ListMultipartUploadParts",
"s3:AbortMultipartUpload"
],
"Resource": ["arn:aws:s3:::*"]
}]
}"#;
if let Ok(policy) = serde_json::from_str::<Policy>(readwrite_json) {
policies.insert("ReadWritePolicy".to_string(), policy);
}
// ReadOnly policy - read-only access
let readonly_json = r#"{
"Version": "2012-10-17",
"ID": "ReadOnlyPolicy",
"Statement": [{
"Sid": "ReadOnlyAccess",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:ListBucket",
"s3:GetBucketLocation"
],
"Resource": ["arn:aws:s3:::*"]
}]
}"#;
if let Ok(policy) = serde_json::from_str::<Policy>(readonly_json) {
policies.insert("ReadOnlyPolicy".to_string(), policy);
}
policies
}
/// Check if user has permission based on Keystone roles
pub fn has_permission(&self, roles: &[String], action: &str, _resource: &str) -> bool {
// Admin always has access
if roles.iter().any(|r| r.eq_ignore_ascii_case("admin") || r == "ResellerAdmin") {
return true;
}
// Check role-based permissions
for role in roles {
if let Some(policy_name) = self.role_policy_map.get(role) {
match policy_name.as_str() {
"AdminPolicy" => return true,
"ReadWritePolicy" => {
if action.starts_with("s3:Get")
|| action.starts_with("s3:Put")
|| action.starts_with("s3:Delete")
|| action.starts_with("s3:List")
{
return true;
}
}
"ReadOnlyPolicy" => {
if action.starts_with("s3:Get") || action.starts_with("s3:List") {
return true;
}
}
_ => continue,
}
}
}
false
}
/// Check if tenant prefixing is enabled
pub fn is_tenant_prefix_enabled(&self) -> bool {
self.enable_tenant_prefix
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::KeystoneVersion;
fn create_mapper() -> KeystoneIdentityMapper {
let client = KeystoneClient::new(
"http://localhost:5000".to_string(),
KeystoneVersion::V3,
None,
None,
None,
"Default".to_string(),
true,
);
KeystoneIdentityMapper::new(Arc::new(client), true)
}
#[test]
fn test_tenant_prefix() {
let mapper = create_mapper();
let prefixed = mapper.apply_tenant_prefix("mybucket", Some("proj123"));
assert_eq!(prefixed, "proj123:mybucket");
let unprefixed = mapper.remove_tenant_prefix("proj123:mybucket", Some("proj123"));
assert_eq!(unprefixed, "mybucket");
// No project ID
let no_prefix = mapper.apply_tenant_prefix("mybucket", None);
assert_eq!(no_prefix, "mybucket");
}
#[test]
fn test_is_project_bucket() {
let mapper = create_mapper();
assert!(mapper.is_project_bucket("proj123:mybucket", Some("proj123")));
assert!(!mapper.is_project_bucket("proj456:mybucket", Some("proj123")));
assert!(!mapper.is_project_bucket("mybucket", Some("proj123")));
}
#[test]
fn test_extract_project_id() {
let mapper = create_mapper();
assert_eq!(mapper.extract_project_id("proj123:mybucket"), Some("proj123".to_string()));
assert_eq!(mapper.extract_project_id("mybucket"), None);
}
#[test]
fn test_role_mapping() {
let mapper = create_mapper();
let roles = vec!["Member".to_string(), "admin".to_string()];
let policies = mapper.map_roles_to_policies(&roles);
assert!(policies.contains(&"ReadWritePolicy".to_string()));
assert!(policies.contains(&"AdminPolicy".to_string()));
}
#[test]
fn test_has_permission() {
let mapper = create_mapper();
// Admin has all permissions
assert!(mapper.has_permission(&["admin".to_string()], "s3:DeleteBucket", ""));
// Member has read/write permissions
assert!(mapper.has_permission(&["Member".to_string()], "s3:PutObject", ""));
assert!(mapper.has_permission(&["Member".to_string()], "s3:GetObject", ""));
// _member_ has read-only permissions
assert!(mapper.has_permission(&["_member_".to_string()], "s3:GetObject", ""));
assert!(!mapper.has_permission(&["_member_".to_string()], "s3:PutObject", ""));
}
#[test]
fn test_add_role_mapping() {
let mut mapper = create_mapper();
mapper.add_role_mapping("CustomRole".to_string(), "CustomPolicy".to_string());
let policies = mapper.map_roles_to_policies(&["CustomRole".to_string()]);
assert_eq!(policies, vec!["CustomPolicy".to_string()]);
}
}
+192
View File
@@ -0,0 +1,192 @@
// 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.
//! OpenStack Keystone integration for RustFS
//!
//! This module provides authentication and identity management
//! integration with OpenStack Keystone, similar to Ceph RGW.
//!
//! # Features
//!
//! - Keystone v3 token authentication
//! - EC2 credential support for S3 API compatibility
//! - Multi-tenancy with project-based bucket prefixing
//! - Role-based access control mapping
//! - Token caching for performance
//!
//! # Example
//!
//! ```no_run
//! use rustfs_keystone::{KeystoneConfig, KeystoneClient, KeystoneAuthProvider};
//!
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
//! let config = KeystoneConfig::from_env()?;
//! let client = KeystoneClient::new(
//! config.auth_url.clone(),
//! config.get_version()?,
//! config.admin_user.clone(),
//! config.admin_password.clone(),
//! config.admin_project.clone(),
//! config.get_admin_domain(),
//! config.verify_ssl,
//! );
//!
//! let auth_provider = KeystoneAuthProvider::new(
//! client,
//! config.cache_size,
//! config.get_cache_ttl(),
//! config.enable_cache,
//! );
//!
//! // Authenticate with Keystone token
//! let credentials = auth_provider.authenticate_with_token("token123").await?;
//! # Ok(())
//! # }
//! ```
use moka::future::Cache;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::Duration;
use time::OffsetDateTime;
pub mod auth;
pub mod client;
pub mod config;
pub mod error;
pub mod identity;
pub mod middleware;
pub use auth::KeystoneAuthProvider;
pub use client::KeystoneClient;
pub use config::{KeystoneConfig, RoleMapping};
pub use error::{KeystoneError, Result};
pub use identity::KeystoneIdentityMapper;
pub use middleware::{KEYSTONE_CREDENTIALS, KeystoneAuthLayer};
/// Keystone API version
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeystoneVersion {
/// Keystone API v2.0 (legacy)
V2_0,
/// Keystone API v3
V3,
}
/// Keystone token information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeystoneToken {
/// Token string (may be empty for cached tokens)
pub token: String,
/// User ID
pub user_id: String,
/// Username
pub username: String,
/// Project/Tenant ID
pub project_id: Option<String>,
/// Project/Tenant name
pub project_name: Option<String>,
/// Domain ID
pub domain_id: Option<String>,
/// Domain name
pub domain_name: Option<String>,
/// Assigned roles
pub roles: Vec<String>,
/// Token expiration time
pub expires_at: OffsetDateTime,
/// Token issue time
pub issued_at: OffsetDateTime,
}
impl KeystoneToken {
/// Check if token is expired
pub fn is_expired(&self) -> bool {
OffsetDateTime::now_utc() >= self.expires_at
}
/// Check if token has specific role
pub fn has_role(&self, role: &str) -> bool {
self.roles.iter().any(|r| r == role)
}
/// Check if token has admin role
pub fn is_admin(&self) -> bool {
self.has_role("admin") || self.has_role("Admin")
}
}
/// EC2 credentials from Keystone
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EC2Credential {
/// Access key (format: user_id:project_id or user_id)
pub access: String,
/// Secret key
pub secret: String,
/// User ID
pub user_id: String,
/// Project ID
pub project_id: Option<String>,
/// Trust ID (for delegated credentials)
pub trust_id: Option<String>,
}
impl EC2Credential {
/// Parse access key to extract user_id and project_id
///
/// Format: "user_id:project_id" or "user_id"
pub fn parse_access_key(access_key: &str) -> Option<(String, Option<String>)> {
if access_key.contains(':') {
let parts: Vec<&str> = access_key.split(':').collect();
if parts.len() == 2 {
return Some((parts[0].to_string(), Some(parts[1].to_string())));
}
}
Some((access_key.to_string(), None))
}
}
/// Token cache for performance optimization
#[derive(Clone)]
pub struct TokenCache {
cache: Cache<String, Arc<KeystoneToken>>,
}
impl TokenCache {
/// Create new token cache
pub fn new(capacity: u64, ttl: Duration) -> Self {
Self {
cache: Cache::builder().max_capacity(capacity).time_to_live(ttl).build(),
}
}
/// Get cached token
pub async fn get(&self, token: &str) -> Option<Arc<KeystoneToken>> {
self.cache.get(token).await
}
/// Insert token into cache
pub async fn insert(&self, token: String, info: Arc<KeystoneToken>) {
self.cache.insert(token, info).await;
}
/// Invalidate cached token
pub async fn invalidate(&self, token: &str) {
self.cache.invalidate(token).await;
}
/// Clear all cached tokens
pub async fn clear(&self) {
self.cache.invalidate_all();
}
}
+298
View File
@@ -0,0 +1,298 @@
// 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.
//! Keystone authentication middleware
//!
//! This middleware intercepts HTTP requests and checks for OpenStack Keystone
//! authentication headers (X-Auth-Token). If found, it validates the token
//! with Keystone and stores the authenticated credentials in task-local storage
//! for use by downstream authentication handlers.
//!
//! ## Authentication Flow
//!
//! 1. Check if Keystone is enabled (via global provider)
//! 2. Extract X-Auth-Token header from request
//! 3. If token present:
//! - Validate with Keystone service
//! - On success: Store credentials in task-local, continue processing
//! - On failure: Return 401 Unauthorized immediately
//! 4. If no token: Pass through to standard S3 authentication
//!
//! ## Task-Local Storage
//!
//! Uses tokio task-local storage to pass credentials from middleware to
//! auth handlers without modifying request/response types. This is async-safe
//! and properly scoped to the request lifetime.
use bytes::Bytes;
use futures::Future;
use http::{HeaderMap, Request, Response, StatusCode};
use http_body::Body;
use http_body_util::{BodyExt, Full};
use hyper::body::Incoming;
use rustfs_credentials::Credentials;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use tower::{Layer, Service};
use tracing::{debug, info, warn};
use crate::KeystoneAuthProvider;
// Task-local storage for Keystone credentials
// This allows passing credentials from middleware to auth handlers
// without modifying the request/response types
tokio::task_local! {
pub static KEYSTONE_CREDENTIALS: Option<Credentials>;
}
/// Tower Layer for Keystone authentication
///
/// This layer wraps services with Keystone authentication middleware.
/// It checks for X-Auth-Token headers and validates them with OpenStack Keystone.
#[derive(Clone)]
pub struct KeystoneAuthLayer {
keystone_auth: Option<Arc<KeystoneAuthProvider>>,
}
impl KeystoneAuthLayer {
/// Create a new Keystone authentication layer
///
/// # Arguments
///
/// * `keystone_auth` - Optional Keystone auth provider. If None, middleware is disabled.
pub fn new(keystone_auth: Option<Arc<KeystoneAuthProvider>>) -> Self {
if keystone_auth.is_some() {
info!("Keystone authentication middleware enabled");
} else {
debug!("Keystone authentication middleware disabled (no provider)");
}
Self { keystone_auth }
}
}
impl<S> Layer<S> for KeystoneAuthLayer {
type Service = KeystoneAuthMiddleware<S>;
fn layer(&self, inner: S) -> Self::Service {
KeystoneAuthMiddleware {
inner,
keystone_auth: self.keystone_auth.clone(),
}
}
}
/// Keystone authentication middleware service
///
/// This service intercepts requests, validates Keystone tokens if present,
/// and stores authenticated credentials in task-local storage.
#[derive(Clone)]
pub struct KeystoneAuthMiddleware<S> {
inner: S,
keystone_auth: Option<Arc<KeystoneAuthProvider>>,
}
type BoxError = Box<dyn std::error::Error + Send + Sync>;
type BoxBody = http_body_util::combinators::UnsyncBoxBody<Bytes, BoxError>;
impl<S, B> Service<Request<Incoming>> for KeystoneAuthMiddleware<S>
where
S: Service<Request<Incoming>, Response = Response<B>> + Clone + Send + 'static,
S::Future: Send + 'static,
S::Error: Send + 'static,
B: Body<Data = Bytes> + Send + 'static,
B::Error: Into<BoxError> + Send + 'static,
{
type Response = Response<BoxBody>;
type Error = S::Error;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: Request<Incoming>) -> Self::Future {
let keystone_auth = self.keystone_auth.clone();
let mut inner = self.inner.clone();
Box::pin(async move {
// Check if Keystone is enabled
let keystone_auth = match keystone_auth {
Some(auth) => auth,
None => {
// No Keystone configured, pass through to normal authentication
debug!("Keystone middleware: No provider configured, passing through");
let resp = inner.call(req).await?;
let (parts, body) = resp.into_parts();
let body: BoxBody = body.map_err(Into::into).boxed_unsync();
return Ok(Response::from_parts(parts, body));
}
};
// Extract X-Auth-Token header
let token = extract_keystone_token(req.headers());
if let Some(token) = token {
debug!("Keystone middleware: Found X-Auth-Token header, validating");
// Validate token with Keystone
match keystone_auth.authenticate_with_token(token).await {
Ok(credentials) => {
// Authentication successful!
info!("Keystone middleware: Authentication successful for user: {}", credentials.parent_user);
// Store credentials in task-local storage and continue processing
// The auth handlers will retrieve these credentials when needed
let resp = KEYSTONE_CREDENTIALS.scope(Some(credentials), inner.call(req)).await?;
let (parts, body) = resp.into_parts();
let body: BoxBody = body.map_err(Into::into).boxed_unsync();
return Ok(Response::from_parts(parts, body));
}
Err(e) => {
// Authentication failed - return 401 Unauthorized immediately
// Per Q5.A: Return 401 immediately, no fallback to local auth
warn!("Keystone middleware: Authentication failed: {}", e);
let error_xml = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>InvalidToken</Code>
<Message>Invalid Keystone token</Message>
<Details>{}</Details>
</Error>"#,
xml_escape(&e.to_string())
);
let body: BoxBody = Full::new(Bytes::from(error_xml))
.map_err(|e| -> BoxError { Box::new(e) })
.boxed_unsync();
let response = Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header("Content-Type", "application/xml")
.header("WWW-Authenticate", "Keystone")
.body(body)
.unwrap();
return Ok(response);
}
}
}
// No Keystone token header present, pass through to normal S3 authentication
debug!("Keystone middleware: No X-Auth-Token header, passing through to S3 auth");
let resp = inner.call(req).await?;
let (parts, body) = resp.into_parts();
let body: BoxBody = body.map_err(Into::into).boxed_unsync();
Ok(Response::from_parts(parts, body))
})
}
}
/// Extract Keystone token from request headers
///
/// Checks for X-Auth-Token header (Keystone v3 standard).
/// Note: X-Storage-Token (Swift) support deferred to future PR per Q4.C
fn extract_keystone_token(headers: &HeaderMap) -> Option<&str> {
headers.get("X-Auth-Token").and_then(|v| v.to_str().ok())
// TODO: Add X-Storage-Token support in Phase 2 (Swift API)
// .or_else(|| headers.get("X-Storage-Token").and_then(|v| v.to_str().ok()))
}
/// Escape XML special characters to prevent injection
fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{KeystoneClient, KeystoneVersion};
use std::time::Duration;
#[test]
fn test_layer_creation_no_keystone() {
// Test that layer can be created without Keystone provider
let layer = KeystoneAuthLayer::new(None);
assert!(layer.keystone_auth.is_none());
}
#[test]
fn test_layer_creation_with_keystone() {
// Test that layer can be created with Keystone provider
let client = KeystoneClient::new(
"http://localhost:5000".to_string(),
KeystoneVersion::V3,
None,
None,
None,
"Default".to_string(),
true,
);
let provider = KeystoneAuthProvider::new(client, 100, Duration::from_secs(60), true);
let layer = KeystoneAuthLayer::new(Some(Arc::new(provider)));
assert!(layer.keystone_auth.is_some());
}
#[tokio::test]
async fn test_extract_keystone_token() {
let mut headers = HeaderMap::new();
assert!(extract_keystone_token(&headers).is_none());
headers.insert("X-Auth-Token", "test-token-123".parse().unwrap());
assert_eq!(extract_keystone_token(&headers), Some("test-token-123"));
}
#[tokio::test]
async fn test_xml_escape() {
assert_eq!(xml_escape("normal text"), "normal text");
assert_eq!(xml_escape("<tag>"), "&lt;tag&gt;");
assert_eq!(xml_escape("a&b"), "a&amp;b");
assert_eq!(xml_escape("it's \"quoted\""), "it&apos;s &quot;quoted&quot;");
}
#[tokio::test]
async fn test_task_local_scope() {
// Verify that task-local storage works correctly
use rustfs_credentials::Credentials;
let creds = Credentials {
access_key: "test-key".to_string(),
parent_user: "test-user".to_string(),
..Default::default()
};
// Should be None outside of scope
assert!(KEYSTONE_CREDENTIALS.try_with(|c| c.clone()).is_err());
// Should be Some inside scope
KEYSTONE_CREDENTIALS
.scope(Some(creds.clone()), async {
let stored = KEYSTONE_CREDENTIALS.try_with(|c| c.clone()).unwrap();
assert!(stored.is_some());
assert_eq!(stored.unwrap().access_key, "test-key");
})
.await;
// Should be None again after scope
assert!(KEYSTONE_CREDENTIALS.try_with(|c| c.clone()).is_err());
}
// Note: test_valid_token and test_invalid_token require mock Keystone server
// These will be added in Task 3.3 (Integration Testing)
}