1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6use crate::error::{AvError, Result};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Profile {
11 pub allow: String,
13 pub resource: Option<String>,
15 pub description: Option<String>,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize, Default)]
20pub struct Config {
21 pub provider: Option<String>,
23 pub role_arn: Option<String>,
25 pub ttl: Option<String>,
27 pub budget: Option<f64>,
29 pub region: Option<String>,
31 pub deny: Option<Vec<String>>,
33 pub permissions_boundary: Option<String>,
36 #[serde(default)]
38 pub network: Option<crate::policy::NetworkPolicy>,
39 #[serde(default)]
41 pub profiles: HashMap<String, Profile>,
42 pub gcp_service_account: Option<String>,
44 pub gcp_project: Option<String>,
46 #[serde(default)]
63 pub gcp_extended_lifetime: bool,
64 pub azure_subscription: Option<String>,
66 pub azure_tenant: Option<String>,
68 #[serde(default)]
70 pub team: Option<crate::team::TeamConfig>,
71 #[serde(default)]
73 pub approval: Option<crate::approval::ApprovalConfig>,
74 #[serde(default)]
76 pub audit: Option<crate::forward::ForwardConfig>,
77 #[serde(default)]
79 pub roles: Option<crate::roles::RoleMappingConfig>,
80 #[serde(default)]
82 pub ratelimit: Option<crate::ratelimit::RateLimitConfig>,
83 #[serde(default)]
85 pub sso: Option<crate::sso::SsoConfig>,
86 #[serde(default)]
88 pub vault: Option<crate::vault::VaultConfig>,
89 #[serde(default)]
91 pub account: Option<crate::account::AccountConfig>,
92 #[serde(default)]
94 pub broker: Option<crate::broker::BrokerConfig>,
95 #[serde(default)]
99 pub mcp_allowed_commands: Option<Vec<String>>,
100 #[serde(default)]
102 pub dbaudit: Option<crate::dbaudit::DbAuditConfig>,
103 #[serde(default)]
105 pub ha: Option<crate::ha::HaConfig>,
106}
107
108impl Config {
109 pub fn load() -> Result<Self> {
111 let path = Self::path();
112 if !path.exists() {
113 return Ok(Self::default());
114 }
115
116 #[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 fn validate(&self) -> Result<()> {
148 if let Some(ref ttl) = self.ttl {
150 validate_ttl(ttl)?;
151 }
152
153 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 if let Some(ref ha) = self.ha {
169 ha.leader.validate()?;
170 }
171
172 Ok(())
173 }
174
175 pub fn resolve_profile(&self, name: &str) -> Result<Profile> {
177 if name.starts_with("community://") {
179 return crate::community::resolve(name);
180 }
181 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 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 pub fn all_profiles(&self) -> Vec<(String, Profile)> {
207 let mut all: HashMap<String, Profile> = builtin_profiles();
208 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 pub fn path() -> PathBuf {
217 dirs::config_dir()
218 .unwrap_or_else(|| PathBuf::from("."))
219 .join("audex")
220 .join("config.toml")
221 }
222}
223
224fn 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; 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
277pub 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 ("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-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 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 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 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}