cufflink-cli 0.20.0

CLI for the Cufflink CRUD microservice platform — deploy, init, and manage services
//! `cufflink admin` — platform-wide administration.

use crate::commands::tenants::admin_request;
use clap::{Subcommand, ValueEnum};
use serde_json::{json, Value};

#[derive(Subcommand)]
pub enum AdminAction {
    /// Stored secrets
    Secrets {
        #[command(subcommand)]
        action: SecretsAction,
    },
}

#[derive(Subcommand)]
pub enum SecretsAction {
    /// Store secrets again: as the platform's storage mode stores them
    /// (encrypt), under the current key-encryption key (rotate_kek) or
    /// under a new data key (rotate_dek). Runs in batches and may be run
    /// again if interrupted.
    Reseal {
        #[arg(long, value_enum)]
        operation: Operation,
        /// One tenant's slug; every tenant when omitted
        #[arg(long)]
        tenant: Option<String>,
    },
}

/// What a reseal does.
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
#[value(rename_all = "snake_case")]
pub enum Operation {
    /// Store every secret as the storage mode does: sealed under the
    /// active data key, with its plaintext under dual (written back where
    /// encrypted storage removed it) and without it under encrypted
    Encrypt,
    /// Wrap every data key under the current key-encryption key
    RotateKek,
    /// Give every tenant a new data key and seal its secrets under it
    RotateDek,
}

impl Operation {
    fn as_str(self) -> &'static str {
        match self {
            Operation::Encrypt => "encrypt",
            Operation::RotateKek => "rotate_kek",
            Operation::RotateDek => "rotate_dek",
        }
    }
}

fn reseal_body(operation: Operation, tenant: Option<&str>) -> Value {
    json!({ "operation": operation.as_str(), "tenant": tenant })
}

pub async fn run(
    action: AdminAction,
    api_url: Option<&str>,
    env: Option<&str>,
) -> eyre::Result<()> {
    match action {
        AdminAction::Secrets {
            action: SecretsAction::Reseal { operation, tenant },
        } => reseal(operation, tenant.as_deref(), api_url, env).await,
    }
}

async fn reseal(
    operation: Operation,
    tenant: Option<&str>,
    api_url: Option<&str>,
    env: Option<&str>,
) -> eyre::Result<()> {
    let report = admin_request(
        "Reseal",
        reqwest::Method::POST,
        "/api/admin/secrets/reseal",
        Some(reseal_body(operation, tenant)),
        api_url,
        env,
    )
    .await?;
    println!(
        "Resealed secrets ({}, {}): {} rows checked, {} rewritten, {} data keys rewrapped, {} tenants rotated",
        operation.as_str(),
        tenant.unwrap_or("every tenant"),
        report["rows_checked"],
        report["rows_rewritten"],
        report["keys_rewrapped"],
        report["tenants_rotated"],
    );
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use clap::Parser;

    #[derive(Parser)]
    struct Admin {
        #[command(subcommand)]
        action: AdminAction,
    }

    fn parsed(args: &[&str]) -> Result<(Operation, Option<String>), clap::Error> {
        Admin::try_parse_from(["admin"].iter().chain(args)).map(|admin| match admin.action {
            AdminAction::Secrets {
                action: SecretsAction::Reseal { operation, tenant },
            } => (operation, tenant),
        })
    }

    #[test]
    fn an_operation_is_one_of_three_and_goes_to_the_platform_by_its_name() {
        for (given, operation) in [
            ("encrypt", Operation::Encrypt),
            ("rotate_kek", Operation::RotateKek),
            ("rotate_dek", Operation::RotateDek),
        ] {
            let (parsed, tenant) =
                parsed(&["secrets", "reseal", "--operation", given]).expect(given);
            assert_eq!((parsed, tenant), (operation, None));
            assert_eq!(
                reseal_body(parsed, None),
                json!({ "operation": given, "tenant": null })
            );
        }
        assert_eq!(
            parsed(&[
                "secrets",
                "reseal",
                "--operation",
                "encrypt",
                "--tenant",
                "acme"
            ])
            .expect("a tenant"),
            (Operation::Encrypt, Some("acme".into()))
        );
    }

    #[test]
    fn another_operation_is_refused_before_anything_is_asked() {
        let refused = parsed(&["secrets", "reseal", "--operation", "rotate"])
            .expect_err("an unknown operation");
        assert_eq!(refused.kind(), clap::error::ErrorKind::InvalidValue);
        assert!(refused.to_string().contains("rotate_kek"), "{refused}");
        assert!(
            parsed(&["secrets", "reseal"]).is_err(),
            "the operation is required"
        );
    }
}