| use std::path::PathBuf; |
| use std::sync::{Arc, LazyLock}; |
|
|
| use anyhow::Context; |
| use bytes::Bytes; |
| use forge_app::domain::{ |
| ExecuteRule, Fetch, Permission, PermissionOperation, Policy, PolicyConfig, PolicyEngine, |
| ReadRule, Rule, WriteRule, |
| }; |
| use forge_app::{ |
| DirectoryReaderInfra, EnvironmentInfra, FileInfoInfra, FileReaderInfra, FileWriterInfra, |
| PolicyDecision, PolicyService, UserInfra, |
| }; |
| use strum_macros::{Display, EnumIter}; |
|
|
| |
| #[derive(Debug, Clone, PartialEq, Eq, Display, EnumIter, strum_macros::EnumString)] |
| pub enum PolicyPermission { |
| |
| #[strum(to_string = "Accept")] |
| Accept, |
| |
| #[strum(to_string = "Reject")] |
| Reject, |
| |
| #[strum(to_string = "Accept and Remember")] |
| AcceptAndRemember, |
| } |
|
|
| #[derive(Clone)] |
| pub struct ForgePolicyService<I> { |
| infra: Arc<I>, |
| } |
| |
| static DEFAULT_POLICIES: LazyLock<PolicyConfig> = LazyLock::new(|| { |
| let yaml_content = include_str!("./permissions.default.yaml"); |
| serde_yml::from_str(yaml_content).expect( |
| "Failed to parse default policies YAML. This should never happen as the YAML is embedded.", |
| ) |
| }); |
|
|
| impl<I> ForgePolicyService<I> |
| where |
| I: FileReaderInfra + FileWriterInfra + FileInfoInfra + EnvironmentInfra + DirectoryReaderInfra, |
| { |
| pub fn new(infra: Arc<I>) -> Self { |
| Self { infra } |
| } |
|
|
| fn permissions_path(&self) -> PathBuf { |
| self.infra.get_environment().permissions_path() |
| } |
|
|
| |
| |
| fn load_default_policies() -> PolicyConfig { |
| DEFAULT_POLICIES.clone() |
| } |
|
|
| |
| async fn add_policy_for_operation( |
| &self, |
| operation: &PermissionOperation, |
| ) -> anyhow::Result<Option<PathBuf>> |
| where |
| I: UserInfra, |
| { |
| if let Some(new_policy) = create_policy_for_operation(operation, None) { |
| |
| self.modify_policy(new_policy).await?; |
| Ok(Some(self.permissions_path())) |
| } else { |
| Ok(None) |
| } |
| } |
|
|
| |
| async fn read_policies(&self) -> anyhow::Result<Option<PolicyConfig>> { |
| let policies_path = self.permissions_path(); |
| if !self.infra.exists(&policies_path).await? { |
| return Ok(None); |
| } |
|
|
| let content = self.infra.read_utf8(&policies_path).await?; |
| let policies = serde_yml::from_str(&content) |
| .with_context(|| format!("Failed to parse policy {}", policies_path.display()))?; |
|
|
| Ok(Some(policies)) |
| } |
|
|
| |
| async fn modify_policy(&self, policy: Policy) -> anyhow::Result<()> { |
| let policies_path = self.permissions_path(); |
| let mut policies = self.read_policies().await?.unwrap_or_default(); |
|
|
| |
| policies = policies.add_policy(policy); |
|
|
| |
| let new_content = serde_yml::to_string(&policies) |
| .with_context(|| "Failed to serialize policies to YAML")?; |
|
|
| |
| self.infra |
| .write(&policies_path, Bytes::from(new_content.to_owned())) |
| .await?; |
|
|
| Ok(()) |
| } |
|
|
| |
| async fn init_policies(&self) -> anyhow::Result<()> { |
| let policies_path = self.permissions_path(); |
|
|
| |
| if self.infra.exists(&policies_path).await? { |
| return Ok(()); |
| } |
|
|
| |
| let default_policies = Self::load_default_policies(); |
| let content = serde_yml::to_string(&default_policies) |
| .with_context(|| "Failed to serialize default policies to YAML")?; |
|
|
| |
| self.infra |
| .write(&policies_path, Bytes::from(content)) |
| .await?; |
|
|
| Ok(()) |
| } |
|
|
| |
| #[async_recursion::async_recursion] |
| async fn get_or_create_policies(&self) -> anyhow::Result<(PolicyConfig, Option<PathBuf>)> |
| where |
| I: UserInfra, |
| { |
| if let Some(policies) = self.read_policies().await? { |
| Ok((policies, None)) |
| } else { |
| self.init_policies().await?; |
| let (policies, _) = self.get_or_create_policies().await?; |
| Ok((policies, Some(self.permissions_path()))) |
| } |
| } |
| } |
|
|
| #[async_trait::async_trait] |
| impl<I> PolicyService for ForgePolicyService<I> |
| where |
| I: FileReaderInfra |
| + FileWriterInfra |
| + FileInfoInfra |
| + EnvironmentInfra |
| + DirectoryReaderInfra |
| + UserInfra, |
| { |
| |
| |
| async fn check_operation_permission( |
| &self, |
| operation: &PermissionOperation, |
| ) -> anyhow::Result<PolicyDecision> { |
| let (policies, path) = self.get_or_create_policies().await?; |
|
|
| let engine = PolicyEngine::new(&policies); |
| let permission = engine.can_perform(operation); |
|
|
| match permission { |
| Permission::Deny => Ok(PolicyDecision { allowed: false, path }), |
| Permission::Allow => Ok(PolicyDecision { allowed: true, path }), |
| Permission::Confirm => { |
| |
| let confirmation_msg = match operation { |
| PermissionOperation::Read { message, .. } => { |
| format!("{message}. How would you like to proceed?") |
| } |
| PermissionOperation::Write { message, .. } => { |
| format!("{message}. How would you like to proceed?") |
| } |
| PermissionOperation::Execute { .. } => { |
| "How would you like to proceed?".to_string() |
| } |
| PermissionOperation::Fetch { message, .. } => { |
| format!("{message}. How would you like to proceed?") |
| } |
| }; |
|
|
| match self |
| .infra |
| .select_one_enum::<PolicyPermission>(&confirmation_msg) |
| .await? |
| { |
| Some(PolicyPermission::Accept) => Ok(PolicyDecision { allowed: true, path }), |
| Some(PolicyPermission::AcceptAndRemember) => { |
| let update_path = self.add_policy_for_operation(operation).await?; |
| Ok(PolicyDecision { allowed: true, path: update_path.or(path) }) |
| } |
| Some(PolicyPermission::Reject) | None => { |
| Ok(PolicyDecision { allowed: false, path }) |
| } |
| } |
| } |
| } |
| } |
| } |
|
|
| |
| fn create_policy_for_operation( |
| operation: &PermissionOperation, |
| dir: Option<String>, |
| ) -> Option<Policy> { |
| fn create_file_policy( |
| path: &std::path::Path, |
| rule_constructor: fn(String) -> Rule, |
| ) -> Option<Policy> { |
| path.extension() |
| .and_then(|ext| ext.to_str()) |
| .map(|extension| Policy::Simple { |
| permission: Permission::Allow, |
| rule: rule_constructor(format!("*.{extension}")), |
| }) |
| } |
|
|
| match operation { |
| PermissionOperation::Read { path, cwd: _, message: _ } => { |
| create_file_policy(path, |pattern| { |
| Rule::Read(ReadRule { read: pattern, dir: None }) |
| }) |
| } |
| PermissionOperation::Write { path, cwd: _, message: _ } => { |
| create_file_policy(path, |pattern| { |
| Rule::Write(WriteRule { write: pattern, dir: None }) |
| }) |
| } |
|
|
| PermissionOperation::Fetch { url, cwd: _, message: _ } => { |
| if let Ok(parsed_url) = url::Url::parse(url) { |
| parsed_url.host_str().map(|host| Policy::Simple { |
| permission: Permission::Allow, |
| rule: Rule::Fetch(Fetch { url: format!("{host}*"), dir: None }), |
| }) |
| } else { |
| Some(Policy::Simple { |
| permission: Permission::Allow, |
| rule: Rule::Fetch(Fetch { url: url.to_string(), dir: None }), |
| }) |
| } |
| } |
| PermissionOperation::Execute { command, cwd: _ } => { |
| let parts: Vec<&str> = command.split_whitespace().collect(); |
| match parts.as_slice() { |
| [] => None, |
| [cmd] => Some(Policy::Simple { |
| permission: Permission::Allow, |
| rule: Rule::Execute(ExecuteRule { command: format!("{cmd}*"), dir }), |
| }), |
| [cmd, subcmd, ..] => Some(Policy::Simple { |
| permission: Permission::Allow, |
| rule: Rule::Execute(ExecuteRule { command: format!("{cmd} {subcmd}*"), dir }), |
| }), |
| } |
| } |
| } |
| } |
|
|
| #[cfg(test)] |
| mod tests { |
| use pretty_assertions::assert_eq; |
|
|
| use super::*; |
|
|
| #[test] |
| fn test_create_policy_for_read_operation() { |
| let path = PathBuf::from("/path/to/file.rs"); |
| let operation = PermissionOperation::Read { |
| path, |
| cwd: std::path::PathBuf::from("/test/cwd"), |
| message: "Read file: /path/to/file.rs".to_string(), |
| }; |
|
|
| let actual = create_policy_for_operation(&operation, None); |
|
|
| let expected = Some(Policy::Simple { |
| permission: Permission::Allow, |
| rule: Rule::Read(ReadRule { read: "*.rs".to_string(), dir: None }), |
| }); |
|
|
| assert_eq!(actual, expected); |
| } |
|
|
| #[test] |
| fn test_create_policy_for_write_operation() { |
| let path = PathBuf::from("/path/to/file.json"); |
| let operation = PermissionOperation::Write { |
| path, |
| cwd: std::path::PathBuf::from("/test/cwd"), |
| message: "Create/overwrite file: /path/to/file.json".to_string(), |
| }; |
|
|
| let actual = create_policy_for_operation(&operation, None); |
|
|
| let expected = Some(Policy::Simple { |
| permission: Permission::Allow, |
| rule: Rule::Write(WriteRule { write: "*.json".to_string(), dir: None }), |
| }); |
|
|
| assert_eq!(actual, expected); |
| } |
|
|
| #[test] |
| fn test_create_policy_for_write_patch_operation() { |
| let path = PathBuf::from("/path/to/file.toml"); |
| let operation = PermissionOperation::Write { |
| path, |
| cwd: std::path::PathBuf::from("/test/cwd"), |
| message: "Modify file: /path/to/file.toml".to_string(), |
| }; |
|
|
| let actual = create_policy_for_operation(&operation, None); |
|
|
| let expected = Some(Policy::Simple { |
| permission: Permission::Allow, |
| rule: Rule::Write(WriteRule { write: "*.toml".to_string(), dir: None }), |
| }); |
|
|
| assert_eq!(actual, expected); |
| } |
|
|
| #[test] |
| fn test_create_policy_for_net_fetch_operation() { |
| let url = "https://example.com/api/data".to_string(); |
| let operation = PermissionOperation::Fetch { |
| url, |
| cwd: std::path::PathBuf::from("/test/cwd"), |
| message: "Fetch content from URL: https://example.com/api/data".to_string(), |
| }; |
|
|
| let actual = create_policy_for_operation(&operation, None); |
|
|
| let expected = Some(Policy::Simple { |
| permission: Permission::Allow, |
| rule: Rule::Fetch(Fetch { url: "example.com*".to_string(), dir: None }), |
| }); |
|
|
| assert_eq!(actual, expected); |
| } |
|
|
| #[test] |
| fn test_create_policy_for_execute_operation_with_subcommand() { |
| let command = "git push origin main".to_string(); |
| let operation = |
| PermissionOperation::Execute { command, cwd: std::path::PathBuf::from("/test/cwd") }; |
|
|
| let actual = create_policy_for_operation(&operation, None); |
|
|
| let expected = Some(Policy::Simple { |
| permission: Permission::Allow, |
| rule: Rule::Execute(ExecuteRule { command: "git push*".to_string(), dir: None }), |
| }); |
|
|
| assert_eq!(actual, expected); |
| } |
|
|
| #[test] |
| fn test_create_policy_for_execute_operation_single_command() { |
| let command = "ls".to_string(); |
| let operation = |
| PermissionOperation::Execute { command, cwd: std::path::PathBuf::from("/test/cwd") }; |
|
|
| let actual = create_policy_for_operation(&operation, None); |
|
|
| let expected = Some(Policy::Simple { |
| permission: Permission::Allow, |
| rule: Rule::Execute(ExecuteRule { command: "ls*".to_string(), dir: None }), |
| }); |
|
|
| assert_eq!(actual, expected); |
| } |
|
|
| #[test] |
| fn test_create_policy_for_file_without_extension() { |
| let path = PathBuf::from("/path/to/file"); |
| let operation = PermissionOperation::Read { |
| path, |
| cwd: std::path::PathBuf::from("/test/cwd"), |
| message: "Read file: /path/to/file".to_string(), |
| }; |
|
|
| let actual = create_policy_for_operation(&operation, None); |
|
|
| let expected = None; |
|
|
| assert_eq!(actual, expected); |
| } |
|
|
| #[test] |
| fn test_create_policy_for_invalid_url() { |
| let url = "not-a-valid-url".to_string(); |
| let operation = PermissionOperation::Fetch { |
| url, |
| cwd: std::path::PathBuf::from("/test/cwd"), |
| message: "Fetch content from URL: not-a-valid-url".to_string(), |
| }; |
|
|
| let actual = create_policy_for_operation(&operation, None); |
|
|
| let expected = Some(Policy::Simple { |
| permission: Permission::Allow, |
| rule: Rule::Fetch(Fetch { url: "not-a-valid-url".to_string(), dir: None }), |
| }); |
|
|
| assert_eq!(actual, expected); |
| } |
|
|
| #[test] |
| fn test_create_policy_for_empty_execute_command() { |
| let command = "".to_string(); |
| let operation = |
| PermissionOperation::Execute { command, cwd: std::path::PathBuf::from("/test/cwd") }; |
|
|
| let actual = create_policy_for_operation(&operation, None); |
|
|
| let expected = None; |
|
|
| assert_eq!(actual, expected); |
| } |
|
|
| #[test] |
| fn test_create_policy_for_execute_operation_with_working_directory() { |
| let command = "ls".to_string(); |
| let operation = |
| PermissionOperation::Execute { command, cwd: std::path::PathBuf::from("/test/cwd") }; |
| let working_directory = Some("/home/user/project".to_string()); |
|
|
| let actual = create_policy_for_operation(&operation, working_directory.clone()); |
|
|
| let expected = Some(Policy::Simple { |
| permission: Permission::Allow, |
| rule: Rule::Execute(ExecuteRule { command: "ls*".to_string(), dir: working_directory }), |
| }); |
|
|
| assert_eq!(actual, expected); |
| } |
| } |
|
|