diff --git a/src/config/schema.rs b/src/config/schema.rs index 490891b18..e39e1233b 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -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, + + /// 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, + + /// Nevis role to ZeroClaw permission mappings. + #[serde(default)] + pub role_mapping: Vec, + + /// 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, + + /// Workspace names this role can access. Use `"all"` for unrestricted. + #[serde(default)] + pub workspace_access: Vec, +} + /// 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" + ); + } } diff --git a/src/cron/scheduler.rs b/src/cron/scheduler.rs index 290cfd482..992609f14 100644 --- a/src/cron/scheduler.rs +++ b/src/cron/scheduler.rs @@ -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")); diff --git a/src/security/iam_policy.rs b/src/security/iam_policy.rs new file mode 100644 index 000000000..36a5fab00 --- /dev/null +++ b/src/security/iam_policy.rs @@ -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, + /// Workspace names this role can access. Use `"all"` for unrestricted. + #[serde(default)] + pub workspace_access: Vec, +} + +/// 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, +} + +#[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, + /// Whether this role has access to all workspaces. + all_workspaces: bool, + /// Specific workspace names this role can access (lowercase). + allowed_workspaces: Vec, +} + +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 { + 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 = 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 = 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 { + 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()); + } +} diff --git a/src/security/mod.rs b/src/security/mod.rs index 37f62c531..f80268427 100644 --- a/src/security/mod.rs +++ b/src/security/mod.rs @@ -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}***") } } diff --git a/src/security/nevis.rs b/src/security/nevis.rs new file mode 100644 index 000000000..f6b5ef109 --- /dev/null +++ b/src/security/nevis.rs @@ -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, + /// OAuth2 scopes granted to this session. + pub scopes: Vec, + /// 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 { + 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, + /// Token validation strategy. + validation_mode: TokenValidationMode, + /// JWKS endpoint for local token validation. + jwks_url: Option, + /// 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() {} + fn _assert() { + _assert_send_sync::(); + } +}; + +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, + token_validation: &str, + jwks_url: Option, + require_mfa: bool, + session_timeout_secs: u64, + ) -> Result { + 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 { + 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 { + 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 { + // 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 { + 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, + scope: Option, + exp: Option, + #[serde(rename = "realm_access")] + realm_access: Option, + /// Authentication Context Class Reference + acr: Option, + /// Authentication Methods References + amr: Option>, +} + +#[derive(Debug, Deserialize)] +struct RealmAccess { + #[serde(default)] + roles: Vec, +} + +#[derive(Debug, Deserialize)] +struct UserInfoResponse { + sub: String, + #[serde(rename = "realm_access")] + realm_access: Option, + scope: Option, + acr: Option, + /// Authentication Methods References + amr: Option>, +} + +// ── 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")); + } +}