Skip to content

Implement file transfer filtering infrastructure #138

@inureyes

Description

@inureyes

Summary

Implement a file transfer filtering infrastructure that can allow, deny, or log file transfers based on configurable policies.

Parent Epic

Implementation Details

1. Filter Trait and Types

// src/server/filter/mod.rs
pub mod policy;
pub mod path;
pub mod pattern;

use std::path::Path;

/// File transfer operation type
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Operation {
    Upload,
    Download,
    Delete,
    Rename,
    CreateDir,
    ListDir,
}

/// Result of filter check
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum FilterResult {
    /// Allow the operation
    Allow,
    /// Deny the operation
    Deny,
    /// Allow but log the operation
    Log,
}

/// Trait for file transfer filters
pub trait TransferFilter: Send + Sync {
    /// Check if operation is allowed
    fn check(&self, path: &Path, operation: Operation, user: &str) -> FilterResult;
    
    /// Check with destination (for rename/copy)
    fn check_with_dest(
        &self,
        src: &Path,
        dest: &Path,
        operation: Operation,
        user: &str,
    ) -> FilterResult {
        // Default: check both paths
        let src_result = self.check(src, operation, user);
        let dest_result = self.check(dest, operation, user);
        
        match (src_result, dest_result) {
            (FilterResult::Deny, _) | (_, FilterResult::Deny) => FilterResult::Deny,
            (FilterResult::Log, _) | (_, FilterResult::Log) => FilterResult::Log,
            _ => FilterResult::Allow,
        }
    }
}

2. Policy Engine

// src/server/filter/policy.rs
use super::*;

/// Filter policy engine
pub struct FilterPolicy {
    rules: Vec<FilterRule>,
    default_action: FilterResult,
}

#[derive(Debug, Clone)]
pub struct FilterRule {
    /// Rule name (for logging)
    pub name: Option<String>,
    /// Pattern matcher
    pub matcher: Box<dyn Matcher>,
    /// Action to take
    pub action: FilterResult,
    /// Operations this rule applies to
    pub operations: Option<Vec<Operation>>,
    /// Users this rule applies to (None = all users)
    pub users: Option<Vec<String>>,
}

pub trait Matcher: Send + Sync {
    fn matches(&self, path: &Path) -> bool;
    fn clone_box(&self) -> Box<dyn Matcher>;
}

impl Clone for Box<dyn Matcher> {
    fn clone(&self) -> Self {
        self.clone_box()
    }
}

impl FilterPolicy {
    pub fn new() -> Self {
        Self {
            rules: Vec::new(),
            default_action: FilterResult::Allow,
        }
    }

    pub fn with_default(mut self, action: FilterResult) -> Self {
        self.default_action = action;
        self
    }

    pub fn add_rule(mut self, rule: FilterRule) -> Self {
        self.rules.push(rule);
        self
    }

    /// Load policy from configuration
    pub fn from_config(config: &FilterConfig) -> Result<Self> {
        let mut policy = Self::new();
        
        for rule_config in &config.rules {
            let rule = Self::rule_from_config(rule_config)?;
            policy.rules.push(rule);
        }
        
        Ok(policy)
    }

    fn rule_from_config(config: &FilterRuleConfig) -> Result<FilterRule> {
        let matcher: Box<dyn Matcher> = if let Some(ref pattern) = config.pattern {
            Box::new(GlobMatcher::new(pattern)?)
        } else if let Some(ref prefix) = config.path_prefix {
            Box::new(PrefixMatcher::new(prefix))
        } else {
            anyhow::bail!("Rule must have pattern or path_prefix");
        };

        Ok(FilterRule {
            name: config.name.clone(),
            matcher,
            action: match config.action {
                FilterActionConfig::Allow => FilterResult::Allow,
                FilterActionConfig::Deny => FilterResult::Deny,
                FilterActionConfig::Log => FilterResult::Log,
            },
            operations: config.operations.clone(),
            users: config.users.clone(),
        })
    }
}

