Files
rustfs/crates/keystone/src/auth.rs
T

317 lines
11 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, 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
///
/// Fails closed. A correct implementation must resolve the full identity
/// (user, project, roles, expiry) from Keystone using admin credentials or
/// a trusted mapping. It must never fabricate roles or derive identity from
/// client-supplied input. Until that is modeled against a live server, this
/// path refuses to mint a token.
async fn ec2_to_keystone_token(&self, _ec2_cred: &EC2Credential) -> Result<KeystoneToken> {
Err(KeystoneError::Ec2AuthUnsupported)
}
/// 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,
Duration::from_secs(30),
);
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,
Duration::from_secs(30),
);
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));
}
#[tokio::test]
async fn test_ec2_authentication_fails_closed() {
// The EC2/SigV4 path must never fabricate an identity from client input.
// It must fail closed rather than produce credentials.
let client = KeystoneClient::new(
"http://localhost:5000".to_string(),
crate::KeystoneVersion::V3,
None,
None,
None,
"Default".to_string(),
true,
Duration::from_secs(30),
);
let provider = KeystoneAuthProvider::new(client, 100, Duration::from_secs(60), true).without_cache();
// A `:`-bearing access key previously shaped user_id/project_id. It must
// no longer produce any credentials.
let result = provider
.authenticate_with_ec2("attacker:admin-project", "sig", "string-to-sign")
.await;
assert!(result.is_err(), "EC2 auth must not return fabricated credentials");
assert!(
matches!(result, Err(KeystoneError::Ec2AuthUnsupported)),
"EC2 auth must fail closed with Ec2AuthUnsupported"
);
}
}