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 pub azure_subscription: Option<String>,
48 pub azure_tenant: Option<String>,
50 #[serde(default)]
52 pub team: Option<crate::team::TeamConfig>,
53 #[serde(default)]
55 pub approval: Option<crate::approval::ApprovalConfig>,
56 #[serde(default)]
58 pub audit: Option<crate::forward::ForwardConfig>,
59 #[serde(default)]
61 pub roles: Option<crate::roles::RoleMappingConfig>,
62 #[serde(default)]
64 pub ratelimit: Option<crate::ratelimit::RateLimitConfig>,
65 #[serde(default)]
67 pub sso: Option<crate::sso::SsoConfig>,
68 #[serde(default)]
70 pub vault: Option<crate::vault::VaultConfig>,
71 #[serde(default)]
73 pub account: Option<crate::account::AccountConfig>,
74 #[serde(default)]
76 pub broker: Option<crate::broker::BrokerConfig>,
77 #[serde(default)]
79 pub dbaudit: Option<crate::dbaudit::DbAuditConfig>,
80 #[serde(default)]
82 pub ha: Option<crate::ha::HaConfig>,
83}
84
85impl Config {
86 pub fn load() -> Result<Self> {
88 let path = Self::path();
89 if !path.exists() {
90 return Ok(Self::default());
91 }
92 let contents = std::fs::read_to_string(&path)?;
93 toml::from_str(&contents).map_err(|e| {
94 AvError::InvalidPolicy(format!("Invalid config at {}: {}", path.display(), e))
95 })
96 }
97
98 pub fn resolve_profile(&self, name: &str) -> Result<Profile> {
100 if name.starts_with("community://") {
102 return crate::community::resolve(name);
103 }
104 if name.starts_with("team://") {
106 return crate::team::resolve_cached(name);
107 }
108 if let Some(p) = self.profiles.get(name) {
109 return Ok(p.clone());
110 }
111 if let Some(p) = builtin_profiles().get(name) {
112 return Ok(p.clone());
113 }
114 if let Ok(p) = crate::community::resolve(name) {
116 return Ok(p);
117 }
118 let mut available: Vec<String> = self.profiles.keys().cloned().collect();
119 available.extend(builtin_profiles().keys().cloned());
120 available.sort();
121 Err(AvError::InvalidPolicy(format!(
122 "Unknown profile '{}'. Available: {} (also try community:// policies)",
123 name,
124 available.join(", ")
125 )))
126 }
127
128 pub fn all_profiles(&self) -> Vec<(String, Profile)> {
130 let mut all: HashMap<String, Profile> = builtin_profiles();
131 all.extend(self.profiles.clone());
133 let mut sorted: Vec<(String, Profile)> = all.into_iter().collect();
134 sorted.sort_by(|a, b| a.0.cmp(&b.0));
135 sorted
136 }
137
138 pub fn path() -> PathBuf {
140 dirs::config_dir()
141 .unwrap_or_else(|| PathBuf::from("."))
142 .join("audex")
143 .join("config.toml")
144 }
145}
146
147fn p(allow: &str, desc: &str) -> Profile {
148 Profile {
149 allow: allow.to_string(),
150 resource: None,
151 description: Some(desc.to_string()),
152 }
153}
154
155pub fn builtin_profiles() -> HashMap<String, Profile> {
157 HashMap::from([
158 ("s3-readonly".into(), p(
159 "s3:GetObject,s3:ListBucket,s3:GetBucketLocation,s3:ListAllMyBuckets",
160 "Read-only S3 access",
161 )),
162 ("s3-readwrite".into(), p(
163 "s3:GetObject,s3:PutObject,s3:DeleteObject,s3:ListBucket,s3:GetBucketLocation,s3:ListAllMyBuckets",
164 "Read/write S3 access",
165 )),
166 ("lambda-deploy".into(), p(
167 "lambda:UpdateFunctionCode,lambda:UpdateFunctionConfiguration,lambda:GetFunction,lambda:ListFunctions",
168 "Deploy and manage Lambda functions",
169 )),
170 ("dynamodb-query".into(), p(
171 "dynamodb:GetItem,dynamodb:Query,dynamodb:Scan,dynamodb:BatchGetItem,dynamodb:DescribeTable,dynamodb:ListTables",
172 "Read-only DynamoDB access",
173 )),
174 ("dynamodb-readwrite".into(), p(
175 "dynamodb:GetItem,dynamodb:PutItem,dynamodb:UpdateItem,dynamodb:DeleteItem,dynamodb:Query,dynamodb:Scan,dynamodb:BatchGetItem,dynamodb:BatchWriteItem,dynamodb:DescribeTable,dynamodb:ListTables",
176 "Read/write DynamoDB access",
177 )),
178 ("ec2-readonly".into(), p(
179 "ec2:DescribeInstances,ec2:DescribeSecurityGroups,ec2:DescribeSubnets,ec2:DescribeVpcs,ec2:DescribeImages",
180 "Read-only EC2 access",
181 )),
182 ("cloudwatch-logs".into(), p(
183 "logs:GetLogEvents,logs:DescribeLogGroups,logs:DescribeLogStreams,logs:FilterLogEvents",
184 "Read CloudWatch Logs",
185 )),
186 ("ssm-readonly".into(), p(
187 "ssm:GetParameter,ssm:GetParameters,ssm:GetParametersByPath,ssm:DescribeParameters",
188 "Read SSM Parameter Store",
189 )),
190 ("sqs-readwrite".into(), p(
191 "sqs:SendMessage,sqs:ReceiveMessage,sqs:DeleteMessage,sqs:GetQueueAttributes,sqs:ListQueues",
192 "Read/write SQS access",
193 )),
194 ("sns-publish".into(), p(
195 "sns:Publish,sns:ListTopics,sns:GetTopicAttributes",
196 "Publish to SNS topics",
197 )),
198 ("ecr-push".into(), p(
199 "ecr:GetAuthorizationToken,ecr:BatchCheckLayerAvailability,ecr:GetDownloadUrlForLayer,ecr:BatchGetImage,ecr:PutImage,ecr:InitiateLayerUpload,ecr:UploadLayerPart,ecr:CompleteLayerUpload,ecr:DescribeRepositories,ecr:CreateRepository",
200 "Push Docker images to ECR",
201 )),
202 ("ecr-pull".into(), p(
203 "ecr:GetAuthorizationToken,ecr:BatchCheckLayerAvailability,ecr:GetDownloadUrlForLayer,ecr:BatchGetImage,ecr:DescribeRepositories",
204 "Pull Docker images from ECR",
205 )),
206 ("terraform-plan".into(), p(
207 "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",
208 "Terraform plan (read-only state + describe resources)",
209 )),
210 ("terraform-apply".into(), p(
211 "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:*",
212 "Terraform apply (full infrastructure management)",
213 )),
214 ("gcs-readonly".into(), p(
216 "storage.objects.get,storage.objects.list,storage.buckets.get,storage.buckets.list",
217 "Read-only Google Cloud Storage access",
218 )),
219 ("gcs-readwrite".into(), p(
220 "storage.objects.get,storage.objects.list,storage.objects.create,storage.objects.delete,storage.buckets.get,storage.buckets.list",
221 "Read/write Google Cloud Storage access",
222 )),
223 ("gce-readonly".into(), p(
224 "compute.instances.get,compute.instances.list,compute.zones.list,compute.regions.list",
225 "Read-only Compute Engine access",
226 )),
227 ("gcf-deploy".into(), p(
228 "cloudfunctions.functions.get,cloudfunctions.functions.list,cloudfunctions.functions.update,cloudfunctions.functions.create",
229 "Deploy Google Cloud Functions",
230 )),
231 ("bigquery-readonly".into(), p(
232 "bigquery.jobs.create,bigquery.tables.getData,bigquery.tables.list,bigquery.datasets.get",
233 "Read-only BigQuery access",
234 )),
235 ("azure-storage-readonly".into(), p(
237 "Microsoft.Storage/storageAccounts/read,Microsoft.Storage/storageAccounts/listKeys/action,Microsoft.Storage/storageAccounts/blobServices/containers/read",
238 "Read-only Azure Storage access",
239 )),
240 ("azure-storage-readwrite".into(), p(
241 "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",
242 "Read/write Azure Storage access",
243 )),
244 ("azure-compute-readonly".into(), p(
245 "Microsoft.Compute/virtualMachines/read,Microsoft.Compute/virtualMachines/instanceView/read,Microsoft.Network/networkInterfaces/read,Microsoft.Network/publicIPAddresses/read",
246 "Read-only Azure Compute access",
247 )),
248 ("azure-keyvault-readonly".into(), p(
249 "Microsoft.KeyVault/vaults/read,Microsoft.KeyVault/vaults/secrets/read",
250 "Read-only Azure Key Vault access",
251 )),
252 ("azure-functions-deploy".into(), p(
253 "Microsoft.Web/sites/read,Microsoft.Web/sites/write,Microsoft.Web/sites/functions/read,Microsoft.Web/sites/functions/write,Microsoft.Web/sites/restart/action",
254 "Deploy Azure Functions",
255 )),
256 ("azure-aks-readonly".into(), p(
257 "Microsoft.ContainerService/managedClusters/read,Microsoft.ContainerService/managedClusters/listClusterUserCredential/action",
258 "Read-only Azure Kubernetes Service access",
259 )),
260 ])
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266
267 #[test]
268 fn test_builtin_profile_resolve() {
269 let config = Config::default();
270 let profile = config.resolve_profile("s3-readonly").unwrap();
271 assert!(profile.allow.contains("s3:GetObject"));
272 assert!(profile.allow.contains("s3:ListBucket"));
273 }
274
275 #[test]
276 fn test_unknown_profile_error() {
277 let config = Config::default();
278 assert!(config.resolve_profile("nonexistent").is_err());
279 }
280
281 #[test]
282 fn test_user_profile_overrides_builtin() {
283 let mut config = Config::default();
284 config.profiles.insert(
285 "s3-readonly".into(),
286 Profile {
287 allow: "s3:GetObject".to_string(),
288 resource: Some("arn:aws:s3:::my-bucket/*".to_string()),
289 description: Some("Custom s3 readonly".to_string()),
290 },
291 );
292 let profile = config.resolve_profile("s3-readonly").unwrap();
293 assert_eq!(profile.allow, "s3:GetObject");
295 assert!(profile.resource.is_some());
296 }
297
298 #[test]
299 fn test_all_profiles_includes_builtins() {
300 let config = Config::default();
301 let all = config.all_profiles();
302 assert!(all.len() >= 10);
303 assert!(all.iter().any(|(name, _)| name == "s3-readonly"));
304 assert!(all.iter().any(|(name, _)| name == "lambda-deploy"));
305 }
306
307 #[test]
308 fn test_config_toml_with_profiles() {
309 let toml_str = r#"
310role_arn = "arn:aws:iam::123456789012:role/MyRole"
311ttl = "30m"
312
313[profiles.my-custom]
314allow = "s3:GetObject,s3:PutObject"
315resource = "arn:aws:s3:::my-bucket/*"
316description = "Custom profile"
317"#;
318 let config: Config = toml::from_str(toml_str).unwrap();
319 assert_eq!(
320 config.role_arn.unwrap(),
321 "arn:aws:iam::123456789012:role/MyRole"
322 );
323 assert_eq!(config.ttl.unwrap(), "30m");
324 let profile = config.profiles.get("my-custom").unwrap();
325 assert!(profile.allow.contains("s3:GetObject"));
326 assert!(profile.resource.is_some());
327 }
328}