Skip to main content

dove_core/provision/
mod.rs

1//! Stand up the self-hosted backend from your own AWS account, using the `aws`
2//! CLI so it rides your existing credentials/SSO. The simple tier creates a
3//! private bucket (all public access blocked) with a lifecycle rule that
4//! auto-deletes objects after a ceiling of days, and mints a **least-privilege
5//! IAM user** scoped to just this bucket, whose key `share` signs with — never
6//! your full account credentials, and with a long-term key so presigned links
7//! get their full requested lifetime. The full tier additionally provisions the
8//! gate: DynamoDB (share policies) + a Lambda (role, function) + API Gateway +
9//! CloudFront + the cost circuit-breaker + the SSM-held MAC secret.
10//!
11//! Moved from the `dove` CLI's `src/provision.rs` — the AWS orchestration
12//! (commands, order, idempotency tolerations, retries) is unchanged. What
13//! changed crossing the extraction boundary:
14//! - Terminal output (`ui::step`/`ui::field`) became `Progress` calls — this
15//!   crate does no terminal I/O of its own.
16//! - The interactive helpers (`choose_profile`, `confirm`) stayed in the CLI;
17//!   these functions take an already-resolved `profile` and assume the caller
18//!   already confirmed.
19//! - Instead of loading/saving the config registry, these functions take the
20//!   prior config (if any) as a parameter and *return* the new config — the
21//!   caller (the CLI) owns persisting it.
22
23pub mod apigw;
24pub mod breaker;
25pub mod cloudfront;
26pub mod domain;
27pub mod gate;
28
29use crate::config::SelfHostedConfig;
30use crate::progress::Progress;
31use anyhow::{anyhow, bail, Context, Result};
32use std::process::Command;
33
34pub struct ProvisionArgs {
35    /// Override the derived bucket name (default `dove-shares-<account-id>`).
36    pub bucket: Option<String>,
37    pub region: String,
38    pub expire_days: u32,
39}
40
41/// Provision the simple tier: a private, auto-expiring bucket + a scoped IAM
42/// user/key. `profile` is already resolved (the CLI's interactive
43/// `choose_profile` ran before this, if at all) and the operator already
44/// confirmed.
45pub fn provision_simple(
46    args: &ProvisionArgs,
47    profile: Option<String>,
48    progress: &dyn Progress,
49) -> Result<SelfHostedConfig> {
50    let (_account, bucket) = base_provision(args, profile.as_deref(), progress)?;
51    Ok(SelfHostedConfig {
52        bucket,
53        region: args.region.clone(),
54        profile,
55        endpoint: None,
56        table: None,
57        gate_url: None,
58        distribution_id: None,
59    })
60}
61
62/// Provision the full tier: the simple tier's bucket, plus the gate (DynamoDB +
63/// Lambda + API Gateway + CloudFront + cost breaker). Idempotent: re-running
64/// reuses existing resources (the IAM access key, the API by name, the
65/// CloudFront distribution from `existing`).
66///
67/// `existing` is the currently active config, if any — read by the caller
68/// before calling this. It's what lets a re-provision keep a custom gate_url
69/// (set by `domain add`) instead of reverting to the bare `*.cloudfront.net`
70/// domain, and reuse the existing CloudFront distribution instead of minting a
71/// second one.
72pub fn provision_full(
73    args: &ProvisionArgs,
74    profile: Option<String>,
75    existing: Option<SelfHostedConfig>,
76    progress: &dyn Progress,
77) -> Result<SelfHostedConfig> {
78    let (account, bucket) = base_provision(args, profile.as_deref(), progress)?;
79
80    let prior_gate_url = existing.as_ref().and_then(|c| c.gate_url.clone());
81    let existing_dist = existing.and_then(|c| c.distribution_id);
82
83    let infra = build_full_infra(
84        profile.as_deref(),
85        &account,
86        &bucket,
87        &args.region,
88        existing_dist.as_deref(),
89        progress,
90    )?;
91
92    // Upgrade the scoped signing user so `dove share` can register a share's
93    // policy row in DynamoDB with its own key — no operator credentials at share
94    // time. Provisioning is the only step that still needs the operator profile.
95    progress.step("scoped key · gate write");
96    let scoped = share_policy_full(&bucket, &args.region, &account, &infra.table);
97    aws_ok(
98        profile.as_deref(),
99        &[
100            "iam",
101            "put-user-policy",
102            "--user-name",
103            &bucket,
104            "--policy-name",
105            "dove-share",
106            "--policy-document",
107            &scoped,
108        ],
109        &[],
110    )?;
111    progress.done("scoped key · gate write");
112
113    // If a custom domain was already added (`dove domain add`), keep it — don't
114    // revert to the *.cloudfront.net URL on a re-provision.
115    let gate_url = match prior_gate_url {
116        Some(existing) if !existing.contains(".cloudfront.net") => existing,
117        _ => infra.gate_url,
118    };
119
120    Ok(SelfHostedConfig {
121        bucket,
122        region: args.region.clone(),
123        profile,
124        endpoint: None,
125        table: Some(infra.table),
126        gate_url: Some(gate_url),
127        distribution_id: infra.distribution_id,
128    })
129}
130
131/// The steps common to both tiers: create the bucket, block public access, set
132/// the lifecycle rule, and mint (or reuse) the scoped IAM user + key. Returns
133/// the resolved `(account, bucket)`.
134fn base_provision(
135    args: &ProvisionArgs,
136    profile: Option<&str>,
137    progress: &dyn Progress,
138) -> Result<(String, String)> {
139    let (account, _arn) = caller_identity(profile).with_context(|| {
140        format!(
141            "resolving the AWS identity for {} — is it logged in (e.g. `aws sso login`)?",
142            profile.unwrap_or("the default profile")
143        )
144    })?;
145    let bucket = derive_bucket(&account, args.bucket.as_deref());
146
147    // 1. Create the bucket. us-east-1 must NOT get a LocationConstraint.
148    let mut create = vec!["s3api", "create-bucket", "--bucket", bucket.as_str()];
149    let lc = format!("LocationConstraint={}", args.region);
150    if args.region != "us-east-1" {
151        create.push("--region");
152        create.push(&args.region);
153        create.push("--create-bucket-configuration");
154        create.push(&lc);
155    }
156    progress.step("creating bucket");
157    let created = aws_ok(profile, &create, &["BucketAlreadyOwnedByYou"]);
158    if created.is_ok() {
159        progress.done("creating bucket");
160    }
161    created?;
162
163    // 2. Block ALL public access — shares are reached by presigned URL only.
164    progress.step("blocking public access");
165    let blocked = aws_ok(
166        profile,
167        &[
168            "s3api",
169            "put-public-access-block",
170            "--bucket",
171            &bucket,
172            "--public-access-block-configuration",
173            PUBLIC_ACCESS_BLOCK,
174        ],
175        &[],
176    );
177    if blocked.is_ok() {
178        progress.done("blocking public access");
179    }
180    blocked?;
181
182    // 3. Lifecycle: auto-delete objects after the ceiling of days.
183    let lifecycle = lifecycle_config(args.expire_days);
184    let lifecycle_label = format!("lifecycle · {} days", args.expire_days);
185    progress.step(&lifecycle_label);
186    let lifecycled = aws_ok(
187        profile,
188        &[
189            "s3api",
190            "put-bucket-lifecycle-configuration",
191            "--bucket",
192            &bucket,
193            "--lifecycle-configuration",
194            &lifecycle,
195        ],
196        &[],
197    );
198    if lifecycled.is_ok() {
199        progress.done(&lifecycle_label);
200    }
201    lifecycled?;
202
203    // 4. A least-privilege IAM user dove signs share links with — so links
204    //    aren't signed with your full account creds, and (crucially) their
205    //    expiry isn't capped by an SSO session's lifetime. Same name as the
206    //    bucket, different namespace.
207    let iam_user = bucket.clone();
208    progress.step("scoped IAM user");
209    let user_created = aws_ok(
210        profile,
211        &["iam", "create-user", "--user-name", &iam_user],
212        &["EntityAlreadyExists"],
213    );
214    if user_created.is_ok() {
215        progress.done("scoped IAM user");
216    }
217    user_created?;
218
219    progress.step("least-privilege policy");
220    let policy = share_policy(&bucket);
221    let policy_put = aws_ok(
222        profile,
223        &[
224            "iam",
225            "put-user-policy",
226            "--user-name",
227            &iam_user,
228            "--policy-name",
229            "dove-share",
230            "--policy-document",
231            &policy,
232        ],
233        &[],
234    );
235    if policy_put.is_ok() {
236        progress.done("least-privilege policy");
237    }
238    policy_put?;
239
240    // Mint a key only if we don't already have one — a re-provision reuses it
241    // (an IAM user can hold at most two keys; don't orphan the old one).
242    if crate::secrets::Secrets::exists() {
243        progress.step("access key (reusing)");
244        progress.done("access key (reusing)");
245    } else {
246        progress.step("minting access key");
247        let minted = (|| -> Result<()> {
248            let out = aws(
249                profile,
250                &[
251                    "iam",
252                    "create-access-key",
253                    "--user-name",
254                    &iam_user,
255                    "--output",
256                    "json",
257                ],
258            )?;
259            if !out.status.success() {
260                bail!(
261                    "creating access key: {}",
262                    String::from_utf8_lossy(&out.stderr).trim()
263                );
264            }
265            let (id, secret) = parse_access_key(&out.stdout)?;
266            crate::secrets::Secrets {
267                access_key_id: id,
268                secret_access_key: secret,
269                gate_secret: None,
270            }
271            .save()
272        })();
273        if minted.is_ok() {
274            progress.done("minting access key");
275        }
276        minted?;
277    }
278
279    Ok((account, bucket))
280}
281
282/// The full tier's extra infrastructure, standing on the simple-tier bucket.
283struct FullInfra {
284    table: String,
285    gate_url: String,
286    distribution_id: Option<String>,
287}
288
289/// Provision the gate: DynamoDB (share policies) + the Lambda (role, function) +
290/// API Gateway + CloudFront. Idempotent: re-running reuses existing resources
291/// (API by name, distribution from `existing_distribution`).
292fn build_full_infra(
293    profile: Option<&str>,
294    account: &str,
295    bucket: &str,
296    region: &str,
297    existing_distribution: Option<&str>,
298    progress: &dyn Progress,
299) -> Result<FullInfra> {
300    let table = bucket.to_string(); // same name as the bucket, different namespace
301    let name = format!("dove-gate-{account}"); // role + lambda share this name
302
303    // Bucket CORS so the browser decryptor can read the ciphertext from the
304    // presigned URL cross-origin (the gate 302s to S3; the fetch is cross-site).
305    progress.step("bucket CORS");
306    let cors = aws_ok(
307        profile,
308        &[
309            "s3api",
310            "put-bucket-cors",
311            "--bucket",
312            bucket,
313            "--cors-configuration",
314            BUCKET_CORS,
315        ],
316        &[],
317    );
318    if cors.is_ok() {
319        progress.done("bucket CORS");
320    }
321    cors?;
322
323    // DynamoDB table with TTL on expires_at (auto-cleanup of dead policies).
324    progress.step("dynamodb table");
325    let dynamo = (|| -> Result<()> {
326        aws_ok(
327            profile,
328            &[
329                "dynamodb",
330                "create-table",
331                "--table-name",
332                &table,
333                "--attribute-definitions",
334                "AttributeName=id,AttributeType=S",
335                "--key-schema",
336                "AttributeName=id,KeyType=HASH",
337                "--billing-mode",
338                "PAY_PER_REQUEST",
339            ],
340            &["ResourceInUseException"],
341        )?;
342        aws_ok(
343            profile,
344            &["dynamodb", "wait", "table-exists", "--table-name", &table],
345            &[],
346        )?;
347        aws_ok(
348            profile,
349            &[
350                "dynamodb",
351                "update-time-to-live",
352                "--table-name",
353                &table,
354                "--time-to-live-specification",
355                "Enabled=true,AttributeName=expires_at",
356            ],
357            &["TimeToLive is already enabled"],
358        )
359    })();
360    if dynamo.is_ok() {
361        progress.done("dynamodb table");
362    }
363    dynamo?;
364
365    // The gate's execution role.
366    let role_arn = format!("arn:aws:iam::{account}:role/{name}");
367    progress.step("gate IAM role");
368    let role = (|| -> Result<()> {
369        aws_ok(
370            profile,
371            &[
372                "iam",
373                "create-role",
374                "--role-name",
375                &name,
376                "--assume-role-policy-document",
377                LAMBDA_TRUST,
378            ],
379            &["EntityAlreadyExists"],
380        )?;
381        let policy = gate_role_policy(account, region, &table, bucket);
382        aws_ok(
383            profile,
384            &[
385                "iam",
386                "put-role-policy",
387                "--role-name",
388                &name,
389                "--policy-name",
390                "dove-gate",
391                "--policy-document",
392                &policy,
393            ],
394            &[],
395        )
396    })();
397    if role.is_ok() {
398        progress.done("gate IAM role");
399    }
400    role?;
401
402    // The gate secret — the HMAC key that mints/verifies unforgeable share ids.
403    // Stable across re-provision (generated once, kept in secrets.toml). Stored in
404    // SSM as a SecureString (encrypted, not readable from the function config);
405    // the Lambda reads it at cold start. The env carries only the parameter name.
406    let gate_secret = ensure_gate_secret()?;
407    let secret_param = format!("/dove/{bucket}/gate-secret");
408    progress.step("gate secret (SSM)");
409    let secret_put = aws_ok(
410        profile,
411        &[
412            "ssm",
413            "put-parameter",
414            "--name",
415            &secret_param,
416            "--value",
417            &gate_secret,
418            "--type",
419            "SecureString",
420            "--overwrite",
421        ],
422        &[],
423    );
424    if secret_put.is_ok() {
425        progress.done("gate secret (SSM)");
426    }
427    secret_put?;
428
429    // The gate Lambda. A freshly-created role isn't assumable for a few seconds,
430    // so retry create-function on that specific error.
431    let zip = temp_path("zip");
432    gate::write_deployment_zip(&zip)?;
433    let zip_arg = format!("fileb://{}", zip.display());
434    let env =
435        format!("Variables={{BUCKET={bucket},TABLE={table},GATE_SECRET_PARAM={secret_param}}}");
436    progress.step("gate Lambda");
437    let lambda_result = (|| -> Result<()> {
438        aws_retry(
439            profile,
440            &[
441                "lambda",
442                "create-function",
443                "--function-name",
444                &name,
445                "--runtime",
446                gate::RUNTIME,
447                "--handler",
448                gate::HANDLER,
449                "--role",
450                &role_arn,
451                "--zip-file",
452                &zip_arg,
453                "--environment",
454                &env,
455                "--timeout",
456                "30",
457            ],
458            &["ResourceConflictException"],
459            "cannot be assumed",
460            6,
461        )?;
462        // A freshly created function is 'Pending'/'Creating' and rejects code
463        // updates until it's Active. Wait for that (best-effort — the retry below
464        // is the real guard), then push the latest gate code + page. On a re-
465        // provision this updates an existing function; on a fresh create it's a
466        // no-op redeploy of the same code. The "cannot be performed at this time"
467        // message covers every not-ready state (Creating / Pending / InProgress).
468        let _ = aws(
469            profile,
470            &[
471                "lambda",
472                "wait",
473                "function-active-v2",
474                "--function-name",
475                &name,
476            ],
477        );
478        aws_retry(
479            profile,
480            &[
481                "lambda",
482                "update-function-code",
483                "--function-name",
484                &name,
485                "--zip-file",
486                &zip_arg,
487            ],
488            &[],
489            "cannot be performed at this time",
490            10,
491        )?;
492        // Let the code update settle before the config update.
493        let _ = aws(
494            profile,
495            &[
496                "lambda",
497                "wait",
498                "function-updated-v2",
499                "--function-name",
500                &name,
501            ],
502        );
503        // Set the env (BUCKET/TABLE/GATE_SECRET_PARAM) — a fresh create already has it,
504        // but a re-provision of an existing function needs it applied here.
505        aws_retry(
506            profile,
507            &[
508                "lambda",
509                "update-function-configuration",
510                "--function-name",
511                &name,
512                "--environment",
513                &env,
514            ],
515            &[],
516            "cannot be performed at this time",
517            10,
518        )?;
519        let _ = aws(
520            profile,
521            &[
522                "lambda",
523                "wait",
524                "function-updated-v2",
525                "--function-name",
526                &name,
527            ],
528        );
529        Ok(())
530    })();
531    let _ = std::fs::remove_file(&zip);
532    if lambda_result.is_ok() {
533        progress.done("gate Lambda");
534    }
535    lambda_result?;
536
537    // Public front: API Gateway → Lambda, behind CloudFront. (Public Function URLs
538    // don't work in every account; the API Gateway hop uses lambda:InvokeFunction,
539    // which is universally allowed.)
540    let function_arn = format!("arn:aws:lambda:{region}:{account}:function:{name}");
541    let api = apigw::provision_api(profile, region, account, &name, &function_arn, progress)?;
542    // Reuse an existing distribution on re-provision (from the caller-supplied
543    // prior config).
544    let front =
545        cloudfront::front_gate(profile, account, &api.host, existing_distribution, progress)?;
546
547    // Cost circuit-breaker: a flood auto-disables the gate before it can run up a
548    // bill. A public endpoint on the operator's account should never exist without
549    // this backstop.
550    breaker::provision_breaker(profile, region, account, &name, progress)?;
551
552    Ok(FullInfra {
553        table,
554        gate_url: format!("https://{}", front.domain),
555        distribution_id: Some(front.distribution_id),
556    })
557}
558
559/// Load the gate secret, generating and persisting one the first time. Kept in
560/// secrets.toml so `share` can mint ids and so it's stable across re-provision
561/// (regenerating it would invalidate every outstanding link).
562fn ensure_gate_secret() -> Result<String> {
563    let mut s = crate::secrets::Secrets::load()?;
564    if let Some(g) = &s.gate_secret {
565        return Ok(g.clone());
566    }
567    let g = crate::crypto::gen_gate_secret();
568    s.gate_secret = Some(g.clone());
569    s.save()?;
570    Ok(g)
571}
572
573/// The bucket name: the override if given, else derived from the account id.
574pub fn derive_bucket(account: &str, override_bucket: Option<&str>) -> String {
575    override_bucket
576        .map(str::to_string)
577        .unwrap_or_else(|| format!("dove-shares-{account}"))
578}
579
580/// Whether the `aws` CLI is on PATH — provisioning shells out to it so it rides
581/// the operator's existing credentials/SSO config.
582pub fn have_aws() -> bool {
583    Command::new("aws")
584        .arg("--version")
585        .output()
586        .map(|o| o.status.success())
587        .unwrap_or(false)
588}
589
590/// The caller's AWS account id and identity ARN, via `sts get-caller-identity`.
591/// Used both to derive the default bucket name and (by the CLI) to show the
592/// operator what they're about to provision into before they confirm.
593pub fn caller_identity(profile: Option<&str>) -> Result<(String, String)> {
594    let out = aws(profile, &["sts", "get-caller-identity", "--output", "json"])?;
595    if !out.status.success() {
596        bail!("{}", String::from_utf8_lossy(&out.stderr).trim());
597    }
598    let v: serde_json::Value = serde_json::from_slice(&out.stdout)?;
599    let account = v["Account"].as_str().unwrap_or("?").to_string();
600    let arn = v["Arn"].as_str().unwrap_or("?").to_string();
601    Ok((account, arn))
602}
603
604/// Trust policy letting Lambda assume the gate's role.
605const LAMBDA_TRUST: &str = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}"#;
606
607/// Bucket CORS: let any origin GET (the browser decryptor fetching ciphertext
608/// from the presigned URL) and POST (the browser uploader submitting a
609/// presigned POST policy for a file request). Both are still gated by the
610/// presign + the gate — this only lifts the cross-origin block the browser
611/// itself would otherwise enforce.
612const BUCKET_CORS: &str = r#"{"CORSRules":[{"AllowedOrigins":["*"],"AllowedMethods":["GET","POST"],"AllowedHeaders":["*"],"MaxAgeSeconds":3000}]}"#;
613
614/// The gate role's inline policy: log, decrement the one table, presign from the
615/// one bucket. Nothing else.
616pub fn gate_role_policy(account: &str, region: &str, table: &str, bucket: &str) -> String {
617    format!(
618        r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Action":["logs:CreateLogGroup","logs:CreateLogStream","logs:PutLogEvents"],"Resource":"arn:aws:logs:*:{account}:*"}},{{"Effect":"Allow","Action":["dynamodb:GetItem","dynamodb:UpdateItem"],"Resource":"arn:aws:dynamodb:{region}:{account}:table/{table}"}},{{"Effect":"Allow","Action":["s3:GetObject","s3:PutObject"],"Resource":"arn:aws:s3:::{bucket}/*"}},{{"Effect":"Allow","Action":"ssm:GetParameter","Resource":"arn:aws:ssm:{region}:{account}:parameter/dove/{bucket}/gate-secret"}},{{"Effect":"Allow","Action":"kms:Decrypt","Resource":"*"}}]}}"#
619    )
620}
621
622/// A unique temp path with the given extension.
623fn temp_path(ext: &str) -> std::path::PathBuf {
624    let mut b = [0u8; 8];
625    getrandom::getrandom(&mut b).expect("OS RNG");
626    let hex: String = b.iter().map(|x| format!("{x:02x}")).collect();
627    std::env::temp_dir().join(format!("dove-{hex}.{ext}"))
628}
629
630/// Like `aws_ok`, but retry on a specific stderr substring (e.g. IAM
631/// propagation delays), sleeping 3s between attempts.
632fn aws_retry(
633    profile: Option<&str>,
634    args: &[&str],
635    tolerate: &[&str],
636    retry_on: &str,
637    attempts: u32,
638) -> Result<()> {
639    for i in 0..attempts {
640        let out = aws(profile, args)?;
641        if out.status.success() {
642            return Ok(());
643        }
644        let stderr = String::from_utf8_lossy(&out.stderr);
645        if tolerate.iter().any(|t| stderr.contains(t)) {
646            return Ok(());
647        }
648        if stderr.contains(retry_on) && i + 1 < attempts {
649            std::thread::sleep(std::time::Duration::from_secs(3));
650            continue;
651        }
652        bail!("aws {} failed: {}", args.join(" "), stderr.trim());
653    }
654    Ok(())
655}
656
657/// All four public-access-block switches on.
658const PUBLIC_ACCESS_BLOCK: &str = "BlockPublicAcls=true,IgnorePublicAcls=true,\
659     BlockPublicPolicy=true,RestrictPublicBuckets=true";
660
661/// The lifecycle configuration JSON: expire every object `days` after creation.
662pub fn lifecycle_config(days: u32) -> String {
663    format!(
664        r#"{{"Rules":[{{"ID":"dove-expire","Status":"Enabled","Filter":{{}},"Expiration":{{"Days":{days}}}}}]}}"#
665    )
666}
667
668/// The least-privilege IAM policy dove's signing user gets: read/write/delete
669/// objects and list — scoped to this one bucket, nothing else in the account.
670pub fn share_policy(bucket: &str) -> String {
671    format!(
672        r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Action":["s3:PutObject","s3:GetObject","s3:DeleteObject"],"Resource":"arn:aws:s3:::{bucket}/*"}},{{"Effect":"Allow","Action":["s3:ListBucket"],"Resource":"arn:aws:s3:::{bucket}"}}]}}"#
673    )
674}
675
676/// The full-tier signing policy: everything `share_policy` grants, plus
677/// `dynamodb:PutItem` on the gate table — so `dove share` registers a share's
678/// access policy with its own scoped key, never the operator's credentials.
679/// (Provisioning still uses the operator profile; that's the rare, gated part.)
680pub fn share_policy_full(bucket: &str, region: &str, account: &str, table: &str) -> String {
681    format!(
682        r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Action":["s3:PutObject","s3:GetObject","s3:DeleteObject"],"Resource":"arn:aws:s3:::{bucket}/*"}},{{"Effect":"Allow","Action":["s3:ListBucket"],"Resource":"arn:aws:s3:::{bucket}"}},{{"Effect":"Allow","Action":"dynamodb:PutItem","Resource":"arn:aws:dynamodb:{region}:{account}:table/{table}"}}]}}"#
683    )
684}
685
686/// Extract `(AccessKeyId, SecretAccessKey)` from `iam create-access-key` JSON.
687pub fn parse_access_key(json_bytes: &[u8]) -> Result<(String, String)> {
688    let json: serde_json::Value =
689        serde_json::from_slice(json_bytes).context("parsing create-access-key output")?;
690    let key = json
691        .get("AccessKey")
692        .ok_or_else(|| anyhow!("create-access-key output missing AccessKey"))?;
693    let id = key
694        .get("AccessKeyId")
695        .and_then(|v| v.as_str())
696        .ok_or_else(|| anyhow!("create-access-key output missing AccessKeyId"))?;
697    let secret = key
698        .get("SecretAccessKey")
699        .and_then(|v| v.as_str())
700        .ok_or_else(|| anyhow!("create-access-key output missing SecretAccessKey"))?;
701    Ok((id.to_string(), secret.to_string()))
702}
703
704/// Run `aws [--profile P] <args>`, returning the raw output.
705fn aws(profile: Option<&str>, args: &[&str]) -> Result<std::process::Output> {
706    let mut cmd = Command::new("aws");
707    if let Some(p) = profile {
708        cmd.args(["--profile", p]);
709    }
710    cmd.args(args)
711        .output()
712        .map_err(|e| anyhow!("running aws {}: {e}", args.join(" ")))
713}
714
715/// Run an `aws` call that must succeed, tolerating stderr substrings in
716/// `tolerate` (idempotent re-runs — e.g. the bucket already exists).
717fn aws_ok(profile: Option<&str>, args: &[&str], tolerate: &[&str]) -> Result<()> {
718    let out = aws(profile, args)?;
719    if out.status.success() {
720        return Ok(());
721    }
722    let stderr = String::from_utf8_lossy(&out.stderr);
723    if tolerate.iter().any(|t| stderr.contains(t)) {
724        return Ok(());
725    }
726    bail!("aws {} failed: {}", args.join(" "), stderr.trim())
727}
728
729#[cfg(test)]
730mod tests {
731    use super::*;
732
733    #[test]
734    fn lifecycle_expires_after_the_given_days() {
735        let lc = lifecycle_config(7);
736        assert!(lc.contains("\"Days\":7"));
737        assert!(lc.contains("\"Status\":\"Enabled\""));
738        assert!(lc.contains("\"Expiration\""));
739    }
740
741    #[test]
742    fn share_policy_is_scoped_to_the_one_bucket() {
743        let p = share_policy("dove-shares-123");
744        assert!(p.contains("s3:PutObject"));
745        assert!(p.contains("s3:GetObject"));
746        assert!(p.contains("s3:DeleteObject"));
747        assert!(p.contains("s3:ListBucket"));
748        assert!(p.contains("arn:aws:s3:::dove-shares-123/*"));
749        assert!(p.contains("arn:aws:s3:::dove-shares-123\""));
750        // No account-wide grant.
751        assert!(!p.contains("\"Resource\":\"*\""));
752    }
753
754    #[test]
755    fn share_policy_full_adds_dynamodb_putitem_scoped_to_the_table() {
756        let p = share_policy_full("dove-shares-123", "us-east-1", "123", "dove-shares-123");
757        assert!(p.contains("s3:PutObject")); // still grants everything share_policy does
758        assert!(p.contains("s3:ListBucket"));
759        assert!(p.contains("dynamodb:PutItem")); // so `dove share` writes the policy row itself
760        assert!(p.contains("arn:aws:dynamodb:us-east-1:123:table/dove-shares-123"));
761        // Still least-privilege: PutItem only, on the one table, no account-wide grant.
762        assert!(!p.contains("dynamodb:*"));
763        assert!(!p.contains("\"Resource\":\"*\""));
764    }
765
766    #[test]
767    fn gate_role_policy_scopes_to_the_one_table_and_bucket() {
768        let p = gate_role_policy("123", "us-east-1", "dove-shares-123", "dove-shares-123");
769        assert!(p.contains("dynamodb:GetItem")); // /meta + /dl read the item
770        assert!(p.contains("dynamodb:UpdateItem"));
771        assert!(p.contains("arn:aws:dynamodb:us-east-1:123:table/dove-shares-123"));
772        assert!(p.contains("s3:GetObject"));
773        assert!(p.contains("s3:PutObject")); // gate presigns uploads for a requested file
774        assert!(p.contains("arn:aws:s3:::dove-shares-123/*"));
775        assert!(p.contains("logs:PutLogEvents"));
776        assert!(p.contains("ssm:GetParameter")); // reads the gate secret from SSM
777        assert!(p.contains("parameter/dove/dove-shares-123/gate-secret"));
778        assert!(p.contains("kms:Decrypt"));
779    }
780
781    #[test]
782    fn parse_access_key_extracts_id_and_secret() {
783        let json =
784            br#"{"AccessKey":{"AccessKeyId":"AKIA1","SecretAccessKey":"shh","Status":"Active"}}"#;
785        let (id, secret) = parse_access_key(json).unwrap();
786        assert_eq!(id, "AKIA1");
787        assert_eq!(secret, "shh");
788    }
789
790    #[test]
791    fn public_access_block_turns_everything_on() {
792        for k in [
793            "BlockPublicAcls=true",
794            "IgnorePublicAcls=true",
795            "BlockPublicPolicy=true",
796            "RestrictPublicBuckets=true",
797        ] {
798            assert!(PUBLIC_ACCESS_BLOCK.contains(k), "missing {k}");
799        }
800    }
801
802    #[test]
803    fn bucket_cors_allows_get_and_post() {
804        assert!(BUCKET_CORS.contains("\"GET\""));
805        assert!(BUCKET_CORS.contains("\"POST\""));
806        assert!(BUCKET_CORS.contains("\"AllowedOrigins\":[\"*\"]"));
807    }
808
809    #[test]
810    fn derive_bucket_uses_override_or_falls_back_to_account() {
811        assert_eq!(
812            derive_bucket("123456789012", None),
813            "dove-shares-123456789012"
814        );
815        assert_eq!(
816            derive_bucket("123456789012", Some("my-bucket")),
817            "my-bucket"
818        );
819    }
820}