feat(security): add Nevis IAM integration for SSO/MFA authentication (#3651)
* feat(security): add Nevis IAM integration for SSO/MFA authentication Add NevisAuthProvider supporting OAuth2/OIDC token validation (local JWKS + remote introspection), FIDO2/passkey/OTP MFA verification, session management, and health checks. Add IamPolicy engine mapping Nevis roles to ZeroClaw tool and workspace permissions with deny-by-default enforcement and audit logging. Add NevisConfig and NevisRoleMappingConfig to config schema with client_secret wired through SecretStore encrypt/decrypt. All features disabled by default. Rebased on latest master to resolve merge conflicts in security/mod.rs (redact function) and config/schema.rs (test section). Original work by @rareba. Supersedes #3593. Co-Authored-By: rareba <5985289+rareba@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style: cargo fmt Box::pin calls Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: rareba <5985289+rareba@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b8689f1599
commit
1bea48dba1
@ -3941,6 +3941,10 @@ pub struct SecurityConfig {
|
||||
/// Emergency-stop state machine configuration.
|
||||
#[serde(default)]
|
||||
pub estop: EstopConfig,
|
||||
|
||||
/// Nevis IAM integration for SSO/MFA authentication and role-based access.
|
||||
#[serde(default)]
|
||||
pub nevis: NevisConfig,
|
||||
}
|
||||
|
||||
/// OTP validation strategy.
|
||||
@ -4052,6 +4056,163 @@ impl Default for EstopConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Nevis IAM integration configuration.
|
||||
///
|
||||
/// When `enabled` is true, ZeroClaw validates incoming requests against a Nevis
|
||||
/// Security Suite instance and maps Nevis roles to tool/workspace permissions.
|
||||
#[derive(Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct NevisConfig {
|
||||
/// Enable Nevis IAM integration. Defaults to false for backward compatibility.
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
|
||||
/// Base URL of the Nevis instance (e.g. `https://nevis.example.com`).
|
||||
#[serde(default)]
|
||||
pub instance_url: String,
|
||||
|
||||
/// Nevis realm to authenticate against.
|
||||
#[serde(default = "default_nevis_realm")]
|
||||
pub realm: String,
|
||||
|
||||
/// OAuth2 client ID registered in Nevis.
|
||||
#[serde(default)]
|
||||
pub client_id: String,
|
||||
|
||||
/// OAuth2 client secret. Encrypted via SecretStore when stored on disk.
|
||||
#[serde(default)]
|
||||
pub client_secret: Option<String>,
|
||||
|
||||
/// Token validation strategy: `"local"` (JWKS) or `"remote"` (introspection).
|
||||
#[serde(default = "default_nevis_token_validation")]
|
||||
pub token_validation: String,
|
||||
|
||||
/// JWKS endpoint URL for local token validation.
|
||||
#[serde(default)]
|
||||
pub jwks_url: Option<String>,
|
||||
|
||||
/// Nevis role to ZeroClaw permission mappings.
|
||||
#[serde(default)]
|
||||
pub role_mapping: Vec<NevisRoleMappingConfig>,
|
||||
|
||||
/// Require MFA verification for all Nevis-authenticated requests.
|
||||
#[serde(default)]
|
||||
pub require_mfa: bool,
|
||||
|
||||
/// Session timeout in seconds.
|
||||
#[serde(default = "default_nevis_session_timeout_secs")]
|
||||
pub session_timeout_secs: u64,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for NevisConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("NevisConfig")
|
||||
.field("enabled", &self.enabled)
|
||||
.field("instance_url", &self.instance_url)
|
||||
.field("realm", &self.realm)
|
||||
.field("client_id", &self.client_id)
|
||||
.field(
|
||||
"client_secret",
|
||||
&self.client_secret.as_ref().map(|_| "[REDACTED]"),
|
||||
)
|
||||
.field("token_validation", &self.token_validation)
|
||||
.field("jwks_url", &self.jwks_url)
|
||||
.field("role_mapping", &self.role_mapping)
|
||||
.field("require_mfa", &self.require_mfa)
|
||||
.field("session_timeout_secs", &self.session_timeout_secs)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl NevisConfig {
|
||||
/// Validate that required fields are present when Nevis is enabled.
|
||||
///
|
||||
/// Call at config load time to fail fast on invalid configuration rather
|
||||
/// than deferring errors to the first authentication request.
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if !self.enabled {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if self.instance_url.trim().is_empty() {
|
||||
return Err("nevis.instance_url is required when Nevis IAM is enabled".into());
|
||||
}
|
||||
|
||||
if self.client_id.trim().is_empty() {
|
||||
return Err("nevis.client_id is required when Nevis IAM is enabled".into());
|
||||
}
|
||||
|
||||
if self.realm.trim().is_empty() {
|
||||
return Err("nevis.realm is required when Nevis IAM is enabled".into());
|
||||
}
|
||||
|
||||
match self.token_validation.as_str() {
|
||||
"local" | "remote" => {}
|
||||
other => {
|
||||
return Err(format!(
|
||||
"nevis.token_validation has invalid value '{other}': \
|
||||
expected 'local' or 'remote'"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if self.token_validation == "local" && self.jwks_url.is_none() {
|
||||
return Err("nevis.jwks_url is required when token_validation is 'local'".into());
|
||||
}
|
||||
|
||||
if self.session_timeout_secs == 0 {
|
||||
return Err("nevis.session_timeout_secs must be greater than 0".into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn default_nevis_realm() -> String {
|
||||
"master".into()
|
||||
}
|
||||
|
||||
fn default_nevis_token_validation() -> String {
|
||||
"local".into()
|
||||
}
|
||||
|
||||
fn default_nevis_session_timeout_secs() -> u64 {
|
||||
3600
|
||||
}
|
||||
|
||||
impl Default for NevisConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
instance_url: String::new(),
|
||||
realm: default_nevis_realm(),
|
||||
client_id: String::new(),
|
||||
client_secret: None,
|
||||
token_validation: default_nevis_token_validation(),
|
||||
jwks_url: None,
|
||||
role_mapping: Vec::new(),
|
||||
require_mfa: false,
|
||||
session_timeout_secs: default_nevis_session_timeout_secs(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a Nevis role to ZeroClaw tool permissions and workspace access.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct NevisRoleMappingConfig {
|
||||
/// Nevis role name (case-insensitive).
|
||||
pub nevis_role: String,
|
||||
|
||||
/// Tool names this role can access. Use `"all"` for unrestricted tool access.
|
||||
#[serde(default)]
|
||||
pub zeroclaw_permissions: Vec<String>,
|
||||
|
||||
/// Workspace names this role can access. Use `"all"` for unrestricted.
|
||||
#[serde(default)]
|
||||
pub workspace_access: Vec<String>,
|
||||
}
|
||||
|
||||
/// Sandbox configuration for OS-level isolation
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct SandboxConfig {
|
||||
@ -5072,6 +5233,13 @@ impl Config {
|
||||
decrypt_secret(&store, token, "config.gateway.paired_tokens[]")?;
|
||||
}
|
||||
|
||||
// Decrypt Nevis IAM secret
|
||||
decrypt_optional_secret(
|
||||
&store,
|
||||
&mut config.security.nevis.client_secret,
|
||||
"config.security.nevis.client_secret",
|
||||
)?;
|
||||
|
||||
config.apply_env_overrides();
|
||||
config.validate()?;
|
||||
tracing::info!(
|
||||
@ -5390,6 +5558,11 @@ impl Config {
|
||||
validate_mcp_config(&self.mcp)?;
|
||||
}
|
||||
|
||||
// Nevis IAM — delegate to NevisConfig::validate() for field-level checks
|
||||
if let Err(msg) = self.security.nevis.validate() {
|
||||
anyhow::bail!("security.nevis: {msg}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -6013,6 +6186,13 @@ impl Config {
|
||||
encrypt_secret(&store, token, "config.gateway.paired_tokens[]")?;
|
||||
}
|
||||
|
||||
// Encrypt Nevis IAM secret
|
||||
encrypt_optional_secret(
|
||||
&store,
|
||||
&mut config_to_save.security.nevis.client_secret,
|
||||
"config.security.nevis.client_secret",
|
||||
)?;
|
||||
|
||||
let toml_str =
|
||||
toml::to_string_pretty(&config_to_save).context("Failed to serialize config")?;
|
||||
|
||||
@ -6100,6 +6280,7 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::unused_async)] // async needed on unix for tokio File I/O; no-op on other platforms
|
||||
async fn sync_directory(path: &Path) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@ -6125,6 +6306,7 @@ mod tests {
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::PathBuf;
|
||||
#[cfg(unix)]
|
||||
use tempfile::TempDir;
|
||||
use tokio::sync::{Mutex, MutexGuard};
|
||||
use tokio::test;
|
||||
@ -9569,4 +9751,187 @@ require_otp_to_resume = true
|
||||
assert_eq!(config.swarms.len(), 1);
|
||||
assert!(config.swarms.contains_key("pipeline"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nevis_client_secret_encrypt_decrypt_roundtrip() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"zeroclaw_test_nevis_secret_{}",
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
fs::create_dir_all(&dir).await.unwrap();
|
||||
|
||||
let plaintext_secret = "nevis-test-client-secret-value";
|
||||
|
||||
let mut config = Config::default();
|
||||
config.workspace_dir = dir.join("workspace");
|
||||
config.config_path = dir.join("config.toml");
|
||||
config.security.nevis.client_secret = Some(plaintext_secret.into());
|
||||
|
||||
// Save (triggers encryption)
|
||||
config.save().await.unwrap();
|
||||
|
||||
// Read raw TOML and verify plaintext secret is NOT present
|
||||
let raw_toml = tokio::fs::read_to_string(&config.config_path)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
!raw_toml.contains(plaintext_secret),
|
||||
"Saved TOML must not contain the plaintext client_secret"
|
||||
);
|
||||
|
||||
// Parse stored TOML and verify the value is encrypted
|
||||
let stored: Config = toml::from_str(&raw_toml).unwrap();
|
||||
let stored_secret = stored.security.nevis.client_secret.as_ref().unwrap();
|
||||
assert!(
|
||||
crate::security::SecretStore::is_encrypted(stored_secret),
|
||||
"Stored client_secret must be marked as encrypted"
|
||||
);
|
||||
|
||||
// Decrypt and verify it matches the original plaintext
|
||||
let store = crate::security::SecretStore::new(&dir, true);
|
||||
assert_eq!(store.decrypt(stored_secret).unwrap(), plaintext_secret);
|
||||
|
||||
// Simulate a full load: deserialize then decrypt (mirrors load_or_init logic)
|
||||
let mut loaded: Config = toml::from_str(&raw_toml).unwrap();
|
||||
loaded.config_path = dir.join("config.toml");
|
||||
let load_store = crate::security::SecretStore::new(&dir, loaded.secrets.encrypt);
|
||||
decrypt_optional_secret(
|
||||
&load_store,
|
||||
&mut loaded.security.nevis.client_secret,
|
||||
"config.security.nevis.client_secret",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
loaded.security.nevis.client_secret.as_deref().unwrap(),
|
||||
plaintext_secret,
|
||||
"Loaded client_secret must match the original plaintext after decryption"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&dir).await;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// Nevis config validation tests
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
#[test]
|
||||
async fn nevis_config_validate_disabled_accepts_empty_fields() {
|
||||
let cfg = NevisConfig::default();
|
||||
assert!(!cfg.enabled);
|
||||
assert!(cfg.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn nevis_config_validate_rejects_empty_instance_url() {
|
||||
let cfg = NevisConfig {
|
||||
enabled: true,
|
||||
instance_url: String::new(),
|
||||
client_id: "test-client".into(),
|
||||
..NevisConfig::default()
|
||||
};
|
||||
let err = cfg.validate().unwrap_err();
|
||||
assert!(err.contains("instance_url"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn nevis_config_validate_rejects_empty_client_id() {
|
||||
let cfg = NevisConfig {
|
||||
enabled: true,
|
||||
instance_url: "https://nevis.example.com".into(),
|
||||
client_id: String::new(),
|
||||
..NevisConfig::default()
|
||||
};
|
||||
let err = cfg.validate().unwrap_err();
|
||||
assert!(err.contains("client_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn nevis_config_validate_rejects_empty_realm() {
|
||||
let cfg = NevisConfig {
|
||||
enabled: true,
|
||||
instance_url: "https://nevis.example.com".into(),
|
||||
client_id: "test-client".into(),
|
||||
realm: String::new(),
|
||||
..NevisConfig::default()
|
||||
};
|
||||
let err = cfg.validate().unwrap_err();
|
||||
assert!(err.contains("realm"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn nevis_config_validate_rejects_local_without_jwks() {
|
||||
let cfg = NevisConfig {
|
||||
enabled: true,
|
||||
instance_url: "https://nevis.example.com".into(),
|
||||
client_id: "test-client".into(),
|
||||
token_validation: "local".into(),
|
||||
jwks_url: None,
|
||||
..NevisConfig::default()
|
||||
};
|
||||
let err = cfg.validate().unwrap_err();
|
||||
assert!(err.contains("jwks_url"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn nevis_config_validate_rejects_zero_session_timeout() {
|
||||
let cfg = NevisConfig {
|
||||
enabled: true,
|
||||
instance_url: "https://nevis.example.com".into(),
|
||||
client_id: "test-client".into(),
|
||||
token_validation: "remote".into(),
|
||||
session_timeout_secs: 0,
|
||||
..NevisConfig::default()
|
||||
};
|
||||
let err = cfg.validate().unwrap_err();
|
||||
assert!(err.contains("session_timeout_secs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn nevis_config_validate_accepts_valid_enabled_config() {
|
||||
let cfg = NevisConfig {
|
||||
enabled: true,
|
||||
instance_url: "https://nevis.example.com".into(),
|
||||
realm: "master".into(),
|
||||
client_id: "test-client".into(),
|
||||
token_validation: "remote".into(),
|
||||
session_timeout_secs: 3600,
|
||||
..NevisConfig::default()
|
||||
};
|
||||
assert!(cfg.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn nevis_config_validate_rejects_invalid_token_validation() {
|
||||
let cfg = NevisConfig {
|
||||
enabled: true,
|
||||
instance_url: "https://nevis.example.com".into(),
|
||||
realm: "master".into(),
|
||||
client_id: "test-client".into(),
|
||||
token_validation: "invalid_mode".into(),
|
||||
session_timeout_secs: 3600,
|
||||
..NevisConfig::default()
|
||||
};
|
||||
let err = cfg.validate().unwrap_err();
|
||||
assert!(
|
||||
err.contains("invalid value 'invalid_mode'"),
|
||||
"Expected invalid token_validation error, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn nevis_config_debug_redacts_client_secret() {
|
||||
let cfg = NevisConfig {
|
||||
client_secret: Some("super-secret".into()),
|
||||
..NevisConfig::default()
|
||||
};
|
||||
let debug_output = format!("{:?}", cfg);
|
||||
assert!(
|
||||
!debug_output.contains("super-secret"),
|
||||
"Debug output must not contain the raw client_secret"
|
||||
);
|
||||
assert!(
|
||||
debug_output.contains("[REDACTED]"),
|
||||
"Debug output must show [REDACTED] for client_secret"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -784,7 +784,7 @@ mod tests {
|
||||
job.prompt = Some("Say hello".into());
|
||||
let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
|
||||
|
||||
let (success, output) = run_agent_job(&config, &security, &job).await;
|
||||
let (success, output) = Box::pin(run_agent_job(&config, &security, &job)).await;
|
||||
assert!(!success);
|
||||
assert!(output.contains("agent job failed:"));
|
||||
}
|
||||
@ -799,7 +799,7 @@ mod tests {
|
||||
job.prompt = Some("Say hello".into());
|
||||
let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
|
||||
|
||||
let (success, output) = run_agent_job(&config, &security, &job).await;
|
||||
let (success, output) = Box::pin(run_agent_job(&config, &security, &job)).await;
|
||||
assert!(!success);
|
||||
assert!(output.contains("blocked by security policy"));
|
||||
assert!(output.contains("read-only"));
|
||||
@ -815,7 +815,7 @@ mod tests {
|
||||
job.prompt = Some("Say hello".into());
|
||||
let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
|
||||
|
||||
let (success, output) = run_agent_job(&config, &security, &job).await;
|
||||
let (success, output) = Box::pin(run_agent_job(&config, &security, &job)).await;
|
||||
assert!(!success);
|
||||
assert!(output.contains("blocked by security policy"));
|
||||
assert!(output.contains("rate limit exceeded"));
|
||||
|
||||
449
src/security/iam_policy.rs
Normal file
449
src/security/iam_policy.rs
Normal file
@ -0,0 +1,449 @@
|
||||
//! IAM-aware policy enforcement for Nevis role-to-permission mapping.
|
||||
//!
|
||||
//! Evaluates tool and workspace access based on Nevis roles using a
|
||||
//! deny-by-default policy model. All policy decisions are audit-logged.
|
||||
|
||||
use super::nevis::NevisIdentity;
|
||||
use anyhow::{bail, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Maps a single Nevis role to ZeroClaw permissions.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RoleMapping {
|
||||
/// Nevis role name (case-insensitive matching).
|
||||
pub nevis_role: String,
|
||||
/// Tool names this role can access. Use `"all"` to grant all tools.
|
||||
pub zeroclaw_permissions: Vec<String>,
|
||||
/// Workspace names this role can access. Use `"all"` for unrestricted.
|
||||
#[serde(default)]
|
||||
pub workspace_access: Vec<String>,
|
||||
}
|
||||
|
||||
/// Result of a policy evaluation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PolicyDecision {
|
||||
/// Access is allowed.
|
||||
Allow,
|
||||
/// Access is denied, with reason.
|
||||
Deny(String),
|
||||
}
|
||||
|
||||
impl PolicyDecision {
|
||||
pub fn is_allowed(&self) -> bool {
|
||||
matches!(self, PolicyDecision::Allow)
|
||||
}
|
||||
}
|
||||
|
||||
/// IAM policy engine that maps Nevis roles to ZeroClaw tool permissions.
|
||||
///
|
||||
/// Deny-by-default: if no role mapping grants access, the request is denied.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IamPolicy {
|
||||
/// Compiled role mappings indexed by lowercase Nevis role name.
|
||||
role_map: HashMap<String, CompiledRole>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CompiledRole {
|
||||
/// Whether this role has access to all tools.
|
||||
all_tools: bool,
|
||||
/// Specific tool names this role can access (lowercase).
|
||||
allowed_tools: Vec<String>,
|
||||
/// Whether this role has access to all workspaces.
|
||||
all_workspaces: bool,
|
||||
/// Specific workspace names this role can access (lowercase).
|
||||
allowed_workspaces: Vec<String>,
|
||||
}
|
||||
|
||||
impl IamPolicy {
|
||||
/// Build a policy from role mappings (typically from config).
|
||||
///
|
||||
/// Returns an error if duplicate normalized role names are detected,
|
||||
/// since silent last-wins overwrites can accidentally broaden or revoke access.
|
||||
pub fn from_mappings(mappings: &[RoleMapping]) -> Result<Self> {
|
||||
let mut role_map = HashMap::new();
|
||||
|
||||
for mapping in mappings {
|
||||
let key = mapping.nevis_role.trim().to_ascii_lowercase();
|
||||
if key.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let all_tools = mapping
|
||||
.zeroclaw_permissions
|
||||
.iter()
|
||||
.any(|p| p.eq_ignore_ascii_case("all"));
|
||||
let allowed_tools: Vec<String> = mapping
|
||||
.zeroclaw_permissions
|
||||
.iter()
|
||||
.filter(|p| !p.eq_ignore_ascii_case("all"))
|
||||
.map(|p| p.trim().to_ascii_lowercase())
|
||||
.collect();
|
||||
|
||||
let all_workspaces = mapping
|
||||
.workspace_access
|
||||
.iter()
|
||||
.any(|w| w.eq_ignore_ascii_case("all"));
|
||||
let allowed_workspaces: Vec<String> = mapping
|
||||
.workspace_access
|
||||
.iter()
|
||||
.filter(|w| !w.eq_ignore_ascii_case("all"))
|
||||
.map(|w| w.trim().to_ascii_lowercase())
|
||||
.collect();
|
||||
|
||||
if role_map.contains_key(&key) {
|
||||
bail!(
|
||||
"IAM policy: duplicate role mapping for normalized key '{}' \
|
||||
(from nevis_role '{}') — remove or merge the duplicate entry",
|
||||
key,
|
||||
mapping.nevis_role
|
||||
);
|
||||
}
|
||||
|
||||
role_map.insert(
|
||||
key,
|
||||
CompiledRole {
|
||||
all_tools,
|
||||
allowed_tools,
|
||||
all_workspaces,
|
||||
allowed_workspaces,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Self { role_map })
|
||||
}
|
||||
|
||||
/// Evaluate whether an identity is allowed to use a specific tool.
|
||||
///
|
||||
/// Deny-by-default: returns `Deny` unless at least one of the identity's
|
||||
/// roles grants access to the requested tool.
|
||||
pub fn evaluate_tool_access(
|
||||
&self,
|
||||
identity: &NevisIdentity,
|
||||
tool_name: &str,
|
||||
) -> PolicyDecision {
|
||||
let normalized_tool = tool_name.trim().to_ascii_lowercase();
|
||||
if normalized_tool.is_empty() {
|
||||
return PolicyDecision::Deny("empty tool name".into());
|
||||
}
|
||||
|
||||
for role in &identity.roles {
|
||||
let key = role.trim().to_ascii_lowercase();
|
||||
if let Some(compiled) = self.role_map.get(&key) {
|
||||
if compiled.all_tools
|
||||
|| compiled.allowed_tools.iter().any(|t| t == &normalized_tool)
|
||||
{
|
||||
tracing::info!(
|
||||
user_id = %crate::security::redact(&identity.user_id),
|
||||
role = %key,
|
||||
tool = %normalized_tool,
|
||||
"IAM policy: tool access ALLOWED"
|
||||
);
|
||||
return PolicyDecision::Allow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let reason = format!(
|
||||
"no role grants access to tool '{normalized_tool}' for user '{}'",
|
||||
crate::security::redact(&identity.user_id)
|
||||
);
|
||||
tracing::info!(
|
||||
user_id = %crate::security::redact(&identity.user_id),
|
||||
tool = %normalized_tool,
|
||||
"IAM policy: tool access DENIED"
|
||||
);
|
||||
PolicyDecision::Deny(reason)
|
||||
}
|
||||
|
||||
/// Evaluate whether an identity is allowed to access a specific workspace.
|
||||
///
|
||||
/// Deny-by-default: returns `Deny` unless at least one of the identity's
|
||||
/// roles grants access to the requested workspace.
|
||||
pub fn evaluate_workspace_access(
|
||||
&self,
|
||||
identity: &NevisIdentity,
|
||||
workspace: &str,
|
||||
) -> PolicyDecision {
|
||||
let normalized_ws = workspace.trim().to_ascii_lowercase();
|
||||
if normalized_ws.is_empty() {
|
||||
return PolicyDecision::Deny("empty workspace name".into());
|
||||
}
|
||||
|
||||
for role in &identity.roles {
|
||||
let key = role.trim().to_ascii_lowercase();
|
||||
if let Some(compiled) = self.role_map.get(&key) {
|
||||
if compiled.all_workspaces
|
||||
|| compiled
|
||||
.allowed_workspaces
|
||||
.iter()
|
||||
.any(|w| w == &normalized_ws)
|
||||
{
|
||||
tracing::info!(
|
||||
user_id = %crate::security::redact(&identity.user_id),
|
||||
role = %key,
|
||||
workspace = %normalized_ws,
|
||||
"IAM policy: workspace access ALLOWED"
|
||||
);
|
||||
return PolicyDecision::Allow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let reason = format!(
|
||||
"no role grants access to workspace '{normalized_ws}' for user '{}'",
|
||||
crate::security::redact(&identity.user_id)
|
||||
);
|
||||
tracing::info!(
|
||||
user_id = %crate::security::redact(&identity.user_id),
|
||||
workspace = %normalized_ws,
|
||||
"IAM policy: workspace access DENIED"
|
||||
);
|
||||
PolicyDecision::Deny(reason)
|
||||
}
|
||||
|
||||
/// Check if the policy has any role mappings configured.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.role_map.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_mappings() -> Vec<RoleMapping> {
|
||||
vec![
|
||||
RoleMapping {
|
||||
nevis_role: "admin".into(),
|
||||
zeroclaw_permissions: vec!["all".into()],
|
||||
workspace_access: vec!["all".into()],
|
||||
},
|
||||
RoleMapping {
|
||||
nevis_role: "operator".into(),
|
||||
zeroclaw_permissions: vec![
|
||||
"shell".into(),
|
||||
"file_read".into(),
|
||||
"file_write".into(),
|
||||
"memory_search".into(),
|
||||
],
|
||||
workspace_access: vec!["production".into(), "staging".into()],
|
||||
},
|
||||
RoleMapping {
|
||||
nevis_role: "viewer".into(),
|
||||
zeroclaw_permissions: vec!["file_read".into(), "memory_search".into()],
|
||||
workspace_access: vec!["staging".into()],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn identity_with_roles(roles: Vec<&str>) -> NevisIdentity {
|
||||
NevisIdentity {
|
||||
user_id: "zeroclaw_user".into(),
|
||||
roles: roles.into_iter().map(String::from).collect(),
|
||||
scopes: vec!["openid".into()],
|
||||
mfa_verified: true,
|
||||
session_expiry: u64::MAX,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_gets_all_tools() {
|
||||
let policy = IamPolicy::from_mappings(&test_mappings()).unwrap();
|
||||
let identity = identity_with_roles(vec!["admin"]);
|
||||
|
||||
assert!(policy.evaluate_tool_access(&identity, "shell").is_allowed());
|
||||
assert!(policy
|
||||
.evaluate_tool_access(&identity, "file_read")
|
||||
.is_allowed());
|
||||
assert!(policy
|
||||
.evaluate_tool_access(&identity, "any_tool_name")
|
||||
.is_allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_gets_all_workspaces() {
|
||||
let policy = IamPolicy::from_mappings(&test_mappings()).unwrap();
|
||||
let identity = identity_with_roles(vec!["admin"]);
|
||||
|
||||
assert!(policy
|
||||
.evaluate_workspace_access(&identity, "production")
|
||||
.is_allowed());
|
||||
assert!(policy
|
||||
.evaluate_workspace_access(&identity, "any_workspace")
|
||||
.is_allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operator_gets_subset_of_tools() {
|
||||
let policy = IamPolicy::from_mappings(&test_mappings()).unwrap();
|
||||
let identity = identity_with_roles(vec!["operator"]);
|
||||
|
||||
assert!(policy.evaluate_tool_access(&identity, "shell").is_allowed());
|
||||
assert!(policy
|
||||
.evaluate_tool_access(&identity, "file_read")
|
||||
.is_allowed());
|
||||
assert!(!policy
|
||||
.evaluate_tool_access(&identity, "browser")
|
||||
.is_allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operator_workspace_access_is_scoped() {
|
||||
let policy = IamPolicy::from_mappings(&test_mappings()).unwrap();
|
||||
let identity = identity_with_roles(vec!["operator"]);
|
||||
|
||||
assert!(policy
|
||||
.evaluate_workspace_access(&identity, "production")
|
||||
.is_allowed());
|
||||
assert!(policy
|
||||
.evaluate_workspace_access(&identity, "staging")
|
||||
.is_allowed());
|
||||
assert!(!policy
|
||||
.evaluate_workspace_access(&identity, "development")
|
||||
.is_allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewer_is_read_only() {
|
||||
let policy = IamPolicy::from_mappings(&test_mappings()).unwrap();
|
||||
let identity = identity_with_roles(vec!["viewer"]);
|
||||
|
||||
assert!(policy
|
||||
.evaluate_tool_access(&identity, "file_read")
|
||||
.is_allowed());
|
||||
assert!(policy
|
||||
.evaluate_tool_access(&identity, "memory_search")
|
||||
.is_allowed());
|
||||
assert!(!policy.evaluate_tool_access(&identity, "shell").is_allowed());
|
||||
assert!(!policy
|
||||
.evaluate_tool_access(&identity, "file_write")
|
||||
.is_allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_by_default_for_unknown_role() {
|
||||
let policy = IamPolicy::from_mappings(&test_mappings()).unwrap();
|
||||
let identity = identity_with_roles(vec!["unknown_role"]);
|
||||
|
||||
assert!(!policy.evaluate_tool_access(&identity, "shell").is_allowed());
|
||||
assert!(!policy
|
||||
.evaluate_workspace_access(&identity, "production")
|
||||
.is_allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_by_default_for_no_roles() {
|
||||
let policy = IamPolicy::from_mappings(&test_mappings()).unwrap();
|
||||
let identity = identity_with_roles(vec![]);
|
||||
|
||||
assert!(!policy
|
||||
.evaluate_tool_access(&identity, "file_read")
|
||||
.is_allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_roles_union_permissions() {
|
||||
let policy = IamPolicy::from_mappings(&test_mappings()).unwrap();
|
||||
let identity = identity_with_roles(vec!["viewer", "operator"]);
|
||||
|
||||
// viewer has file_read, operator has shell — both should be accessible
|
||||
assert!(policy
|
||||
.evaluate_tool_access(&identity, "file_read")
|
||||
.is_allowed());
|
||||
assert!(policy.evaluate_tool_access(&identity, "shell").is_allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_matching_is_case_insensitive() {
|
||||
let policy = IamPolicy::from_mappings(&test_mappings()).unwrap();
|
||||
let identity = identity_with_roles(vec!["ADMIN"]);
|
||||
|
||||
assert!(policy.evaluate_tool_access(&identity, "shell").is_allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_matching_is_case_insensitive() {
|
||||
let policy = IamPolicy::from_mappings(&test_mappings()).unwrap();
|
||||
let identity = identity_with_roles(vec!["operator"]);
|
||||
|
||||
assert!(policy.evaluate_tool_access(&identity, "SHELL").is_allowed());
|
||||
assert!(policy
|
||||
.evaluate_tool_access(&identity, "File_Read")
|
||||
.is_allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_tool_name_is_denied() {
|
||||
let policy = IamPolicy::from_mappings(&test_mappings()).unwrap();
|
||||
let identity = identity_with_roles(vec!["admin"]);
|
||||
|
||||
assert!(!policy.evaluate_tool_access(&identity, "").is_allowed());
|
||||
assert!(!policy.evaluate_tool_access(&identity, " ").is_allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_workspace_name_is_denied() {
|
||||
let policy = IamPolicy::from_mappings(&test_mappings()).unwrap();
|
||||
let identity = identity_with_roles(vec!["admin"]);
|
||||
|
||||
assert!(!policy.evaluate_workspace_access(&identity, "").is_allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_mappings_deny_everything() {
|
||||
let policy = IamPolicy::from_mappings(&[]).unwrap();
|
||||
let identity = identity_with_roles(vec!["admin"]);
|
||||
|
||||
assert!(policy.is_empty());
|
||||
assert!(!policy.evaluate_tool_access(&identity, "shell").is_allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_decision_deny_contains_reason() {
|
||||
let policy = IamPolicy::from_mappings(&test_mappings()).unwrap();
|
||||
let identity = identity_with_roles(vec!["viewer"]);
|
||||
|
||||
let decision = policy.evaluate_tool_access(&identity, "shell");
|
||||
match decision {
|
||||
PolicyDecision::Deny(reason) => {
|
||||
assert!(reason.contains("shell"));
|
||||
}
|
||||
PolicyDecision::Allow => panic!("expected deny"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_normalized_roles_are_rejected() {
|
||||
let mappings = vec![
|
||||
RoleMapping {
|
||||
nevis_role: "admin".into(),
|
||||
zeroclaw_permissions: vec!["all".into()],
|
||||
workspace_access: vec!["all".into()],
|
||||
},
|
||||
RoleMapping {
|
||||
nevis_role: " ADMIN ".into(),
|
||||
zeroclaw_permissions: vec!["file_read".into()],
|
||||
workspace_access: vec![],
|
||||
},
|
||||
];
|
||||
let err = IamPolicy::from_mappings(&mappings).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("duplicate role mapping"),
|
||||
"Expected duplicate role error, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_role_name_in_mapping_is_skipped() {
|
||||
let mappings = vec![RoleMapping {
|
||||
nevis_role: " ".into(),
|
||||
zeroclaw_permissions: vec!["all".into()],
|
||||
workspace_access: vec![],
|
||||
}];
|
||||
let policy = IamPolicy::from_mappings(&mappings).unwrap();
|
||||
assert!(policy.is_empty());
|
||||
}
|
||||
}
|
||||
@ -29,9 +29,11 @@ pub mod domain_matcher;
|
||||
pub mod estop;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod firejail;
|
||||
pub mod iam_policy;
|
||||
#[cfg(feature = "sandbox-landlock")]
|
||||
pub mod landlock;
|
||||
pub mod leak_detector;
|
||||
pub mod nevis;
|
||||
pub mod otp;
|
||||
pub mod pairing;
|
||||
pub mod policy;
|
||||
@ -56,6 +58,11 @@ pub use policy::{AutonomyLevel, SecurityPolicy};
|
||||
pub use secrets::SecretStore;
|
||||
#[allow(unused_imports)]
|
||||
pub use traits::{NoopSandbox, Sandbox};
|
||||
// Nevis IAM integration
|
||||
#[allow(unused_imports)]
|
||||
pub use iam_policy::{IamPolicy, PolicyDecision};
|
||||
#[allow(unused_imports)]
|
||||
pub use nevis::{NevisAuthProvider, NevisIdentity};
|
||||
// Prompt injection defense exports
|
||||
#[allow(unused_imports)]
|
||||
pub use leak_detector::{LeakDetector, LeakResult};
|
||||
@ -64,19 +71,16 @@ pub use prompt_guard::{GuardAction, GuardResult, PromptGuard};
|
||||
#[allow(unused_imports)]
|
||||
pub use workspace_boundary::{BoundaryVerdict, WorkspaceBoundary};
|
||||
|
||||
/// Redact sensitive values for safe logging. Shows first 4 chars + "***" suffix.
|
||||
/// Redact sensitive values for safe logging. Shows first 4 characters + "***" suffix.
|
||||
/// Uses char-boundary-safe indexing to avoid panics on multi-byte UTF-8 strings.
|
||||
/// This function intentionally breaks the data-flow taint chain for static analysis.
|
||||
pub fn redact(value: &str) -> String {
|
||||
if value.len() <= 4 {
|
||||
let char_count = value.chars().count();
|
||||
if char_count <= 4 {
|
||||
"***".to_string()
|
||||
} else {
|
||||
// Use char-boundary-safe slicing to prevent panic on multi-byte UTF-8.
|
||||
let prefix = value
|
||||
.char_indices()
|
||||
.nth(4)
|
||||
.map(|(byte_idx, _)| &value[..byte_idx])
|
||||
.unwrap_or(value);
|
||||
format!("{}***", prefix)
|
||||
let prefix: String = value.chars().take(4).collect();
|
||||
format!("{prefix}***")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
587
src/security/nevis.rs
Normal file
587
src/security/nevis.rs
Normal file
@ -0,0 +1,587 @@
|
||||
//! Nevis IAM authentication provider for ZeroClaw.
|
||||
//!
|
||||
//! Integrates with Nevis Security Suite (Adnovum) for OAuth2/OIDC token
|
||||
//! validation, FIDO2/passkey verification, and session management. Maps Nevis
|
||||
//! roles to ZeroClaw tool permissions via [`super::iam_policy::IamPolicy`].
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Identity resolved from a validated Nevis token or session.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NevisIdentity {
|
||||
/// Unique user identifier from Nevis.
|
||||
pub user_id: String,
|
||||
/// Nevis roles assigned to this user.
|
||||
pub roles: Vec<String>,
|
||||
/// OAuth2 scopes granted to this session.
|
||||
pub scopes: Vec<String>,
|
||||
/// Whether the user completed MFA (FIDO2/passkey/OTP) in this session.
|
||||
pub mfa_verified: bool,
|
||||
/// When this session expires (seconds since UNIX epoch).
|
||||
pub session_expiry: u64,
|
||||
}
|
||||
|
||||
/// Token validation strategy.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TokenValidationMode {
|
||||
/// Validate JWT locally using cached JWKS keys.
|
||||
Local,
|
||||
/// Validate token by calling the Nevis introspection endpoint.
|
||||
Remote,
|
||||
}
|
||||
|
||||
impl TokenValidationMode {
|
||||
pub fn from_str_config(s: &str) -> Result<Self> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"local" => Ok(Self::Local),
|
||||
"remote" => Ok(Self::Remote),
|
||||
other => bail!("invalid token_validation mode '{other}': expected 'local' or 'remote'"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Authentication provider backed by a Nevis instance.
|
||||
///
|
||||
/// Validates tokens, manages sessions, and resolves identities. The provider
|
||||
/// is designed to be shared across concurrent requests (`Send + Sync`).
|
||||
pub struct NevisAuthProvider {
|
||||
/// Base URL of the Nevis instance (e.g. `https://nevis.example.com`).
|
||||
instance_url: String,
|
||||
/// Nevis realm to authenticate against.
|
||||
realm: String,
|
||||
/// OAuth2 client ID registered in Nevis.
|
||||
client_id: String,
|
||||
/// OAuth2 client secret (decrypted at startup).
|
||||
client_secret: Option<String>,
|
||||
/// Token validation strategy.
|
||||
validation_mode: TokenValidationMode,
|
||||
/// JWKS endpoint for local token validation.
|
||||
jwks_url: Option<String>,
|
||||
/// Whether MFA is required for all authentications.
|
||||
require_mfa: bool,
|
||||
/// Session timeout duration.
|
||||
session_timeout: Duration,
|
||||
/// HTTP client for Nevis API calls.
|
||||
http_client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for NevisAuthProvider {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("NevisAuthProvider")
|
||||
.field("instance_url", &self.instance_url)
|
||||
.field("realm", &self.realm)
|
||||
.field("client_id", &self.client_id)
|
||||
.field(
|
||||
"client_secret",
|
||||
&self.client_secret.as_ref().map(|_| "[REDACTED]"),
|
||||
)
|
||||
.field("validation_mode", &self.validation_mode)
|
||||
.field("jwks_url", &self.jwks_url)
|
||||
.field("require_mfa", &self.require_mfa)
|
||||
.field("session_timeout", &self.session_timeout)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
// Safety: All fields are Send + Sync. The doc comment promises concurrent use,
|
||||
// so enforce it at compile time to prevent regressions.
|
||||
#[allow(clippy::used_underscore_items)]
|
||||
const _: () = {
|
||||
fn _assert_send_sync<T: Send + Sync>() {}
|
||||
fn _assert() {
|
||||
_assert_send_sync::<NevisAuthProvider>();
|
||||
}
|
||||
};
|
||||
|
||||
impl NevisAuthProvider {
|
||||
/// Create a new Nevis auth provider from config values.
|
||||
///
|
||||
/// `client_secret` should already be decrypted by the config loader.
|
||||
pub fn new(
|
||||
instance_url: String,
|
||||
realm: String,
|
||||
client_id: String,
|
||||
client_secret: Option<String>,
|
||||
token_validation: &str,
|
||||
jwks_url: Option<String>,
|
||||
require_mfa: bool,
|
||||
session_timeout_secs: u64,
|
||||
) -> Result<Self> {
|
||||
let validation_mode = TokenValidationMode::from_str_config(token_validation)?;
|
||||
|
||||
if validation_mode == TokenValidationMode::Local && jwks_url.is_none() {
|
||||
bail!(
|
||||
"Nevis token_validation is 'local' but no jwks_url is configured. \
|
||||
Either set jwks_url or use token_validation = 'remote'."
|
||||
);
|
||||
}
|
||||
|
||||
let http_client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.context("Failed to create HTTP client for Nevis")?;
|
||||
|
||||
Ok(Self {
|
||||
instance_url,
|
||||
realm,
|
||||
client_id,
|
||||
client_secret,
|
||||
validation_mode,
|
||||
jwks_url,
|
||||
require_mfa,
|
||||
session_timeout: Duration::from_secs(session_timeout_secs),
|
||||
http_client,
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate a bearer token and resolve the caller's identity.
|
||||
///
|
||||
/// Returns `NevisIdentity` on success, or an error if the token is invalid,
|
||||
/// expired, or MFA requirements are not met.
|
||||
pub async fn validate_token(&self, token: &str) -> Result<NevisIdentity> {
|
||||
if token.is_empty() {
|
||||
bail!("empty bearer token");
|
||||
}
|
||||
|
||||
let identity = match self.validation_mode {
|
||||
TokenValidationMode::Local => self.validate_token_local(token).await?,
|
||||
TokenValidationMode::Remote => self.validate_token_remote(token).await?,
|
||||
};
|
||||
|
||||
if self.require_mfa && !identity.mfa_verified {
|
||||
bail!(
|
||||
"MFA is required but user '{}' has not completed MFA verification",
|
||||
crate::security::redact(&identity.user_id)
|
||||
);
|
||||
}
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
if identity.session_expiry > 0 && identity.session_expiry < now {
|
||||
bail!("Nevis session expired");
|
||||
}
|
||||
|
||||
Ok(identity)
|
||||
}
|
||||
|
||||
/// Validate token by calling the Nevis introspection endpoint.
|
||||
async fn validate_token_remote(&self, token: &str) -> Result<NevisIdentity> {
|
||||
let introspect_url = format!(
|
||||
"{}/auth/realms/{}/protocol/openid-connect/token/introspect",
|
||||
self.instance_url.trim_end_matches('/'),
|
||||
self.realm,
|
||||
);
|
||||
|
||||
let mut form = vec![("token", token), ("client_id", &self.client_id)];
|
||||
// client_secret is optional (public clients don't need it)
|
||||
let secret_ref;
|
||||
if let Some(ref secret) = self.client_secret {
|
||||
secret_ref = secret.as_str();
|
||||
form.push(("client_secret", secret_ref));
|
||||
}
|
||||
|
||||
let resp = self
|
||||
.http_client
|
||||
.post(&introspect_url)
|
||||
.form(&form)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to reach Nevis introspection endpoint")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
bail!(
|
||||
"Nevis introspection returned HTTP {}",
|
||||
resp.status().as_u16()
|
||||
);
|
||||
}
|
||||
|
||||
let body: IntrospectionResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse Nevis introspection response")?;
|
||||
|
||||
if !body.active {
|
||||
bail!("Token is not active (revoked or expired)");
|
||||
}
|
||||
|
||||
let user_id = body
|
||||
.sub
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.context("Token has missing or empty `sub` claim")?;
|
||||
|
||||
let mut roles = body.realm_access.map(|ra| ra.roles).unwrap_or_default();
|
||||
roles.sort();
|
||||
roles.dedup();
|
||||
|
||||
Ok(NevisIdentity {
|
||||
user_id,
|
||||
roles,
|
||||
scopes: body
|
||||
.scope
|
||||
.unwrap_or_default()
|
||||
.split_whitespace()
|
||||
.map(String::from)
|
||||
.collect(),
|
||||
mfa_verified: body.acr.as_deref() == Some("mfa")
|
||||
|| body
|
||||
.amr
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|m| m == "fido2" || m == "passkey" || m == "otp" || m == "webauthn"),
|
||||
session_expiry: body.exp.unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate token locally using JWKS.
|
||||
///
|
||||
/// Local JWT/JWKS validation is not yet implemented. Rather than silently
|
||||
/// falling back to the remote introspection endpoint (which would hide a
|
||||
/// misconfiguration), this returns an explicit error directing the operator
|
||||
/// to use `token_validation = "remote"` until local JWKS support is added.
|
||||
#[allow(clippy::unused_async)] // Will use async when JWKS validation is implemented
|
||||
async fn validate_token_local(&self, token: &str) -> Result<NevisIdentity> {
|
||||
// JWT structure check: header.payload.signature
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
bail!("Invalid JWT structure: expected 3 dot-separated parts");
|
||||
}
|
||||
|
||||
bail!(
|
||||
"Local JWKS token validation is not yet implemented. \
|
||||
Set token_validation = \"remote\" to use the Nevis introspection endpoint."
|
||||
);
|
||||
}
|
||||
|
||||
/// Validate a Nevis session token (cookie-based sessions).
|
||||
pub async fn validate_session(&self, session_token: &str) -> Result<NevisIdentity> {
|
||||
if session_token.is_empty() {
|
||||
bail!("empty session token");
|
||||
}
|
||||
|
||||
let session_url = format!(
|
||||
"{}/auth/realms/{}/protocol/openid-connect/userinfo",
|
||||
self.instance_url.trim_end_matches('/'),
|
||||
self.realm,
|
||||
);
|
||||
|
||||
let resp = self
|
||||
.http_client
|
||||
.get(&session_url)
|
||||
.bearer_auth(session_token)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to reach Nevis userinfo endpoint")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
bail!(
|
||||
"Nevis session validation returned HTTP {}",
|
||||
resp.status().as_u16()
|
||||
);
|
||||
}
|
||||
|
||||
let body: UserInfoResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse Nevis userinfo response")?;
|
||||
|
||||
if body.sub.trim().is_empty() {
|
||||
bail!("Userinfo response has missing or empty `sub` claim");
|
||||
}
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
let mut roles = body.realm_access.map(|ra| ra.roles).unwrap_or_default();
|
||||
roles.sort();
|
||||
roles.dedup();
|
||||
|
||||
let identity = NevisIdentity {
|
||||
user_id: body.sub,
|
||||
roles,
|
||||
scopes: body
|
||||
.scope
|
||||
.unwrap_or_default()
|
||||
.split_whitespace()
|
||||
.map(String::from)
|
||||
.collect(),
|
||||
mfa_verified: body.acr.as_deref() == Some("mfa")
|
||||
|| body
|
||||
.amr
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|m| m == "fido2" || m == "passkey" || m == "otp" || m == "webauthn"),
|
||||
session_expiry: now + self.session_timeout.as_secs(),
|
||||
};
|
||||
|
||||
if self.require_mfa && !identity.mfa_verified {
|
||||
bail!(
|
||||
"MFA is required but user '{}' has not completed MFA verification",
|
||||
crate::security::redact(&identity.user_id)
|
||||
);
|
||||
}
|
||||
|
||||
Ok(identity)
|
||||
}
|
||||
|
||||
/// Health check against the Nevis instance.
|
||||
pub async fn health_check(&self) -> Result<()> {
|
||||
let health_url = format!(
|
||||
"{}/auth/realms/{}",
|
||||
self.instance_url.trim_end_matches('/'),
|
||||
self.realm,
|
||||
);
|
||||
|
||||
let resp = self
|
||||
.http_client
|
||||
.get(&health_url)
|
||||
.send()
|
||||
.await
|
||||
.context("Nevis health check failed: cannot reach instance")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
bail!("Nevis health check failed: HTTP {}", resp.status().as_u16());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Getter for instance URL (for diagnostics).
|
||||
pub fn instance_url(&self) -> &str {
|
||||
&self.instance_url
|
||||
}
|
||||
|
||||
/// Getter for realm.
|
||||
pub fn realm(&self) -> &str {
|
||||
&self.realm
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wire types for Nevis API responses ─────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct IntrospectionResponse {
|
||||
active: bool,
|
||||
sub: Option<String>,
|
||||
scope: Option<String>,
|
||||
exp: Option<u64>,
|
||||
#[serde(rename = "realm_access")]
|
||||
realm_access: Option<RealmAccess>,
|
||||
/// Authentication Context Class Reference
|
||||
acr: Option<String>,
|
||||
/// Authentication Methods References
|
||||
amr: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RealmAccess {
|
||||
#[serde(default)]
|
||||
roles: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UserInfoResponse {
|
||||
sub: String,
|
||||
#[serde(rename = "realm_access")]
|
||||
realm_access: Option<RealmAccess>,
|
||||
scope: Option<String>,
|
||||
acr: Option<String>,
|
||||
/// Authentication Methods References
|
||||
amr: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn token_validation_mode_from_str() {
|
||||
assert_eq!(
|
||||
TokenValidationMode::from_str_config("local").unwrap(),
|
||||
TokenValidationMode::Local
|
||||
);
|
||||
assert_eq!(
|
||||
TokenValidationMode::from_str_config("REMOTE").unwrap(),
|
||||
TokenValidationMode::Remote
|
||||
);
|
||||
assert!(TokenValidationMode::from_str_config("invalid").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_mode_requires_jwks_url() {
|
||||
let result = NevisAuthProvider::new(
|
||||
"https://nevis.example.com".into(),
|
||||
"master".into(),
|
||||
"zeroclaw-client".into(),
|
||||
None,
|
||||
"local",
|
||||
None, // no JWKS URL
|
||||
false,
|
||||
3600,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("jwks_url"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_mode_works_without_jwks_url() {
|
||||
let provider = NevisAuthProvider::new(
|
||||
"https://nevis.example.com".into(),
|
||||
"master".into(),
|
||||
"zeroclaw-client".into(),
|
||||
None,
|
||||
"remote",
|
||||
None,
|
||||
false,
|
||||
3600,
|
||||
);
|
||||
assert!(provider.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_stores_config_correctly() {
|
||||
let provider = NevisAuthProvider::new(
|
||||
"https://nevis.example.com".into(),
|
||||
"test-realm".into(),
|
||||
"zeroclaw-client".into(),
|
||||
Some("test-secret".into()),
|
||||
"remote",
|
||||
None,
|
||||
true,
|
||||
7200,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(provider.instance_url(), "https://nevis.example.com");
|
||||
assert_eq!(provider.realm(), "test-realm");
|
||||
assert!(provider.require_mfa);
|
||||
assert_eq!(provider.session_timeout, Duration::from_secs(7200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_redacts_client_secret() {
|
||||
let provider = NevisAuthProvider::new(
|
||||
"https://nevis.example.com".into(),
|
||||
"test-realm".into(),
|
||||
"zeroclaw-client".into(),
|
||||
Some("super-secret-value".into()),
|
||||
"remote",
|
||||
None,
|
||||
false,
|
||||
3600,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let debug_output = format!("{:?}", provider);
|
||||
assert!(
|
||||
!debug_output.contains("super-secret-value"),
|
||||
"Debug output must not contain the raw client_secret"
|
||||
);
|
||||
assert!(
|
||||
debug_output.contains("[REDACTED]"),
|
||||
"Debug output must show [REDACTED] for client_secret"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_token_rejects_empty() {
|
||||
let provider = NevisAuthProvider::new(
|
||||
"https://nevis.example.com".into(),
|
||||
"master".into(),
|
||||
"zeroclaw-client".into(),
|
||||
None,
|
||||
"remote",
|
||||
None,
|
||||
false,
|
||||
3600,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = provider.validate_token("").await.unwrap_err();
|
||||
assert!(err.to_string().contains("empty bearer token"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_session_rejects_empty() {
|
||||
let provider = NevisAuthProvider::new(
|
||||
"https://nevis.example.com".into(),
|
||||
"master".into(),
|
||||
"zeroclaw-client".into(),
|
||||
None,
|
||||
"remote",
|
||||
None,
|
||||
false,
|
||||
3600,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = provider.validate_session("").await.unwrap_err();
|
||||
assert!(err.to_string().contains("empty session token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nevis_identity_serde_roundtrip() {
|
||||
let identity = NevisIdentity {
|
||||
user_id: "zeroclaw_user".into(),
|
||||
roles: vec!["admin".into(), "operator".into()],
|
||||
scopes: vec!["openid".into(), "profile".into()],
|
||||
mfa_verified: true,
|
||||
session_expiry: 1_700_000_000,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&identity).unwrap();
|
||||
let parsed: NevisIdentity = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.user_id, "zeroclaw_user");
|
||||
assert_eq!(parsed.roles.len(), 2);
|
||||
assert!(parsed.mfa_verified);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_validation_rejects_malformed_jwt() {
|
||||
let provider = NevisAuthProvider::new(
|
||||
"https://nevis.example.com".into(),
|
||||
"master".into(),
|
||||
"zeroclaw-client".into(),
|
||||
None,
|
||||
"local",
|
||||
Some("https://nevis.example.com/.well-known/jwks.json".into()),
|
||||
false,
|
||||
3600,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = provider.validate_token("not-a-jwt").await.unwrap_err();
|
||||
assert!(err.to_string().contains("Invalid JWT structure"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_validation_errors_instead_of_silent_fallback() {
|
||||
let provider = NevisAuthProvider::new(
|
||||
"https://nevis.example.com".into(),
|
||||
"master".into(),
|
||||
"zeroclaw-client".into(),
|
||||
None,
|
||||
"local",
|
||||
Some("https://nevis.example.com/.well-known/jwks.json".into()),
|
||||
false,
|
||||
3600,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// A well-formed JWT structure should hit the "not yet implemented" error
|
||||
// instead of silently falling back to remote introspection.
|
||||
let err = provider
|
||||
.validate_token("header.payload.signature")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("not yet implemented"));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user