Skip to main content

tryaudex_core/
config.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6use crate::error::{AvError, Result};
7
8/// A named policy profile (e.g. `s3-readonly`, `lambda-deploy`).
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Profile {
11    /// Comma-separated IAM actions (same format as --allow)
12    pub allow: String,
13    /// Optional comma-separated resource ARNs
14    pub resource: Option<String>,
15    /// Optional description for display
16    pub description: Option<String>,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize, Default)]
20pub struct Config {
21    /// Default cloud provider ("aws", "gcp", or "azure")
22    pub provider: Option<String>,
23    /// Default IAM role ARN to assume (AWS)
24    pub role_arn: Option<String>,
25    /// Default TTL for sessions (e.g. "15m")
26    pub ttl: Option<String>,
27    /// Default budget limit in USD
28    pub budget: Option<f64>,
29    /// Default AWS region
30    pub region: Option<String>,
31    /// Actions that are always denied regardless of --allow
32    pub deny: Option<Vec<String>>,
33    /// AWS permissions boundary policy ARN — applied to every AssumeRole call
34    /// as an additional ceiling beyond the inline session policy.
35    pub permissions_boundary: Option<String>,
36    /// Network policy: restrict credentials to specific source IPs or VPCs.
37    #[serde(default)]
38    pub network: Option<crate::policy::NetworkPolicy>,
39    /// Named policy profiles
40    #[serde(default)]
41    pub profiles: HashMap<String, Profile>,
42    /// GCP service account email for impersonation
43    pub gcp_service_account: Option<String>,
44    /// GCP project ID
45    pub gcp_project: Option<String>,
46    /// Allow GCP access token TTLs beyond the default 1h (up to 12h).
47    ///
48    /// Requires the org policy
49    /// `constraints/iam.allowServiceAccountCredentialLifetimeExtension` to be
50    /// enabled for the target project. If that policy is not set, the IAM
51    /// `generateAccessToken` API will reject requests for tokens with a
52    /// lifetime exceeding 3600s with a `PERMISSION_DENIED` error. Verify the
53    /// org policy is in place before enabling this flag — there is no
54    /// pre-flight API check available for org policy constraints.
55    ///
56    /// To inspect the policy:
57    /// ```text
58    /// gcloud org-policies describe \
59    ///   constraints/iam.allowServiceAccountCredentialLifetimeExtension \
60    ///   --project=<PROJECT_ID>
61    /// ```
62    #[serde(default)]
63    pub gcp_extended_lifetime: bool,
64    /// Azure subscription ID
65    pub azure_subscription: Option<String>,
66    /// Azure tenant ID
67    pub azure_tenant: Option<String>,
68    /// Team policy configuration
69    #[serde(default)]
70    pub team: Option<crate::team::TeamConfig>,
71    /// Approval workflow configuration
72    #[serde(default)]
73    pub approval: Option<crate::approval::ApprovalConfig>,
74    /// Audit forwarding configuration
75    #[serde(default)]
76    pub audit: Option<crate::forward::ForwardConfig>,
77    /// Role mapping configuration
78    #[serde(default)]
79    pub roles: Option<crate::roles::RoleMappingConfig>,
80    /// Rate limiting configuration
81    #[serde(default)]
82    pub ratelimit: Option<crate::ratelimit::RateLimitConfig>,
83    /// SSO configuration
84    #[serde(default)]
85    pub sso: Option<crate::sso::SsoConfig>,
86    /// Vault credential backend configuration
87    #[serde(default)]
88    pub vault: Option<crate::vault::VaultConfig>,
89    /// Multi-account configuration
90    #[serde(default)]
91    pub account: Option<crate::account::AccountConfig>,
92    /// Credential broker configuration
93    #[serde(default)]
94    pub broker: Option<crate::broker::BrokerConfig>,
95    /// MCP command allowlist — when set, only commands whose basename matches
96    /// an entry in this list can be executed via the MCP `audex_run` tool.
97    /// Example: `mcp_allowed_commands = ["aws", "terraform", "gcloud", "az"]`
98    #[serde(default)]
99    pub mcp_allowed_commands: Option<Vec<String>>,
100    /// Database audit backend configuration (planned — parsed but not yet wired to a DB driver)
101    #[serde(default)]
102    pub dbaudit: Option<crate::dbaudit::DbAuditConfig>,
103    /// High availability configuration (planned — parsed but not yet wired to Redis/etcd)
104    #[serde(default)]
105    pub ha: Option<crate::ha::HaConfig>,
106}
107
108impl Config {
109    /// Load config from `~/.config/audex/config.toml`. Returns default if file doesn't exist.
110    pub fn load() -> Result<Self> {
111        let path = Self::path();
112        if !path.exists() {
113            return Ok(Self::default());
114        }
115
116        // R6-M61: warn if the config file is world-readable. The file can
117        // contain api_key, client_secret, and other credential material
118        // that must not be exposed to other users on the machine.
119        #[cfg(unix)]
120        {
121            use std::os::unix::fs::MetadataExt;
122            if let Ok(meta) = std::fs::metadata(&path) {
123                let mode = meta.mode();
124                if mode & 0o077 != 0 {
125                    tracing::warn!(
126                        path = %path.display(),
127                        mode = format!("{:o}", mode & 0o777),
128                        "Config file is accessible by group/other — it may contain secrets. \
129                         Run: chmod 600 {}",
130                        path.display()
131                    );
132                }
133            }
134        }
135
136        let contents = std::fs::read_to_string(&path)?;
137        let config: Self = toml::from_str(&contents).map_err(|e| {
138            AvError::InvalidPolicy(format!("Invalid config at {}: {}", path.display(), e))
139        })?;
140        config.validate()?;
141        Ok(config)
142    }
143
144    /// R6-M62/M63/M64: eagerly validate config values at load time so
145    /// misconfigurations surface immediately rather than at first use
146    /// (which may be minutes into a session, after authentication).
147    fn validate(&self) -> Result<()> {
148        // R6-M62: validate TTL string format.
149        if let Some(ref ttl) = self.ttl {
150            validate_ttl(ttl)?;
151        }
152
153        // R6-M63: reject NaN / Infinity budgets.
154        if let Some(budget) = self.budget {
155            if budget.is_nan() || budget.is_infinite() {
156                return Err(AvError::InvalidPolicy(
157                    "Config budget must be a finite number".to_string(),
158                ));
159            }
160            if budget < 0.0 {
161                return Err(AvError::InvalidPolicy(
162                    "Config budget must not be negative".to_string(),
163                ));
164            }
165        }
166
167        // R6-M64: validate sub-configs eagerly.
168        if let Some(ref ha) = self.ha {
169            ha.leader.validate()?;
170        }
171
172        Ok(())
173    }
174
175    /// Get a profile by name. Checks community:// prefix, then user config, then built-in profiles.
176    pub fn resolve_profile(&self, name: &str) -> Result<Profile> {
177        // Community policy library: `community://nextjs-vercel-deploy`
178        if name.starts_with("community://") {
179            return crate::community::resolve(name);
180        }
181        // Team policy library: `team://deploy-lambda`
182        if name.starts_with("team://") {
183            return crate::team::resolve_cached(name);
184        }
185        if let Some(p) = self.profiles.get(name) {
186            return Ok(p.clone());
187        }
188        if let Some(p) = builtin_profiles().get(name) {
189            return Ok(p.clone());
190        }
191        // Also check community policies without prefix
192        if let Ok(p) = crate::community::resolve(name) {
193            return Ok(p);
194        }
195        let mut available: Vec<String> = self.profiles.keys().cloned().collect();
196        available.extend(builtin_profiles().keys().cloned());
197        available.sort();
198        Err(AvError::InvalidPolicy(format!(
199            "Unknown profile '{}'. Available: {} (also try community:// policies)",
200            name,
201            available.join(", ")
202        )))
203    }
204
205    /// List all available profiles (user + built-in).
206    pub fn all_profiles(&self) -> Vec<(String, Profile)> {
207        let mut all: HashMap<String, Profile> = builtin_profiles();
208        // User profiles override built-ins
209        all.extend(self.profiles.clone());
210        let mut sorted: Vec<(String, Profile)> = all.into_iter().collect();
211        sorted.sort_by(|a, b| a.0.cmp(&b.0));
212        sorted
213    }
214
215    /// Config file path: `~/.config/audex/config.toml`
216    pub fn path() -> PathBuf {
217        dirs::config_dir()
218            .unwrap_or_else(|| PathBuf::from("."))
219            .join("audex")
220            .join("config.toml")
221    }
222}
223
224/// R6-M62: validate that a TTL string is a positive duration in a
225/// sensible range. Accepts `<number><unit>` where unit is s/m/h.
226/// Rejects negative, zero, and excessively long durations (>12 h)
227/// since AWS STS caps role sessions at 12 hours.
228fn validate_ttl(ttl: &str) -> Result<()> {
229    let ttl = ttl.trim();
230    if ttl.is_empty() {
231        return Err(AvError::InvalidPolicy("TTL must not be empty".to_string()));
232    }
233    let (num_str, unit) = if let Some(s) = ttl.strip_suffix('s') {
234        (s, 's')
235    } else if let Some(s) = ttl.strip_suffix('m') {
236        (s, 'm')
237    } else if let Some(s) = ttl.strip_suffix('h') {
238        (s, 'h')
239    } else {
240        return Err(AvError::InvalidPolicy(format!(
241            "TTL '{}' must end with s, m, or h",
242            ttl
243        )));
244    };
245    let num: u64 = num_str
246        .parse()
247        .map_err(|_| AvError::InvalidPolicy(format!("TTL '{}' has invalid numeric part", ttl)))?;
248    if num == 0 {
249        return Err(AvError::InvalidPolicy(
250            "TTL must be greater than zero".to_string(),
251        ));
252    }
253    let secs = match unit {
254        's' => num,
255        'm' => num.saturating_mul(60),
256        'h' => num.saturating_mul(3600),
257        _ => unreachable!(),
258    };
259    const MAX_TTL_SECS: u64 = 12 * 3600; // 12 hours — STS ceiling
260    if secs > MAX_TTL_SECS {
261        return Err(AvError::InvalidPolicy(format!(
262            "TTL '{}' exceeds maximum of 12h ({}s > {}s)",
263            ttl, secs, MAX_TTL_SECS
264        )));
265    }
266    Ok(())
267}
268
269fn p(allow: &str, desc: &str) -> Profile {
270    Profile {
271        allow: allow.to_string(),
272        resource: None,
273        description: Some(desc.to_string()),
274    }
275}
276
277/// Built-in policy profiles that ship with Audex.
278pub fn builtin_profiles() -> HashMap<String, Profile> {
279    HashMap::from([
280        ("s3-readonly".into(), p(
281            "s3:GetObject,s3:ListBucket,s3:GetBucketLocation,s3:ListAllMyBuckets",
282            "Read-only S3 access",
283        )),
284        ("s3-readwrite".into(), p(
285            "s3:GetObject,s3:PutObject,s3:DeleteObject,s3:ListBucket,s3:GetBucketLocation,s3:ListAllMyBuckets",
286            "Read/write S3 access",
287        )),
288        ("lambda-deploy".into(), p(
289            "lambda:UpdateFunctionCode,lambda:UpdateFunctionConfiguration,lambda:GetFunction,lambda:ListFunctions",
290            "Deploy and manage Lambda functions",
291        )),
292        ("dynamodb-query".into(), p(
293            "dynamodb:GetItem,dynamodb:Query,dynamodb:Scan,dynamodb:BatchGetItem,dynamodb:DescribeTable,dynamodb:ListTables",
294            "Read-only DynamoDB access",
295        )),
296        ("dynamodb-readwrite".into(), p(
297            "dynamodb:GetItem,dynamodb:PutItem,dynamodb:UpdateItem,dynamodb:DeleteItem,dynamodb:Query,dynamodb:Scan,dynamodb:BatchGetItem,dynamodb:BatchWriteItem,dynamodb:DescribeTable,dynamodb:ListTables",
298            "Read/write DynamoDB access",
299        )),
300        ("ec2-readonly".into(), p(
301            "ec2:DescribeInstances,ec2:DescribeSecurityGroups,ec2:DescribeSubnets,ec2:DescribeVpcs,ec2:DescribeImages",
302            "Read-only EC2 access",
303        )),
304        ("cloudwatch-logs".into(), p(
305            "logs:GetLogEvents,logs:DescribeLogGroups,logs:DescribeLogStreams,logs:FilterLogEvents",
306            "Read CloudWatch Logs",
307        )),
308        ("ssm-readonly".into(), p(
309            "ssm:GetParameter,ssm:GetParameters,ssm:GetParametersByPath,ssm:DescribeParameters",
310            "Read SSM Parameter Store",
311        )),
312        ("sqs-readwrite".into(), p(
313            "sqs:SendMessage,sqs:ReceiveMessage,sqs:DeleteMessage,sqs:GetQueueAttributes,sqs:ListQueues",
314            "Read/write SQS access",
315        )),
316        ("sns-publish".into(), p(
317            "sns:Publish,sns:ListTopics,sns:GetTopicAttributes",
318            "Publish to SNS topics",
319        )),
320        ("ecr-push".into(), p(
321            "ecr:GetAuthorizationToken,ecr:BatchCheckLayerAvailability,ecr:GetDownloadUrlForLayer,ecr:BatchGetImage,ecr:PutImage,ecr:InitiateLayerUpload,ecr:UploadLayerPart,ecr:CompleteLayerUpload,ecr:DescribeRepositories,ecr:CreateRepository",
322            "Push Docker images to ECR",
323        )),
324        ("ecr-pull".into(), p(
325            "ecr:GetAuthorizationToken,ecr:BatchCheckLayerAvailability,ecr:GetDownloadUrlForLayer,ecr:BatchGetImage,ecr:DescribeRepositories",
326            "Pull Docker images from ECR",
327        )),
328        ("terraform-plan".into(), p(
329            "sts:GetCallerIdentity,s3:GetObject,s3:ListBucket,s3:GetBucketLocation,dynamodb:GetItem,dynamodb:PutItem,ec2:Describe*,iam:GetRole,iam:GetPolicy,iam:GetPolicyVersion,iam:ListRolePolicies,iam:ListAttachedRolePolicies,lambda:GetFunction,lambda:ListFunctions,logs:DescribeLogGroups,cloudformation:DescribeStacks,cloudformation:ListStacks",
330            "Terraform plan (read-only state + describe resources)",
331        )),
332        ("terraform-apply".into(), p(
333            "sts:GetCallerIdentity,s3:GetObject,s3:PutObject,s3:ListBucket,s3:GetBucketLocation,dynamodb:GetItem,dynamodb:PutItem,ec2:*,iam:GetRole,iam:GetPolicy,iam:GetPolicyVersion,iam:CreateRole,iam:DeleteRole,iam:AttachRolePolicy,iam:DetachRolePolicy,iam:PassRole,iam:ListRolePolicies,iam:ListAttachedRolePolicies,iam:PutRolePolicy,iam:DeleteRolePolicy,lambda:*,logs:*,cloudformation:*,apigateway:*",
334            "Terraform apply (full infrastructure management)",
335        )),
336        // GCP profiles
337        ("gcs-readonly".into(), p(
338            "storage.objects.get,storage.objects.list,storage.buckets.get,storage.buckets.list",
339            "Read-only Google Cloud Storage access",
340        )),
341        ("gcs-readwrite".into(), p(
342            "storage.objects.get,storage.objects.list,storage.objects.create,storage.objects.delete,storage.buckets.get,storage.buckets.list",
343            "Read/write Google Cloud Storage access",
344        )),
345        ("gce-readonly".into(), p(
346            "compute.instances.get,compute.instances.list,compute.zones.list,compute.regions.list",
347            "Read-only Compute Engine access",
348        )),
349        ("gcf-deploy".into(), p(
350            "cloudfunctions.functions.get,cloudfunctions.functions.list,cloudfunctions.functions.update,cloudfunctions.functions.create",
351            "Deploy Google Cloud Functions",
352        )),
353        ("bigquery-readonly".into(), p(
354            "bigquery.jobs.create,bigquery.tables.getData,bigquery.tables.list,bigquery.datasets.get",
355            "Read-only BigQuery access",
356        )),
357        // Azure profiles
358        ("azure-storage-readonly".into(), p(
359            "Microsoft.Storage/storageAccounts/read,Microsoft.Storage/storageAccounts/listKeys/action,Microsoft.Storage/storageAccounts/blobServices/containers/read",
360            "Read-only Azure Storage access",
361        )),
362        ("azure-storage-readwrite".into(), p(
363            "Microsoft.Storage/storageAccounts/read,Microsoft.Storage/storageAccounts/write,Microsoft.Storage/storageAccounts/listKeys/action,Microsoft.Storage/storageAccounts/blobServices/containers/read,Microsoft.Storage/storageAccounts/blobServices/containers/write",
364            "Read/write Azure Storage access",
365        )),
366        ("azure-compute-readonly".into(), p(
367            "Microsoft.Compute/virtualMachines/read,Microsoft.Compute/virtualMachines/instanceView/read,Microsoft.Network/networkInterfaces/read,Microsoft.Network/publicIPAddresses/read",
368            "Read-only Azure Compute access",
369        )),
370        ("azure-keyvault-readonly".into(), p(
371            "Microsoft.KeyVault/vaults/read,Microsoft.KeyVault/vaults/secrets/read",
372            "Read-only Azure Key Vault access",
373        )),
374        ("azure-functions-deploy".into(), p(
375            "Microsoft.Web/sites/read,Microsoft.Web/sites/write,Microsoft.Web/sites/functions/read,Microsoft.Web/sites/functions/write,Microsoft.Web/sites/restart/action",
376            "Deploy Azure Functions",
377        )),
378        ("azure-aks-readonly".into(), p(
379            "Microsoft.ContainerService/managedClusters/read,Microsoft.ContainerService/managedClusters/listClusterUserCredential/action",
380            "Read-only Azure Kubernetes Service access",
381        )),
382    ])
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    #[test]
390    fn test_builtin_profile_resolve() {
391        let config = Config::default();
392        let profile = config.resolve_profile("s3-readonly").unwrap();
393        assert!(profile.allow.contains("s3:GetObject"));
394        assert!(profile.allow.contains("s3:ListBucket"));
395    }
396
397    #[test]
398    fn test_unknown_profile_error() {
399        let config = Config::default();
400        assert!(config.resolve_profile("nonexistent").is_err());
401    }
402
403    #[test]
404    fn test_user_profile_overrides_builtin() {
405        let mut config = Config::default();
406        config.profiles.insert(
407            "s3-readonly".into(),
408            Profile {
409                allow: "s3:GetObject".to_string(),
410                resource: Some("arn:aws:s3:::my-bucket/*".to_string()),
411                description: Some("Custom s3 readonly".to_string()),
412            },
413        );
414        let profile = config.resolve_profile("s3-readonly").unwrap();
415        // Should get user's version, not built-in
416        assert_eq!(profile.allow, "s3:GetObject");
417        assert!(profile.resource.is_some());
418    }
419
420    #[test]
421    fn test_all_profiles_includes_builtins() {
422        let config = Config::default();
423        let all = config.all_profiles();
424        assert!(all.len() >= 10);
425        assert!(all.iter().any(|(name, _)| name == "s3-readonly"));
426        assert!(all.iter().any(|(name, _)| name == "lambda-deploy"));
427    }
428
429    #[test]
430    fn test_config_toml_with_profiles() {
431        let toml_str = r#"
432role_arn = "arn:aws:iam::123456789012:role/MyRole"
433ttl = "30m"
434
435[profiles.my-custom]
436allow = "s3:GetObject,s3:PutObject"
437resource = "arn:aws:s3:::my-bucket/*"
438description = "Custom profile"
439"#;
440        let config: Config = toml::from_str(toml_str).unwrap();
441        assert_eq!(
442            config.role_arn.unwrap(),
443            "arn:aws:iam::123456789012:role/MyRole"
444        );
445        assert_eq!(config.ttl.unwrap(), "30m");
446        let profile = config.profiles.get("my-custom").unwrap();
447        assert!(profile.allow.contains("s3:GetObject"));
448        assert!(profile.resource.is_some());
449    }
450
451    #[test]
452    fn test_validate_ttl_valid() {
453        assert!(validate_ttl("15m").is_ok());
454        assert!(validate_ttl("900s").is_ok());
455        assert!(validate_ttl("1h").is_ok());
456        assert!(validate_ttl("12h").is_ok());
457    }
458
459    #[test]
460    fn test_validate_ttl_rejects_bad_values() {
461        // R6-M62
462        assert!(validate_ttl("").is_err());
463        assert!(validate_ttl("0m").is_err());
464        assert!(validate_ttl("13h").is_err());
465        assert!(validate_ttl("99999h").is_err());
466        assert!(validate_ttl("abc").is_err());
467        assert!(validate_ttl("-5m").is_err());
468        assert!(validate_ttl("15").is_err());
469    }
470
471    #[test]
472    fn test_validate_budget_rejects_nan() {
473        // R6-M63
474        let mut config = Config::default();
475        config.budget = Some(f64::NAN);
476        assert!(config.validate().is_err());
477
478        config.budget = Some(f64::INFINITY);
479        assert!(config.validate().is_err());
480
481        config.budget = Some(-1.0);
482        assert!(config.validate().is_err());
483
484        config.budget = Some(10.0);
485        assert!(config.validate().is_ok());
486
487        config.budget = None;
488        assert!(config.validate().is_ok());
489    }
490}