//! Stand up the self-hosted backend from your own AWS account, using the `aws`
//! CLI so it rides your existing credentials/SSO. The simple tier creates a
//! private bucket (all public access blocked) with a lifecycle rule that
//! auto-deletes objects after a ceiling of days, and mints a **least-privilege
//! IAM user** scoped to just this bucket, whose key `share` signs with — never
//! your full account credentials, and with a long-term key so presigned links
//! get their full requested lifetime. The full tier additionally provisions the
//! gate: DynamoDB (share policies) + a Lambda (role, function) + API Gateway +
//! CloudFront + the cost circuit-breaker + the SSM-held MAC secret.
//!
//! Moved from the `dove` CLI's `src/provision.rs` — the AWS orchestration
//! (commands, order, idempotency tolerations, retries) is unchanged. What
//! changed crossing the extraction boundary:
//! - Terminal output (`ui::step`/`ui::field`) became `Progress` calls — this
//! crate does no terminal I/O of its own.
//! - The interactive helpers (`choose_profile`, `confirm`) stayed in the CLI;
//! these functions take an already-resolved `profile` and assume the caller
//! already confirmed.
//! - Instead of loading/saving the config registry, these functions take the
//! prior config (if any) as a parameter and *return* the new config — the
//! caller (the CLI) owns persisting it.
pub mod apigw;
pub mod breaker;
pub mod cloudfront;
pub mod domain;
pub mod gate;
use crate::config::SelfHostedConfig;
use crate::progress::Progress;
use anyhow::{anyhow, bail, Context, Result};
use std::process::Command;
pub struct ProvisionArgs {
/// Override the derived bucket name (default `dove-shares-<account-id>`).
pub bucket: Option<String>,
pub region: String,
pub expire_days: u32,
}
/// Provision the simple tier: a private, auto-expiring bucket + a scoped IAM
/// user/key. `profile` is already resolved (the CLI's interactive
/// `choose_profile` ran before this, if at all) and the operator already
/// confirmed.
pub fn provision_simple(
args: &ProvisionArgs,
profile: Option<String>,
progress: &dyn Progress,
) -> Result<SelfHostedConfig> {
let (_account, bucket) = base_provision(args, profile.as_deref(), progress)?;
Ok(SelfHostedConfig {
bucket,
region: args.region.clone(),
profile,
endpoint: None,
table: None,
gate_url: None,
distribution_id: None,
})
}
/// Provision the full tier: the simple tier's bucket, plus the gate (DynamoDB +
/// Lambda + API Gateway + CloudFront + cost breaker). Idempotent: re-running
/// reuses existing resources (the IAM access key, the API by name, the
/// CloudFront distribution from `existing`).
///
/// `existing` is the currently active config, if any — read by the caller
/// before calling this. It's what lets a re-provision keep a custom gate_url
/// (set by `domain add`) instead of reverting to the bare `*.cloudfront.net`
/// domain, and reuse the existing CloudFront distribution instead of minting a
/// second one.
pub fn provision_full(
args: &ProvisionArgs,
profile: Option<String>,
existing: Option<SelfHostedConfig>,
progress: &dyn Progress,
) -> Result<SelfHostedConfig> {
let (account, bucket) = base_provision(args, profile.as_deref(), progress)?;
let prior_gate_url = existing.as_ref().and_then(|c| c.gate_url.clone());
let existing_dist = existing.and_then(|c| c.distribution_id);
let infra = build_full_infra(
profile.as_deref(),
&account,
&bucket,
&args.region,
existing_dist.as_deref(),
progress,
)?;
// Upgrade the scoped signing user so `dove share` can register a share's
// policy row in DynamoDB with its own key — no operator credentials at share
// time. Provisioning is the only step that still needs the operator profile.
progress.step("scoped key · gate write");
let scoped = share_policy_full(&bucket, &args.region, &account, &infra.table);
aws_ok(
profile.as_deref(),
&[
"iam",
"put-user-policy",
"--user-name",
&bucket,
"--policy-name",
"dove-share",
"--policy-document",
&scoped,
],
&[],
)?;
progress.done("scoped key · gate write");
// If a custom domain was already added (`dove domain add`), keep it — don't
// revert to the *.cloudfront.net URL on a re-provision.
let gate_url = match prior_gate_url {
Some(existing) if !existing.contains(".cloudfront.net") => existing,
_ => infra.gate_url,
};
Ok(SelfHostedConfig {
bucket,
region: args.region.clone(),
profile,
endpoint: None,
table: Some(infra.table),
gate_url: Some(gate_url),
distribution_id: infra.distribution_id,
})
}
/// The steps common to both tiers: create the bucket, block public access, set
/// the lifecycle rule, and mint (or reuse) the scoped IAM user + key. Returns
/// the resolved `(account, bucket)`.
fn base_provision(
args: &ProvisionArgs,
profile: Option<&str>,
progress: &dyn Progress,
) -> Result<(String, String)> {
let (account, _arn) = caller_identity(profile).with_context(|| {
format!(
"resolving the AWS identity for {} — is it logged in (e.g. `aws sso login`)?",
profile.unwrap_or("the default profile")
)
})?;
let bucket = derive_bucket(&account, args.bucket.as_deref());
// 1. Create the bucket. us-east-1 must NOT get a LocationConstraint.
let mut create = vec!["s3api", "create-bucket", "--bucket", bucket.as_str()];
let lc = format!("LocationConstraint={}", args.region);
if args.region != "us-east-1" {
create.push("--region");
create.push(&args.region);
create.push("--create-bucket-configuration");
create.push(&lc);
}
progress.step("creating bucket");
let created = aws_ok(profile, &create, &["BucketAlreadyOwnedByYou"]);
if created.is_ok() {
progress.done("creating bucket");
}
created?;
// 2. Block ALL public access — shares are reached by presigned URL only.
progress.step("blocking public access");
let blocked = aws_ok(
profile,
&[
"s3api",
"put-public-access-block",
"--bucket",
&bucket,
"--public-access-block-configuration",
PUBLIC_ACCESS_BLOCK,
],
&[],
);
if blocked.is_ok() {
progress.done("blocking public access");
}
blocked?;
// 3. Lifecycle: auto-delete objects after the ceiling of days.
let lifecycle = lifecycle_config(args.expire_days);
let lifecycle_label = format!("lifecycle · {} days", args.expire_days);
progress.step(&lifecycle_label);
let lifecycled = aws_ok(
profile,
&[
"s3api",
"put-bucket-lifecycle-configuration",
"--bucket",
&bucket,
"--lifecycle-configuration",
&lifecycle,
],
&[],
);
if lifecycled.is_ok() {
progress.done(&lifecycle_label);
}
lifecycled?;
// 4. A least-privilege IAM user dove signs share links with — so links
// aren't signed with your full account creds, and (crucially) their
// expiry isn't capped by an SSO session's lifetime. Same name as the
// bucket, different namespace.
let iam_user = bucket.clone();
progress.step("scoped IAM user");
let user_created = aws_ok(
profile,
&["iam", "create-user", "--user-name", &iam_user],
&["EntityAlreadyExists"],
);
if user_created.is_ok() {
progress.done("scoped IAM user");
}
user_created?;
progress.step("least-privilege policy");
let policy = share_policy(&bucket);
let policy_put = aws_ok(
profile,
&[
"iam",
"put-user-policy",
"--user-name",
&iam_user,
"--policy-name",
"dove-share",
"--policy-document",
&policy,
],
&[],
);
if policy_put.is_ok() {
progress.done("least-privilege policy");
}
policy_put?;
// Mint a key only if we don't already have one — a re-provision reuses it
// (an IAM user can hold at most two keys; don't orphan the old one).
if crate::secrets::Secrets::exists() {
progress.step("access key (reusing)");
progress.done("access key (reusing)");
} else {
progress.step("minting access key");
let minted = (|| -> Result<()> {
let out = aws(
profile,
&[
"iam",
"create-access-key",
"--user-name",
&iam_user,
"--output",
"json",
],
)?;
if !out.status.success() {
bail!(
"creating access key: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
let (id, secret) = parse_access_key(&out.stdout)?;
crate::secrets::Secrets {
access_key_id: id,
secret_access_key: secret,
gate_secret: None,
}
.save()
})();
if minted.is_ok() {
progress.done("minting access key");
}
minted?;
}
Ok((account, bucket))
}
/// The full tier's extra infrastructure, standing on the simple-tier bucket.
struct FullInfra {
table: String,
gate_url: String,
distribution_id: Option<String>,
}
/// Provision the gate: DynamoDB (share policies) + the Lambda (role, function) +
/// API Gateway + CloudFront. Idempotent: re-running reuses existing resources
/// (API by name, distribution from `existing_distribution`).
fn build_full_infra(
profile: Option<&str>,
account: &str,
bucket: &str,
region: &str,
existing_distribution: Option<&str>,
progress: &dyn Progress,
) -> Result<FullInfra> {
let table = bucket.to_string(); // same name as the bucket, different namespace
let name = format!("dove-gate-{account}"); // role + lambda share this name
// Bucket CORS so the browser decryptor can read the ciphertext from the
// presigned URL cross-origin (the gate 302s to S3; the fetch is cross-site).
progress.step("bucket CORS");
let cors = aws_ok(
profile,
&[
"s3api",
"put-bucket-cors",
"--bucket",
bucket,
"--cors-configuration",
BUCKET_CORS,
],
&[],
);
if cors.is_ok() {
progress.done("bucket CORS");
}
cors?;
// DynamoDB table with TTL on expires_at (auto-cleanup of dead policies).
progress.step("dynamodb table");
let dynamo = (|| -> Result<()> {
aws_ok(
profile,
&[
"dynamodb",
"create-table",
"--table-name",
&table,
"--attribute-definitions",
"AttributeName=id,AttributeType=S",
"--key-schema",
"AttributeName=id,KeyType=HASH",
"--billing-mode",
"PAY_PER_REQUEST",
],
&["ResourceInUseException"],
)?;
aws_ok(
profile,
&["dynamodb", "wait", "table-exists", "--table-name", &table],
&[],
)?;
aws_ok(
profile,
&[
"dynamodb",
"update-time-to-live",
"--table-name",
&table,
"--time-to-live-specification",
"Enabled=true,AttributeName=expires_at",
],
&["TimeToLive is already enabled"],
)
})();
if dynamo.is_ok() {
progress.done("dynamodb table");
}
dynamo?;
// The gate's execution role.
let role_arn = format!("arn:aws:iam::{account}:role/{name}");
progress.step("gate IAM role");
let role = (|| -> Result<()> {
aws_ok(
profile,
&[
"iam",
"create-role",
"--role-name",
&name,
"--assume-role-policy-document",
LAMBDA_TRUST,
],
&["EntityAlreadyExists"],
)?;
let policy = gate_role_policy(account, region, &table, bucket);
aws_ok(
profile,
&[
"iam",
"put-role-policy",
"--role-name",
&name,
"--policy-name",
"dove-gate",
"--policy-document",
&policy,
],
&[],
)
})();
if role.is_ok() {
progress.done("gate IAM role");
}
role?;
// The gate secret — the HMAC key that mints/verifies unforgeable share ids.
// Stable across re-provision (generated once, kept in secrets.toml). Stored in
// SSM as a SecureString (encrypted, not readable from the function config);
// the Lambda reads it at cold start. The env carries only the parameter name.
let gate_secret = ensure_gate_secret()?;
let secret_param = format!("/dove/{bucket}/gate-secret");
progress.step("gate secret (SSM)");
let secret_put = aws_ok(
profile,
&[
"ssm",
"put-parameter",
"--name",
&secret_param,
"--value",
&gate_secret,
"--type",
"SecureString",
"--overwrite",
],
&[],
);
if secret_put.is_ok() {
progress.done("gate secret (SSM)");
}
secret_put?;
// The gate Lambda. A freshly-created role isn't assumable for a few seconds,
// so retry create-function on that specific error.
let zip = temp_path("zip");
gate::write_deployment_zip(&zip)?;
let zip_arg = format!("fileb://{}", zip.display());
let env =
format!("Variables={{BUCKET={bucket},TABLE={table},GATE_SECRET_PARAM={secret_param}}}");
progress.step("gate Lambda");
let lambda_result = (|| -> Result<()> {
aws_retry(
profile,
&[
"lambda",
"create-function",
"--function-name",
&name,
"--runtime",
gate::RUNTIME,
"--handler",
gate::HANDLER,
"--role",
&role_arn,
"--zip-file",
&zip_arg,
"--environment",
&env,
"--timeout",
"30",
],
&["ResourceConflictException"],
"cannot be assumed",
6,
)?;
// A freshly created function is 'Pending'/'Creating' and rejects code
// updates until it's Active. Wait for that (best-effort — the retry below
// is the real guard), then push the latest gate code + page. On a re-
// provision this updates an existing function; on a fresh create it's a
// no-op redeploy of the same code. The "cannot be performed at this time"
// message covers every not-ready state (Creating / Pending / InProgress).
let _ = aws(
profile,
&[
"lambda",
"wait",
"function-active-v2",
"--function-name",
&name,
],
);
aws_retry(
profile,
&[
"lambda",
"update-function-code",
"--function-name",
&name,
"--zip-file",
&zip_arg,
],
&[],
"cannot be performed at this time",
10,
)?;
// Let the code update settle before the config update.
let _ = aws(
profile,
&[
"lambda",
"wait",
"function-updated-v2",
"--function-name",
&name,
],
);
// Set the env (BUCKET/TABLE/GATE_SECRET_PARAM) — a fresh create already has it,
// but a re-provision of an existing function needs it applied here.
aws_retry(
profile,
&[
"lambda",
"update-function-configuration",
"--function-name",
&name,
"--environment",
&env,
],
&[],
"cannot be performed at this time",
10,
)?;
let _ = aws(
profile,
&[
"lambda",
"wait",
"function-updated-v2",
"--function-name",
&name,
],
);
Ok(())
})();
let _ = std::fs::remove_file(&zip);
if lambda_result.is_ok() {
progress.done("gate Lambda");
}
lambda_result?;
// Public front: API Gateway → Lambda, behind CloudFront. (Public Function URLs
// don't work in every account; the API Gateway hop uses lambda:InvokeFunction,
// which is universally allowed.)
let function_arn = format!("arn:aws:lambda:{region}:{account}:function:{name}");
let api = apigw::provision_api(profile, region, account, &name, &function_arn, progress)?;
// Reuse an existing distribution on re-provision (from the caller-supplied
// prior config).
let front =
cloudfront::front_gate(profile, account, &api.host, existing_distribution, progress)?;
// Cost circuit-breaker: a flood auto-disables the gate before it can run up a
// bill. A public endpoint on the operator's account should never exist without
// this backstop.
breaker::provision_breaker(profile, region, account, &name, progress)?;
Ok(FullInfra {
table,
gate_url: format!("https://{}", front.domain),
distribution_id: Some(front.distribution_id),
})
}
/// Load the gate secret, generating and persisting one the first time. Kept in
/// secrets.toml so `share` can mint ids and so it's stable across re-provision
/// (regenerating it would invalidate every outstanding link).
fn ensure_gate_secret() -> Result<String> {
let mut s = crate::secrets::Secrets::load()?;
if let Some(g) = &s.gate_secret {
return Ok(g.clone());
}
let g = crate::crypto::gen_gate_secret();
s.gate_secret = Some(g.clone());
s.save()?;
Ok(g)
}
/// The bucket name: the override if given, else derived from the account id.
pub fn derive_bucket(account: &str, override_bucket: Option<&str>) -> String {
override_bucket
.map(str::to_string)
.unwrap_or_else(|| format!("dove-shares-{account}"))
}
/// Whether the `aws` CLI is on PATH — provisioning shells out to it so it rides
/// the operator's existing credentials/SSO config.
pub fn have_aws() -> bool {
Command::new("aws")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// The caller's AWS account id and identity ARN, via `sts get-caller-identity`.
/// Used both to derive the default bucket name and (by the CLI) to show the
/// operator what they're about to provision into before they confirm.
pub fn caller_identity(profile: Option<&str>) -> Result<(String, String)> {
let out = aws(profile, &["sts", "get-caller-identity", "--output", "json"])?;
if !out.status.success() {
bail!("{}", String::from_utf8_lossy(&out.stderr).trim());
}
let v: serde_json::Value = serde_json::from_slice(&out.stdout)?;
let account = v["Account"].as_str().unwrap_or("?").to_string();
let arn = v["Arn"].as_str().unwrap_or("?").to_string();
Ok((account, arn))
}
/// Trust policy letting Lambda assume the gate's role.
const LAMBDA_TRUST: &str = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}"#;
/// Bucket CORS: let any origin GET (the browser decryptor fetching ciphertext
/// from the presigned URL) and POST (the browser uploader submitting a
/// presigned POST policy for a file request). Both are still gated by the
/// presign + the gate — this only lifts the cross-origin block the browser
/// itself would otherwise enforce.
const BUCKET_CORS: &str = r#"{"CORSRules":[{"AllowedOrigins":["*"],"AllowedMethods":["GET","POST"],"AllowedHeaders":["*"],"MaxAgeSeconds":3000}]}"#;
/// The gate role's inline policy: log, decrement the one table, presign from the
/// one bucket. Nothing else.
pub fn gate_role_policy(account: &str, region: &str, table: &str, bucket: &str) -> String {
format!(
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":"*"}}]}}"#
)
}
/// A unique temp path with the given extension.
fn temp_path(ext: &str) -> std::path::PathBuf {
let mut b = [0u8; 8];
getrandom::getrandom(&mut b).expect("OS RNG");
let hex: String = b.iter().map(|x| format!("{x:02x}")).collect();
std::env::temp_dir().join(format!("dove-{hex}.{ext}"))
}
/// Like `aws_ok`, but retry on a specific stderr substring (e.g. IAM
/// propagation delays), sleeping 3s between attempts.
fn aws_retry(
profile: Option<&str>,
args: &[&str],
tolerate: &[&str],
retry_on: &str,
attempts: u32,
) -> Result<()> {
for i in 0..attempts {
let out = aws(profile, args)?;
if out.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&out.stderr);
if tolerate.iter().any(|t| stderr.contains(t)) {
return Ok(());
}
if stderr.contains(retry_on) && i + 1 < attempts {
std::thread::sleep(std::time::Duration::from_secs(3));
continue;
}
bail!("aws {} failed: {}", args.join(" "), stderr.trim());
}
Ok(())
}
/// All four public-access-block switches on.
const PUBLIC_ACCESS_BLOCK: &str = "BlockPublicAcls=true,IgnorePublicAcls=true,\
BlockPublicPolicy=true,RestrictPublicBuckets=true";
/// The lifecycle configuration JSON: expire every object `days` after creation.
pub fn lifecycle_config(days: u32) -> String {
format!(
r#"{{"Rules":[{{"ID":"dove-expire","Status":"Enabled","Filter":{{}},"Expiration":{{"Days":{days}}}}}]}}"#
)
}
/// The least-privilege IAM policy dove's signing user gets: read/write/delete
/// objects and list — scoped to this one bucket, nothing else in the account.
pub fn share_policy(bucket: &str) -> String {
format!(
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}"}}]}}"#
)
}
/// The full-tier signing policy: everything `share_policy` grants, plus
/// `dynamodb:PutItem` on the gate table — so `dove share` registers a share's
/// access policy with its own scoped key, never the operator's credentials.
/// (Provisioning still uses the operator profile; that's the rare, gated part.)
pub fn share_policy_full(bucket: &str, region: &str, account: &str, table: &str) -> String {
format!(
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}"}}]}}"#
)
}
/// Extract `(AccessKeyId, SecretAccessKey)` from `iam create-access-key` JSON.
pub fn parse_access_key(json_bytes: &[u8]) -> Result<(String, String)> {
let json: serde_json::Value =
serde_json::from_slice(json_bytes).context("parsing create-access-key output")?;
let key = json
.get("AccessKey")
.ok_or_else(|| anyhow!("create-access-key output missing AccessKey"))?;
let id = key
.get("AccessKeyId")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("create-access-key output missing AccessKeyId"))?;
let secret = key
.get("SecretAccessKey")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("create-access-key output missing SecretAccessKey"))?;
Ok((id.to_string(), secret.to_string()))
}
/// Run `aws [--profile P] <args>`, returning the raw output.
fn aws(profile: Option<&str>, args: &[&str]) -> Result<std::process::Output> {
let mut cmd = Command::new("aws");
if let Some(p) = profile {
cmd.args(["--profile", p]);
}
cmd.args(args)
.output()
.map_err(|e| anyhow!("running aws {}: {e}", args.join(" ")))
}
/// Run an `aws` call that must succeed, tolerating stderr substrings in
/// `tolerate` (idempotent re-runs — e.g. the bucket already exists).
fn aws_ok(profile: Option<&str>, args: &[&str], tolerate: &[&str]) -> Result<()> {
let out = aws(profile, args)?;
if out.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&out.stderr);
if tolerate.iter().any(|t| stderr.contains(t)) {
return Ok(());
}
bail!("aws {} failed: {}", args.join(" "), stderr.trim())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lifecycle_expires_after_the_given_days() {
let lc = lifecycle_config(7);
assert!(lc.contains("\"Days\":7"));
assert!(lc.contains("\"Status\":\"Enabled\""));
assert!(lc.contains("\"Expiration\""));
}
#[test]
fn share_policy_is_scoped_to_the_one_bucket() {
let p = share_policy("dove-shares-123");
assert!(p.contains("s3:PutObject"));
assert!(p.contains("s3:GetObject"));
assert!(p.contains("s3:DeleteObject"));
assert!(p.contains("s3:ListBucket"));
assert!(p.contains("arn:aws:s3:::dove-shares-123/*"));
assert!(p.contains("arn:aws:s3:::dove-shares-123\""));
// No account-wide grant.
assert!(!p.contains("\"Resource\":\"*\""));
}
#[test]
fn share_policy_full_adds_dynamodb_putitem_scoped_to_the_table() {
let p = share_policy_full("dove-shares-123", "us-east-1", "123", "dove-shares-123");
assert!(p.contains("s3:PutObject")); // still grants everything share_policy does
assert!(p.contains("s3:ListBucket"));
assert!(p.contains("dynamodb:PutItem")); // so `dove share` writes the policy row itself
assert!(p.contains("arn:aws:dynamodb:us-east-1:123:table/dove-shares-123"));
// Still least-privilege: PutItem only, on the one table, no account-wide grant.
assert!(!p.contains("dynamodb:*"));
assert!(!p.contains("\"Resource\":\"*\""));
}
#[test]
fn gate_role_policy_scopes_to_the_one_table_and_bucket() {
let p = gate_role_policy("123", "us-east-1", "dove-shares-123", "dove-shares-123");
assert!(p.contains("dynamodb:GetItem")); // /meta + /dl read the item
assert!(p.contains("dynamodb:UpdateItem"));
assert!(p.contains("arn:aws:dynamodb:us-east-1:123:table/dove-shares-123"));
assert!(p.contains("s3:GetObject"));
assert!(p.contains("s3:PutObject")); // gate presigns uploads for a requested file
assert!(p.contains("arn:aws:s3:::dove-shares-123/*"));
assert!(p.contains("logs:PutLogEvents"));
assert!(p.contains("ssm:GetParameter")); // reads the gate secret from SSM
assert!(p.contains("parameter/dove/dove-shares-123/gate-secret"));
assert!(p.contains("kms:Decrypt"));
}
#[test]
fn parse_access_key_extracts_id_and_secret() {
let json =
br#"{"AccessKey":{"AccessKeyId":"AKIA1","SecretAccessKey":"shh","Status":"Active"}}"#;
let (id, secret) = parse_access_key(json).unwrap();
assert_eq!(id, "AKIA1");
assert_eq!(secret, "shh");
}
#[test]
fn public_access_block_turns_everything_on() {
for k in [
"BlockPublicAcls=true",
"IgnorePublicAcls=true",
"BlockPublicPolicy=true",
"RestrictPublicBuckets=true",
] {
assert!(PUBLIC_ACCESS_BLOCK.contains(k), "missing {k}");
}
}
#[test]
fn bucket_cors_allows_get_and_post() {
assert!(BUCKET_CORS.contains("\"GET\""));
assert!(BUCKET_CORS.contains("\"POST\""));
assert!(BUCKET_CORS.contains("\"AllowedOrigins\":[\"*\"]"));
}
#[test]
fn derive_bucket_uses_override_or_falls_back_to_account() {
assert_eq!(
derive_bucket("123456789012", None),
"dove-shares-123456789012"
);
assert_eq!(
derive_bucket("123456789012", Some("my-bucket")),
"my-bucket"
);
}
}