impl TransferFilter for FilterPolicy {
    fn check(&self, path: &Path, operation: Operation, user: &str) -> FilterResult {
        for rule in &self.rules {
            // Check if operation matches
            if let Some(ref ops) = rule.operations {
                if !ops.contains(&operation) {
                    continue;
                }
            }
            
            // Check if user matches
            if let Some(ref users) = rule.users {
                if !users.iter().any(|u| u == user) {
                    continue;
                }
            }
            
            // Check if path matches
            if rule.matcher.matches(path) {
                tracing::debug!(
                    "Filter rule {:?} matched path {:?}, action: {:?}",
                    rule.name,
                    path,
                    rule.action
                );
                return rule.action;
            }
        }
        
        self.default_action
    }
}

3. Built-in Matchers

// src/server/filter/pattern.rs
use glob::Pattern;

/// Glob pattern matcher
#[derive(Debug, Clone)]
pub struct GlobMatcher {
    pattern: Pattern,
    raw: String,
}

impl GlobMatcher {
    pub fn new(pattern: &str) -> Result<Self> {
        let pattern = Pattern::new(pattern)
            .context("Invalid glob pattern")?;
        Ok(Self {
            pattern,
            raw: pattern.to_string(),
        })
    }
}

impl Matcher for GlobMatcher {
    fn matches(&self, path: &Path) -> bool {
        self.pattern.matches_path(path)
    }
    
    fn clone_box(&self) -> Box<dyn Matcher> {
        Box::new(self.clone())
    }
}

// src/server/filter/path.rs

/// Path prefix matcher
#[derive(Debug, Clone)]
pub struct PrefixMatcher {
    prefix: PathBuf,
}

impl PrefixMatcher {
    pub fn new(prefix: &str) -> Self {
        Self {
            prefix: PathBuf::from(prefix),
        }
    }
}

impl Matcher for PrefixMatcher {
    fn matches(&self, path: &Path) -> bool {
        path.starts_with(&self.prefix)
    }
    
    fn clone_box(&self) -> Box<dyn Matcher> {
        Box::new(self.clone())
    }
}

/// Exact path matcher
#[derive(Debug, Clone)]
pub struct ExactMatcher {
    path: PathBuf,
}

impl Matcher for ExactMatcher {
    fn matches(&self, path: &Path) -> bool {
        path == self.path
    }
    
    fn clone_box(&self) -> Box<dyn Matcher> {
        Box::new(self.clone())
    }
}

/// Regex matcher
#[derive(Debug, Clone)]
pub struct RegexMatcher {
    regex: regex::Regex,
}

impl RegexMatcher {
    pub fn new(pattern: &str) -> Result<Self> {
        let regex = regex::Regex::new(pattern)?;
        Ok(Self { regex })
    }
}

impl Matcher for RegexMatcher {
    fn matches(&self, path: &Path) -> bool {
        self.regex.is_match(&path.to_string_lossy())
    }
    
    fn clone_box(&self) -> Box<dyn Matcher> {
        Box::new(self.clone())
    }
}

Configuration

filter:
  enabled: true
  default_action: allow  # allow, deny, or log
  rules:
    # Deny access to sensitive files
    - name: "block-secrets"
      pattern: "*.key"
      action: deny
      
    - name: "block-shadow"
      path_prefix: "/etc/shadow"
      action: deny
      
    # Log large file downloads
    - name: "log-archives"
      pattern: "*.{tar,tar.gz,zip}"
      action: log
      operations: [download]
      
    # Restrict certain users
    - name: "restrict-deploy"
      path_prefix: "/etc"
      action: deny
      users: [deploy, www-data]

Files to Create/Modify

File Action
src/server/filter/mod.rs Create - Filter trait and types
src/server/filter/policy.rs Create - Policy engine
src/server/filter/path.rs Create - Path matchers
src/server/filter/pattern.rs Create - Pattern matchers
src/server/mod.rs Modify - Add filter module

Testing Requirements

  1. Unit test: Glob matching
  2. Unit test: Prefix matching
  3. Unit test: Regex matching
  4. Unit test: Policy rule evaluation order
  5. Unit test: User-specific rules
  6. Unit test: Operation-specific rules

Acceptance Criteria

  • TransferFilter trait defined
  • FilterPolicy implementation
  • Glob pattern matcher
  • Path prefix matcher
  • Regex matcher (optional)
  • Per-user rules
  • Per-operation rules
  • Configurable default action
  • Integration with SFTP/SCP
  • Tests passing

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions