// Configuration module for SeckelAPI use anyhow::{Context, Result}; use ipnet::IpNet; use serde::Deserialize; use std::collections::HashMap; use std::fs; use std::str::FromStr; #[derive(Debug, Clone, Deserialize)] pub struct Config { pub database: DatabaseConfig, pub server: ServerConfig, pub security: SecurityConfig, pub permissions: PermissionsConfig, pub logging: LoggingConfig, pub auto_generation: Option>, pub scheduled_queries: Option, } #[derive(Debug, Clone, Deserialize)] pub struct PermissionsConfig { #[serde(flatten)] pub power_levels: HashMap, } #[derive(Debug, Clone, Deserialize)] pub struct PowerLevelPermissions { pub basic_rules: Vec, pub advanced_rules: Option>, pub max_limit: Option, pub max_where_conditions: Option, pub session_timeout_minutes: Option, pub max_concurrent_sessions: Option, #[serde(default = "default_true")] pub rollback_on_error: bool, #[serde(default = "default_false")] pub allow_batch_operations: bool, #[serde(default = "default_user_settings_access")] pub user_settings_access: UserSettingsAccess, } #[derive(Debug, Clone, Deserialize, PartialEq)] #[serde(rename_all = "kebab-case")] pub enum UserSettingsAccess { ReadOwnOnly, ReadWriteOwn, ReadWriteAll, } fn default_user_settings_access() -> UserSettingsAccess { UserSettingsAccess::ReadWriteOwn } #[derive(Debug, Clone, Deserialize)] pub struct DatabaseConfig { pub host: String, pub port: u16, pub database: String, pub username: String, pub password: String, #[serde(default = "default_min_connections")] pub min_connections: u32, #[serde(default = "default_max_connections")] pub max_connections: u32, #[serde(default = "default_connection_timeout_seconds")] pub connection_timeout_seconds: u64, #[serde(default = "default_connection_timeout_wait")] pub connection_timeout_wait: u64, #[serde(default = "default_connection_check_interval")] pub connection_check: u64, } fn default_min_connections() -> u32 { 1 } fn default_max_connections() -> u32 { 10 } fn default_connection_timeout_seconds() -> u64 { 30 } fn default_connection_timeout_wait() -> u64 { 5 } fn default_connection_check_interval() -> u64 { 30 } #[derive(Debug, Clone, Deserialize)] pub struct ServerConfig { pub host: String, pub port: u16, #[serde(default = "default_request_body_limit_mb")] pub request_body_limit_mb: usize, } fn default_request_body_limit_mb() -> usize { 10 // 10 MB default } #[derive(Debug, Clone, Deserialize)] pub struct SecurityConfig { pub whitelisted_pin_ips: Vec, pub whitelisted_string_ips: Vec, pub session_timeout_minutes: u64, pub refresh_session_on_activity: bool, pub max_concurrent_sessions: u32, pub session_cleanup_interval_minutes: u64, pub default_max_limit: u32, pub default_max_where_conditions: u32, #[serde(default = "default_hash_pins")] pub hash_pins: bool, // Whether to use bcrypt for PINs (false = plaintext) #[serde(default = "default_hash_tokens")] pub hash_tokens: bool, // Whether to use bcrypt for login_strings (false = plaintext) #[serde(default = "default_pin_column")] pub pin_column: String, // Database column name for PINs #[serde(default = "default_token_column")] pub token_column: String, // Database column name for login strings // Rate limiting #[serde(default = "default_enable_rate_limiting")] pub enable_rate_limiting: bool, // Master switch for rate limiting (disable for debugging) // Auth rate limiting #[serde(default = "default_auth_rate_limit_per_minute")] pub auth_rate_limit_per_minute: u32, // Max auth requests per IP per minute #[serde(default = "default_auth_rate_limit_per_second")] pub auth_rate_limit_per_second: u32, // Max auth requests per IP per second (burst protection) // API rate limiting #[serde(default = "default_api_rate_limit_per_minute")] pub api_rate_limit_per_minute: u32, // Max API calls per user per minute #[serde(default = "default_api_rate_limit_per_second")] pub api_rate_limit_per_second: u32, // Max API calls per user per second (burst protection) // Table configuration (moved from basics.toml) #[serde(default = "default_known_tables")] pub known_tables: Vec, #[serde(default = "default_read_only_tables")] pub read_only_tables: Vec, #[serde(default = "default_global_write_protected_columns")] pub global_write_protected_columns: Vec, // User preferences access control #[serde(default = "default_user_settings_access")] pub default_user_settings_access: UserSettingsAccess, } fn default_known_tables() -> Vec { vec![] // Empty by default, must be configured } fn default_read_only_tables() -> Vec { vec![] // Empty by default } fn default_global_write_protected_columns() -> Vec { vec!["id".to_string()] // Protect 'id' by default at minimum } fn default_hash_pins() -> bool { false // Default to plaintext (must be explicitly enabled for bcrypt hashing) } fn default_hash_tokens() -> bool { false // Default to plaintext (must be explicitly enabled for bcrypt hashing) } fn default_pin_column() -> String { "pin_code".to_string() } fn default_token_column() -> String { "login_string".to_string() } fn default_enable_rate_limiting() -> bool { true // Enable by default for security } fn default_auth_rate_limit_per_minute() -> u32 { 10 // 10 login attempts per IP per minute (prevents brute force) } fn default_auth_rate_limit_per_second() -> u32 { 5 // Max 5 login attempts per second (burst protection) } fn default_api_rate_limit_per_minute() -> u32 { 60 // 60 API calls per user per minute } fn default_api_rate_limit_per_second() -> u32 { 10 // Max 10 API calls per second (burst protection) } #[derive(Debug, Clone, Deserialize)] pub struct LoggingConfig { pub request_log: Option, pub query_log: Option, pub error_log: Option, pub warning_log: Option, // Warning messages pub info_log: Option, // Info messages pub combined_log: Option, // Unified log with request IDs pub level: String, pub mask_passwords: bool, #[serde(default)] pub sensitive_fields: Vec, // Fields to mask beyond password/pin #[serde(default)] pub custom_filters: Vec, // Regex-based log routing } #[derive(Debug, Clone, Deserialize)] pub struct CustomLogFilter { pub name: String, pub output_file: String, pub pattern: String, // Regex pattern to match #[serde(default = "default_filter_enabled")] pub enabled: bool, } fn default_filter_enabled() -> bool { true } fn default_true() -> bool { true } fn default_false() -> bool { false } #[derive(Debug, Clone, Deserialize)] pub struct AutoGenerationConfig { pub field: String, #[serde(rename = "type")] pub gen_type: String, pub length: Option, pub range_min: Option, pub range_max: Option, pub max_attempts: Option, #[serde(default = "default_on_action")] pub on_action: String, // "insert", "update", or "both" } fn default_on_action() -> String { "insert".to_string() } #[derive(Debug, Clone, Deserialize)] pub struct ScheduledQueriesConfig { #[serde(default)] pub tasks: Vec, } #[derive(Debug, Clone, Deserialize)] pub struct ScheduledQueryTask { pub name: String, pub description: String, pub query: String, pub interval_minutes: u64, #[serde(default = "default_enabled")] pub enabled: bool, #[serde(default = "default_run_on_startup")] pub run_on_startup: bool, } fn default_enabled() -> bool { true } fn default_run_on_startup() -> bool { true // Run immediately on startup by default } impl Config { pub fn load() -> Result { Self::load_from_folder() } fn load_from_folder() -> Result { // Load consolidated config files let basics_content = fs::read_to_string("config/basics.toml").context("Can't read config/basics.toml")?; let security_content = fs::read_to_string("config/security.toml") .context("Can't read config/security.toml")?; let logging_content = fs::read_to_string("config/logging.toml").context("Can't read config/logging.toml")?; // Parse individual sections #[derive(Deserialize)] struct BasicsWrapper { server: ServerConfig, database: DatabaseConfig, #[serde(default)] auto_generation: Option>, } #[derive(Deserialize)] struct SecurityWrapper { security: SecurityConfig, permissions: PermissionsConfig, } #[derive(Deserialize)] struct LoggingWrapper { logging: LoggingConfig, } #[derive(Deserialize)] struct FunctionsWrapper { #[serde(default)] auto_generation: Option>, #[serde(default)] scheduled_queries: Option, } let basics: BasicsWrapper = toml::from_str(&basics_content).context("Failed to parse basics.toml")?; let security: SecurityWrapper = toml::from_str(&security_content).context("Failed to parse security.toml")?; let logging: LoggingWrapper = toml::from_str(&logging_content).context("Failed to parse logging.toml")?; // Load functions.toml if it exists, otherwise use basics fallback let functions: FunctionsWrapper = if fs::metadata("config/functions.toml").is_ok() { let functions_content = fs::read_to_string("config/functions.toml") .context("Can't read config/functions.toml")?; toml::from_str(&functions_content).context("Failed to parse functions.toml")? } else { // Fallback to basics.toml for auto_generation FunctionsWrapper { auto_generation: basics.auto_generation.clone(), scheduled_queries: None, } }; let config = Config { database: basics.database, server: basics.server, security: security.security, permissions: security.permissions, logging: logging.logging, auto_generation: functions.auto_generation, scheduled_queries: functions.scheduled_queries, }; // Validate configuration config.validate()?; Ok(config) } /// Validate configuration values fn validate(&self) -> Result<()> { // Validate server port (u16 is already limited to 0-65535, just check for 0) if self.server.port == 0 { anyhow::bail!( "Invalid server.port: {} (must be 1-65535)", self.server.port ); } // Validate database port (u16 is already limited to 0-65535, just check for 0) if self.database.port == 0 { anyhow::bail!( "Invalid database.port: {} (must be 1-65535)", self.database.port ); } // Validate database connection details if self.database.host.trim().is_empty() { anyhow::bail!("database.host cannot be empty"); } if self.database.database.trim().is_empty() { anyhow::bail!("database.database cannot be empty"); } if self.database.username.trim().is_empty() { anyhow::bail!("database.username cannot be empty"); } // Validate PIN whitelist IPs (can be CIDR or single IPs) for ip in &self.security.whitelisted_pin_ips { if IpNet::from_str(ip).is_err() && ip.parse::().is_err() { anyhow::bail!("Invalid IP/CIDR in whitelisted_pin_ips: {}", ip); } } // Validate string auth whitelist IPs (can be CIDR or single IPs) for ip in &self.security.whitelisted_string_ips { if IpNet::from_str(ip).is_err() && ip.parse::().is_err() { anyhow::bail!("Invalid IP/CIDR in whitelisted_string_ips: {}", ip); } } // Validate session timeout if self.security.session_timeout_minutes == 0 { anyhow::bail!("security.session_timeout_minutes must be greater than 0"); } // Validate permission syntax for (power_level, perms) in &self.permissions.power_levels { // Validate power level is numeric if power_level.parse::().is_err() { anyhow::bail!("Invalid power level '{}': must be numeric", power_level); } // Validate basic rules format for rule in &perms.basic_rules { Self::validate_permission_rule(rule).with_context(|| { format!("Invalid basic rule for power level {}", power_level) })?; } // Validate advanced rules format if let Some(advanced_rules) = &perms.advanced_rules { for rule in advanced_rules { Self::validate_advanced_rule(rule).with_context(|| { format!("Invalid advanced rule for power level {}", power_level) })?; } } } // Validate logging configuration (paths are now optional) // No validation needed for optional log paths // Validate log level let valid_levels = ["trace", "debug", "info", "warn", "error"]; if !valid_levels.contains(&self.logging.level.to_lowercase().as_str()) { anyhow::bail!( "Invalid logging.level '{}': must be one of: {}", self.logging.level, valid_levels.join(", ") ); } Ok(()) } /// Validate basic permission rule format (table:permission) fn validate_permission_rule(rule: &str) -> Result<()> { let parts: Vec<&str> = rule.split(':').collect(); if parts.len() != 2 { anyhow::bail!( "Permission rule '{}' must be in format 'table:permission'", rule ); } let table = parts[0]; let permission = parts[1]; if table.trim().is_empty() { anyhow::bail!("Table name cannot be empty in rule '{}'", rule); } // Validate permission type let valid_permissions = ["r", "rw", "rwd"]; if !valid_permissions.contains(&permission) { anyhow::bail!( "Invalid permission '{}' in rule '{}': must be one of: {}", permission, rule, valid_permissions.join(", ") ); } Ok(()) } /// Validate advanced permission rule format (table.column:permission) fn validate_advanced_rule(rule: &str) -> Result<()> { let parts: Vec<&str> = rule.split(':').collect(); if parts.len() != 2 { anyhow::bail!( "Advanced rule '{}' must be in format 'table.column:permission'", rule ); } let table_col = parts[0]; let permission = parts[1]; // Validate table.column format let col_parts: Vec<&str> = table_col.split('.').collect(); if col_parts.len() != 2 { anyhow::bail!("Advanced rule '{}' must have 'table.column' format", rule); } let table = col_parts[0]; let column = col_parts[1]; if table.trim().is_empty() { anyhow::bail!("Table name cannot be empty in rule '{}'", rule); } if column.trim().is_empty() { anyhow::bail!("Column name cannot be empty in rule '{}'", rule); } // Validate permission type let valid_permissions = ["r", "w", "rw", "block"]; if !valid_permissions.contains(&permission) { anyhow::bail!( "Invalid permission '{}' in rule '{}': must be one of: {}", permission, rule, valid_permissions.join(", ") ); } Ok(()) } pub fn get_database_url(&self) -> String { format!( "mysql://{}:{}@{}:{}/{}", self.database.username, self.database.password, self.database.host, self.database.port, self.database.database ) } pub fn is_pin_ip_whitelisted(&self, ip: &str) -> bool { self.is_ip_in_whitelist(ip, &self.security.whitelisted_pin_ips) } pub fn is_string_ip_whitelisted(&self, ip: &str) -> bool { self.is_ip_in_whitelist(ip, &self.security.whitelisted_string_ips) } fn is_ip_in_whitelist(&self, ip: &str, whitelist: &[String]) -> bool { let client_ip = match ip.parse::() { Ok(addr) => addr, Err(_) => return false, }; for allowed in whitelist { // Try to parse as a network (CIDR notation) if let Ok(network) = IpNet::from_str(allowed) { if network.contains(&client_ip) { return true; } } // Try to parse as a single IP address else if let Ok(allowed_ip) = allowed.parse::() { if client_ip == allowed_ip { return true; } } } false } pub fn get_role_permissions(&self, power: i32) -> Vec<(String, String)> { let power_str = power.to_string(); if let Some(power_perms) = self.permissions.power_levels.get(&power_str) { power_perms .basic_rules .iter() .filter_map(|perm| { let parts: Vec<&str> = perm.split(':').collect(); if parts.len() == 2 { Some((parts[0].to_string(), parts[1].to_string())) } else { None } }) .collect() } else { Vec::new() } } // Helper methods for new configuration sections pub fn get_known_tables(&self) -> Vec { if self.security.known_tables.is_empty() { tracing::warn!("No known_tables configured in security.toml - returning empty list. Wildcard permissions (*) will not work."); } self.security.known_tables.clone() } pub fn is_read_only_table(&self, table: &str) -> bool { self.security.read_only_tables.contains(&table.to_string()) } pub fn get_auto_generation_config(&self, table: &str) -> Option<&AutoGenerationConfig> { self.auto_generation .as_ref() .and_then(|configs| configs.get(table)) } pub fn get_basic_permissions(&self, power: i32) -> Option<&Vec> { self.permissions .power_levels .get(&power.to_string()) .map(|p| &p.basic_rules) } pub fn get_advanced_permissions(&self, power: i32) -> Option<&Vec> { self.permissions .power_levels .get(&power.to_string()) .and_then(|p| p.advanced_rules.as_ref()) } pub fn filter_readable_columns( &self, power: i32, table: &str, requested_columns: &[String], ) -> Vec { if let Some(advanced_rules) = self.get_advanced_permissions(power) { let mut allowed_columns = Vec::new(); let mut blocked_columns = Vec::new(); let mut has_wildcard_block = false; let mut has_wildcard_allow = false; // Parse advanced rules for this table for rule in advanced_rules { if let Some((table_col, permission)) = rule.split_once(':') { if let Some((rule_table, column)) = table_col.split_once('.') { if rule_table == table { match permission { "block" => { if column == "*" { has_wildcard_block = true; } else { blocked_columns.push(column.to_string()); } } "r" | "rw" => { if column == "*" { has_wildcard_allow = true; } else { allowed_columns.push(column.to_string()); } } _ => {} } } } } } // Filter requested columns based on rules let mut result = Vec::new(); for column in requested_columns { let allow = if has_wildcard_block { // If wildcard block, only allow specifically allowed columns allowed_columns.contains(column) } else if has_wildcard_allow { // If wildcard allow, block only specifically blocked columns !blocked_columns.contains(column) } else { // No wildcard rules, block specifically blocked columns !blocked_columns.contains(column) }; if allow { result.push(column.clone()); } } result } else { // No advanced rules, return all requested columns requested_columns.to_vec() } } pub fn filter_writable_columns( &self, power: i32, table: &str, requested_columns: &[String], ) -> Vec { // First, apply global write-protected columns (these override everything) let mut globally_blocked: Vec = self.security.global_write_protected_columns.clone(); if let Some(advanced_rules) = self.get_advanced_permissions(power) { let mut allowed_columns = Vec::new(); let mut blocked_columns = Vec::new(); let mut has_wildcard_block = false; let mut has_wildcard_allow = false; // Parse advanced rules for this table for rule in advanced_rules { if let Some((table_col, permission)) = rule.split_once(':') { if let Some((rule_table, column)) = table_col.split_once('.') { if rule_table == table { match permission { "block" => { if column == "*" { has_wildcard_block = true; } else { blocked_columns.push(column.to_string()); } } "w" | "rw" => { if column == "*" { has_wildcard_allow = true; } else { allowed_columns.push(column.to_string()); } } "r" => { // Read-only: block from writing but not from reading blocked_columns.push(column.to_string()); } _ => {} } } } } } // Merge advanced_rules blocked columns with globally protected blocked_columns.append(&mut globally_blocked); // Filter requested columns based on rules let mut result = Vec::new(); for column in requested_columns { let allow = if has_wildcard_block { // If wildcard block, only allow specifically allowed columns allowed_columns.contains(column) } else if has_wildcard_allow { // If wildcard allow, block only specifically blocked columns !blocked_columns.contains(column) } else { // No wildcard rules, block specifically blocked columns !blocked_columns.contains(column) }; if allow { result.push(column.clone()); } } result } else { // No advanced rules, just filter out globally protected columns requested_columns .iter() .filter(|col| !globally_blocked.contains(col)) .cloned() .collect() } } /// Get the max_limit for a specific power level (with fallback to next lower power level) pub fn get_max_limit(&self, power: i32) -> u32 { // Try exact match first if let Some(perms) = self.permissions.power_levels.get(&power.to_string()) { if let Some(limit) = perms.max_limit { return limit; } } // Find next lower power level let fallback_power = self.find_fallback_power_level(power); if let Some(fb_power) = fallback_power { tracing::warn!( "Power level {} not found in config, falling back to power level {}", power, fb_power ); if let Some(perms) = self.permissions.power_levels.get(&fb_power.to_string()) { if let Some(limit) = perms.max_limit { return limit; } } } // Ultimate fallback to default self.security.default_max_limit } /// Get the max_where_conditions for a specific power level (with fallback to next lower power level) pub fn get_max_where_conditions(&self, power: i32) -> u32 { // Try exact match first if let Some(perms) = self.permissions.power_levels.get(&power.to_string()) { if let Some(max_where) = perms.max_where_conditions { return max_where; } } // Find next lower power level let fallback_power = self.find_fallback_power_level(power); if let Some(fb_power) = fallback_power { tracing::warn!( "Power level {} not found in config, falling back to power level {}", power, fb_power ); if let Some(perms) = self.permissions.power_levels.get(&fb_power.to_string()) { if let Some(max_where) = perms.max_where_conditions { return max_where; } } } // Ultimate fallback to default self.security.default_max_where_conditions } /// Find the next lower configured power level (e.g., power=60 → fallback to 50) fn find_fallback_power_level(&self, power: i32) -> Option { let mut available_powers: Vec = self .permissions .power_levels .keys() .filter_map(|k| k.parse::().ok()) .filter(|&p| p < power) // Only consider lower power levels .collect(); available_powers.sort_by(|a, b| b.cmp(a)); // Sort descending available_powers.first().copied() } /// Get the session_timeout_minutes for a specific power level (with fallback to default) pub fn get_session_timeout(&self, power: i32) -> u64 { self.permissions .power_levels .get(&power.to_string()) .and_then(|p| p.session_timeout_minutes) .unwrap_or(self.security.session_timeout_minutes) } /// Get the max_concurrent_sessions for a specific power level (with fallback to default) pub fn get_max_concurrent_sessions(&self, power: i32) -> u32 { self.permissions .power_levels .get(&power.to_string()) .and_then(|p| p.max_concurrent_sessions) .unwrap_or(self.security.max_concurrent_sessions) } }