Skip to main content

git_ark/
provision.rs

1//! AWS S3 vault provisioning (`git-ark vault provision`).
2//!
3//! Automates what `docs/provisioning.md` / `scripts/provision.sh` do by hand:
4//! create the S3 bucket (Object Lock capable, versioned, SSE, public access
5//! blocked, `history/` lifecycle) and a write-only IAM user scoped to
6//! `s3:PutObject` on the vault prefix, then mint an access key. It shells to
7//! the `aws` CLI so it uses the operator's existing credentials/SSO, discovers
8//! the configured profiles, and lets the operator pick one.
9//!
10//! **AWS S3 only.** The write-only guarantee relies on AWS IAM, so provisioning
11//! does not apply to MinIO, Cloudflare R2, or other S3-compatible stores — for
12//! those, create a bucket yourself and pass `--bucket`/`--endpoint` to
13//! `git-ark host add`.
14
15use anyhow::{anyhow, bail, Context, Result};
16use std::io::{IsTerminal, Write};
17use std::process::Command;
18
19/// Inputs for `vault provision`, mirrored from the CLI.
20pub struct ProvisionArgs {
21    pub bucket: String,
22    pub region: String,
23    pub prefix: String,
24    pub history_days: u32,
25    pub iam_user: String,
26    /// Explicit profile; when `None`, discover + prompt (or use ambient creds).
27    pub profile: Option<String>,
28    /// Skip the confirmation prompt (for non-interactive use).
29    pub yes: bool,
30}
31
32/// The write-only credential the provisioned vault hands back.
33pub struct Provisioned {
34    pub bucket: String,
35    pub region: String,
36    pub prefix: String,
37    pub key_id: String,
38    pub secret: String,
39}
40
41const BANNER: &str = "\
42git-ark vault provision — AWS S3 only.
43  Creates an S3 bucket and a write-only (PutObject-only) IAM key. The write-only
44  guarantee relies on AWS IAM, so this does NOT apply to MinIO, Cloudflare R2, or
45  other S3-compatible stores — for those, make a bucket yourself and pass
46  --bucket/--endpoint to `git-ark host add`.";
47
48/// Run the full `vault provision` flow: banner, profile selection, account
49/// confirmation, provisioning, and the write-only key + next-step output.
50pub fn run(args: &ProvisionArgs) -> Result<()> {
51    eprintln!("{BANNER}\n");
52
53    if !have_aws() {
54        bail!("the AWS CLI (`aws`) is required for provisioning — https://aws.amazon.com/cli/");
55    }
56
57    // 1. Which credentials? Explicit --profile wins; otherwise discover and let
58    //    the operator pick (or fall back to ambient env credentials).
59    let profile = match &args.profile {
60        Some(p) => Some(p.clone()),
61        None => choose_profile()?,
62    };
63
64    // 2. Show whose account this is and confirm before creating anything.
65    let (account, arn) = caller_identity(profile.as_deref()).with_context(|| {
66        format!(
67            "resolving the AWS identity for {} — is it logged in (e.g. `aws sso login`)?",
68            profile.as_deref().unwrap_or("environment credentials")
69        )
70    })?;
71    eprintln!(
72        "About to provision, using {}:\n  bucket:   s3://{}/{}/\n  region:   {}\n  account:  {account}  ({arn})\n  iam user: {}  (PutObject-only)\n",
73        profile.as_deref().unwrap_or("environment credentials"),
74        args.bucket,
75        args.prefix,
76        args.region,
77        args.iam_user,
78    );
79    if !args.yes && !confirm("Create these AWS resources?")? {
80        bail!("aborted — nothing was created");
81    }
82
83    // 3. Provision.
84    let p = provision(args, profile.as_deref())?;
85
86    // 4. Hand back the write-only key + the next step. The secret is printed
87    //    once; there is nowhere else it is stored.
88    println!("\n✓ vault provisioned: s3://{}/{}/", p.bucket, p.prefix);
89    println!("\nWrite-only key (paste into the host via `host add`, then clear your scrollback):");
90    println!("  export GIT_ARK_HOST_S3_KEY_ID={}", p.key_id);
91    println!("  export GIT_ARK_HOST_S3_SECRET={}", p.secret);
92    println!(
93        "\nThen wire a host to it:\n  git-ark host add <name> <user@host> \\\n      --bucket {} --region {} --recipient age1…",
94        p.bucket, p.region
95    );
96    Ok(())
97}
98
99fn have_aws() -> bool {
100    Command::new("aws")
101        .arg("--version")
102        .output()
103        .map(|o| o.status.success())
104        .unwrap_or(false)
105}
106
107/// Discover configured AWS profiles and let the operator pick one, or use
108/// ambient environment credentials. Requires a TTY when `--profile` wasn't
109/// given; returns `None` for the ambient-credentials choice.
110fn choose_profile() -> Result<Option<String>> {
111    let profiles = list_profiles()?;
112    if !std::io::stdin().is_terminal() {
113        bail!("no TTY for interactive profile selection — pass --profile <name>");
114    }
115    eprintln!("Which AWS credentials?");
116    for (i, p) in profiles.iter().enumerate() {
117        eprintln!("  {}) {p}", i + 1);
118    }
119    let env_choice = profiles.len() + 1;
120    eprintln!("  {env_choice}) environment / default credentials (no profile)");
121
122    loop {
123        eprint!("Choose [1-{env_choice}]: ");
124        std::io::stderr().flush().ok();
125        let line = read_line()?;
126        match line.trim().parse::<usize>() {
127            Ok(n) if (1..=profiles.len()).contains(&n) => return Ok(Some(profiles[n - 1].clone())),
128            Ok(n) if n == env_choice => return Ok(None),
129            _ => eprintln!("  please enter a number between 1 and {env_choice}"),
130        }
131    }
132}
133
134/// Configured profile names, via `aws configure list-profiles`.
135fn list_profiles() -> Result<Vec<String>> {
136    let out = Command::new("aws")
137        .args(["configure", "list-profiles"])
138        .output()
139        .map_err(|e| anyhow!("running aws configure list-profiles: {e}"))?;
140    if !out.status.success() {
141        // Older CLIs may lack the subcommand; treat as "no profiles discovered".
142        return Ok(Vec::new());
143    }
144    Ok(String::from_utf8_lossy(&out.stdout)
145        .lines()
146        .map(str::trim)
147        .filter(|l| !l.is_empty())
148        .map(str::to_string)
149        .collect())
150}
151
152/// `(account_id, arn)` for the selected credentials, via
153/// `sts get-caller-identity`.
154fn caller_identity(profile: Option<&str>) -> Result<(String, String)> {
155    let out = aws(profile, &["sts", "get-caller-identity", "--output", "json"])?;
156    if !out.status.success() {
157        bail!("{}", String::from_utf8_lossy(&out.stderr).trim());
158    }
159    let json: serde_json::Value =
160        serde_json::from_slice(&out.stdout).context("parsing sts get-caller-identity output")?;
161    let account = json
162        .get("Account")
163        .and_then(|v| v.as_str())
164        .unwrap_or("<unknown>")
165        .to_string();
166    let arn = json
167        .get("Arn")
168        .and_then(|v| v.as_str())
169        .unwrap_or("<unknown>")
170        .to_string();
171    Ok((account, arn))
172}
173
174/// The IAM inline policy: `s3:PutObject` and nothing else, scoped to the vault
175/// prefix. No Get/List/Delete — a compromised host can only add objects.
176pub fn put_only_policy(bucket: &str, prefix: &str) -> String {
177    format!(
178        r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::{bucket}/{prefix}/*"}}]}}"#
179    )
180}
181
182/// The lifecycle config that expires `<prefix>/` objects + noncurrent versions
183/// after `days`. Retention is enforced here, since the vault itself can't delete.
184pub fn lifecycle_config(prefix: &str, days: u32) -> String {
185    format!(
186        r#"{{"Rules":[{{"ID":"expire-history","Status":"Enabled","Filter":{{"Prefix":"{prefix}/"}},"Expiration":{{"Days":{days}}},"NoncurrentVersionExpiration":{{"NoncurrentDays":{days}}}}}]}}"#
187    )
188}
189
190/// Extract `(AccessKeyId, SecretAccessKey)` from `iam create-access-key` JSON.
191pub fn parse_access_key(json_bytes: &[u8]) -> Result<(String, String)> {
192    let json: serde_json::Value =
193        serde_json::from_slice(json_bytes).context("parsing create-access-key output")?;
194    let key = json
195        .get("AccessKey")
196        .ok_or_else(|| anyhow!("create-access-key output missing AccessKey"))?;
197    let id = key
198        .get("AccessKeyId")
199        .and_then(|v| v.as_str())
200        .ok_or_else(|| anyhow!("create-access-key output missing AccessKeyId"))?;
201    let secret = key
202        .get("SecretAccessKey")
203        .and_then(|v| v.as_str())
204        .ok_or_else(|| anyhow!("create-access-key output missing SecretAccessKey"))?;
205    Ok((id.to_string(), secret.to_string()))
206}
207
208/// The eight provisioning steps (ports `scripts/provision.sh`). `create-bucket`
209/// and `create-user` tolerate "already exists"; the `put-*` calls are
210/// idempotent; `create-access-key` mints a new key each run.
211fn provision(args: &ProvisionArgs, profile: Option<&str>) -> Result<Provisioned> {
212    // 1. Bucket (Object Lock enabled — only possible at creation; requires
213    //    versioning). Region needs a LocationConstraint everywhere but us-east-1.
214    eprintln!("• creating bucket {} (Object Lock enabled)…", args.bucket);
215    let lc = format!("LocationConstraint={}", args.region);
216    let mut create = vec![
217        "s3api",
218        "create-bucket",
219        "--bucket",
220        &args.bucket,
221        "--object-lock-enabled-for-bucket",
222        "--region",
223        &args.region,
224    ];
225    if args.region != "us-east-1" {
226        create.push("--create-bucket-configuration");
227        create.push(&lc);
228    }
229    aws_tolerate(
230        profile,
231        &create,
232        &["BucketAlreadyOwnedByYou", "BucketAlreadyExists"],
233    )?;
234
235    // 2. Versioning. Enabling Object Lock already turns versioning on (it's a
236    //    requirement), so this is belt-and-suspenders. Some backends reject
237    //    re-setting versioning once Object Lock is present ("Object Lock
238    //    configuration is present, so the versioning state cannot be changed") —
239    //    that error means versioning is already Enabled, which is the goal, so
240    //    tolerate it.
241    eprintln!("• enabling versioning…");
242    aws_tolerate(
243        profile,
244        &[
245            "s3api",
246            "put-bucket-versioning",
247            "--bucket",
248            &args.bucket,
249            "--versioning-configuration",
250            "Status=Enabled",
251        ],
252        &["Object Lock configuration is present"],
253    )?;
254
255    // 3. Block all public access.
256    eprintln!("• blocking all public access…");
257    aws_ok(
258        profile,
259        &[
260            "s3api",
261            "put-public-access-block",
262            "--bucket",
263            &args.bucket,
264            "--public-access-block-configuration",
265            "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true",
266        ],
267    )?;
268
269    // 4. Default SSE (AES256) — defense-in-depth atop client-side age.
270    eprintln!("• default server-side encryption (AES256)…");
271    aws_ok(
272        profile,
273        &[
274            "s3api",
275            "put-bucket-encryption",
276            "--bucket",
277            &args.bucket,
278            "--server-side-encryption-configuration",
279            r#"{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}"#,
280        ],
281    )?;
282
283    // 5. Lifecycle: expire history/ after history_days.
284    eprintln!(
285        "• lifecycle: expire {}/ after {} days…",
286        args.prefix, args.history_days
287    );
288    let lifecycle = lifecycle_config(&args.prefix, args.history_days);
289    aws_ok(
290        profile,
291        &[
292            "s3api",
293            "put-bucket-lifecycle-configuration",
294            "--bucket",
295            &args.bucket,
296            "--lifecycle-configuration",
297            &lifecycle,
298        ],
299    )?;
300
301    // 6. Write-only IAM user.
302    eprintln!("• write-only IAM user {}…", args.iam_user);
303    aws_tolerate(
304        profile,
305        &["iam", "create-user", "--user-name", &args.iam_user],
306        &["EntityAlreadyExists"],
307    )?;
308
309    // 7. PutObject-only inline policy.
310    let policy = put_only_policy(&args.bucket, &args.prefix);
311    aws_ok(
312        profile,
313        &[
314            "iam",
315            "put-user-policy",
316            "--user-name",
317            &args.iam_user,
318            "--policy-name",
319            "git-ark-put-only",
320            "--policy-document",
321            &policy,
322        ],
323    )?;
324
325    // 8. Mint an access key.
326    eprintln!("• minting access key…");
327    let out = aws(
328        profile,
329        &[
330            "iam",
331            "create-access-key",
332            "--user-name",
333            &args.iam_user,
334            "--output",
335            "json",
336        ],
337    )?;
338    if !out.status.success() {
339        bail!(
340            "creating access key: {}",
341            String::from_utf8_lossy(&out.stderr).trim()
342        );
343    }
344    let (key_id, secret) = parse_access_key(&out.stdout)?;
345
346    Ok(Provisioned {
347        bucket: args.bucket.clone(),
348        region: args.region.clone(),
349        prefix: args.prefix.clone(),
350        key_id,
351        secret,
352    })
353}
354
355/// Run `aws [--profile P] <args>`, returning the raw output.
356fn aws(profile: Option<&str>, args: &[&str]) -> Result<std::process::Output> {
357    let mut cmd = Command::new("aws");
358    if let Some(p) = profile {
359        cmd.arg("--profile").arg(p);
360    }
361    cmd.args(args)
362        .output()
363        .map_err(|e| anyhow!("running aws {}: {e}", args.join(" ")))
364}
365
366/// Run an `aws` call that must succeed; surface stderr on failure.
367fn aws_ok(profile: Option<&str>, args: &[&str]) -> Result<()> {
368    let out = aws(profile, args)?;
369    if !out.status.success() {
370        bail!(
371            "aws {} failed: {}",
372            args.join(" "),
373            String::from_utf8_lossy(&out.stderr).trim()
374        );
375    }
376    Ok(())
377}
378
379/// Like `aws_ok`, but a failure whose stderr contains any `tolerate` marker is
380/// treated as success (the resource already exists).
381fn aws_tolerate(profile: Option<&str>, args: &[&str], tolerate: &[&str]) -> Result<()> {
382    let out = aws(profile, args)?;
383    if out.status.success() {
384        return Ok(());
385    }
386    let stderr = String::from_utf8_lossy(&out.stderr);
387    if tolerate.iter().any(|m| stderr.contains(m)) {
388        return Ok(());
389    }
390    bail!("aws {} failed: {}", args.join(" "), stderr.trim());
391}
392
393fn confirm(prompt: &str) -> Result<bool> {
394    eprint!("{prompt} [y/N]: ");
395    std::io::stderr().flush().ok();
396    let line = read_line()?;
397    let a = line.trim().to_ascii_lowercase();
398    Ok(a == "y" || a == "yes")
399}
400
401fn read_line() -> Result<String> {
402    let mut s = String::new();
403    std::io::stdin()
404        .read_line(&mut s)
405        .context("reading from stdin")?;
406    Ok(s)
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    #[test]
414    fn put_only_policy_is_putobject_scoped_to_prefix() {
415        let p = put_only_policy("git-ark-vault-123", "git-ark");
416        assert!(p.contains(r#""Action":"s3:PutObject""#));
417        assert!(p.contains(r#""Resource":"arn:aws:s3:::git-ark-vault-123/git-ark/*""#));
418        // Nothing else is granted.
419        assert!(!p.contains("GetObject"));
420        assert!(!p.contains("ListBucket"));
421        assert!(!p.contains("DeleteObject"));
422        // Valid JSON.
423        serde_json::from_str::<serde_json::Value>(&p).unwrap();
424    }
425
426    #[test]
427    fn lifecycle_config_expires_prefix_and_noncurrent() {
428        let l = lifecycle_config("git-ark", 90);
429        let v: serde_json::Value = serde_json::from_str(&l).unwrap();
430        let rule = &v["Rules"][0];
431        assert_eq!(rule["Filter"]["Prefix"], "git-ark/");
432        assert_eq!(rule["Expiration"]["Days"], 90);
433        assert_eq!(rule["NoncurrentVersionExpiration"]["NoncurrentDays"], 90);
434    }
435
436    #[test]
437    fn parse_access_key_extracts_id_and_secret() {
438        let json = br#"{"AccessKey":{"UserName":"git-ark-nas","AccessKeyId":"AKIAEXAMPLE","SecretAccessKey":"s3cr3t/value","Status":"Active"}}"#;
439        let (id, secret) = parse_access_key(json).unwrap();
440        assert_eq!(id, "AKIAEXAMPLE");
441        assert_eq!(secret, "s3cr3t/value");
442    }
443
444    #[test]
445    fn parse_access_key_errors_without_accesskey() {
446        assert!(parse_access_key(br#"{"nope":true}"#).is_err());
447    }
448}