Skip to main content

ironflow_cli/commands/
secret.rs

1//! Secret subcommands: list, set, update, delete, rotate, key-status.
2//!
3//! No command in this module ever renders a secret value: the API's
4//! `SecretResponse` does not carry one, and values are only ever sent.
5
6use anyhow::{Context, Result, bail};
7use clap::{Args, Subcommand, value_parser};
8use ironflow_sdk::IronflowClient;
9use ironflow_sdk::client::ApiResponse;
10use ironflow_sdk::types::{
11    KeyVersionsResponse, RotateSecretsRequest, SetSecretRequest, UpdateSecretRequest,
12};
13use serde::Serialize;
14use std::slice;
15use uuid::Uuid;
16
17use crate::confirm::{confirm, resolve_secret_value};
18use crate::output;
19
20/// Arguments for the `secret` command group.
21#[derive(Debug, Args)]
22pub struct SecretArgs {
23    /// Secret subcommand.
24    #[command(subcommand)]
25    pub command: SecretCommands,
26}
27
28/// Available secret subcommands.
29#[derive(Debug, Subcommand)]
30pub enum SecretCommands {
31    /// List secret keys. Values are never returned by the API.
32    List,
33    /// Create a secret, or replace the value of an existing one.
34    Set {
35        /// Secret key (namespaced, e.g. `workflows/inbox/gmail_token`).
36        key: String,
37        /// Secret value. Read from stdin when omitted, which keeps it out of
38        /// the shell history and out of `ps` output.
39        value: Option<String>,
40    },
41    /// Replace the value of an existing secret. Fails if the key is unknown.
42    Update {
43        /// Secret key.
44        key: String,
45        /// New secret value. Read from stdin when omitted.
46        value: Option<String>,
47    },
48    /// Delete a secret.
49    Delete {
50        /// Secret key.
51        key: String,
52        /// Skip the interactive confirmation.
53        #[arg(long)]
54        yes: bool,
55    },
56    /// Re-encrypt every stored secret with a key version.
57    ///
58    /// Runs batch by batch until the whole stock is on the target version.
59    /// Safe to interrupt and rerun: secrets already rotated are skipped.
60    Rotate(RotateArgs),
61    /// Show which encryption key versions are configured and in use.
62    KeyStatus,
63}
64
65/// Arguments for `secret rotate`.
66#[derive(Debug, Args)]
67pub struct RotateArgs {
68    /// Target key version. Defaults to the server's active version.
69    #[arg(long, value_parser = value_parser!(i32).range(1..))]
70    pub to_version: Option<i32>,
71
72    /// Secrets to re-encrypt per request.
73    #[arg(long, default_value_t = 100, value_parser = value_parser!(i32).range(1..=1000))]
74    pub batch_size: i32,
75}
76
77/// Summary of a completed rotation, for `--json` output.
78#[derive(Debug, Serialize)]
79struct RotationSummary {
80    /// Key version the stock was rotated to.
81    to_version: i32,
82    /// Total secrets re-encrypted.
83    rotated: u64,
84    /// Total secrets skipped because they could not be decrypted.
85    failed: u64,
86    /// Number of requests issued.
87    batches: u64,
88}
89
90/// Execute a secret subcommand.
91///
92/// # Errors
93///
94/// Returns an error on API failure, on an empty value, when a destructive
95/// command is not confirmed, or when a rotation left secrets behind because
96/// they could not be decrypted.
97pub async fn execute(client: &IronflowClient, args: &SecretArgs, json_mode: bool) -> Result<()> {
98    match &args.command {
99        SecretCommands::List => {
100            let response = client.list_secrets().await?;
101            output::print_output(json_mode, &response, || {
102                output::secrets_table(&response.data)
103            })?;
104        }
105        SecretCommands::Set { key, value } => {
106            let value = resolve_secret_value(value.as_deref(), "secret value")?;
107            let request: SetSecretRequest = SetSecretRequest::builder()
108                .key(key.clone())
109                .value(value)
110                .try_into()
111                .context("failed to build SetSecretRequest")?;
112
113            let response = client.create_secret(&request).await?;
114            output::print_output(json_mode, &response, || {
115                output::secrets_table(slice::from_ref(&response.data))
116            })?;
117        }
118        SecretCommands::Update { key, value } => {
119            let value = resolve_secret_value(value.as_deref(), "secret value")?;
120            let request: UpdateSecretRequest = UpdateSecretRequest::builder()
121                .value(value)
122                .try_into()
123                .context("failed to build UpdateSecretRequest")?;
124
125            let response = client.update_secret(key, &request).await?;
126            output::print_output(json_mode, &response, || {
127                output::secrets_table(slice::from_ref(&response.data))
128            })?;
129        }
130        SecretCommands::Delete { key, yes } => {
131            confirm(&format!("Delete secret '{key}'?"), *yes)?;
132            client.delete_secret(key).await?;
133            output::report_deletion(json_mode, "secret", key)?;
134        }
135        SecretCommands::Rotate(rotate_args) => rotate(client, rotate_args, json_mode).await?,
136        SecretCommands::KeyStatus => key_status(client, json_mode).await?,
137    }
138    Ok(())
139}
140
141/// Drive a rotation to completion, one batch per request.
142async fn rotate(client: &IronflowClient, args: &RotateArgs, json_mode: bool) -> Result<()> {
143    let mut cursor: Option<Uuid> = None;
144    let mut to_version = args.to_version;
145    let mut rotated = 0u64;
146    let mut failed = 0u64;
147    let mut batches = 0u64;
148
149    loop {
150        let request = RotateSecretsRequest {
151            to_version,
152            batch_size: Some(args.batch_size),
153            after_id: cursor,
154        };
155
156        let response = client.rotate_secrets(&request).await?;
157        let batch = response.data;
158        batches += 1;
159        rotated += batch.rotated as u64;
160        failed += batch.failed as u64;
161
162        // The server resolves an omitted version to its active one; pin it
163        // for the remaining batches so a mid-rotation config change cannot
164        // send the tail of the stock to a different version.
165        to_version = Some(batch.to_version);
166
167        if !json_mode {
168            let done = rotated + failed;
169            println!(
170                "rotating to version {}: {done} done, {} remaining",
171                batch.to_version, batch.remaining
172            );
173        }
174
175        // A batch with no cursor means nothing was left to read.
176        let Some(last_id) = batch.last_id else { break };
177        if batch.remaining == 0 {
178            break;
179        }
180        cursor = Some(last_id);
181    }
182
183    let summary = RotationSummary {
184        to_version: to_version.unwrap_or_default(),
185        rotated,
186        failed,
187        batches,
188    };
189
190    if json_mode {
191        output::print_json(&summary)?;
192    } else {
193        println!(
194            "done: {rotated} secret(s) rotated to version {} in {batches} batch(es)",
195            summary.to_version
196        );
197    }
198
199    if failed > 0 {
200        bail!(
201            "{failed} secret(s) could not be decrypted and were left on their previous key version; check the server logs for the affected keys"
202        );
203    }
204
205    Ok(())
206}
207
208/// Report the key ring status.
209async fn key_status(client: &IronflowClient, json_mode: bool) -> Result<()> {
210    let response = client.secret_key_versions().await?;
211    output::print_output(json_mode, &response, || {
212        output::key_versions_table(&response.data)
213    })?;
214
215    warn_on_missing(&response);
216
217    Ok(())
218}
219
220/// Print a warning when stored secrets reference an unconfigured key version.
221fn warn_on_missing(response: &ApiResponse<KeyVersionsResponse>) {
222    if response.data.missing.is_empty() {
223        return;
224    }
225
226    let missing = response
227        .data
228        .missing
229        .iter()
230        .map(|v| v.to_string())
231        .collect::<Vec<_>>()
232        .join(", ");
233    eprintln!(
234        "warning: key version(s) {missing} are used by stored secrets but not configured; those secrets cannot be read and the server will refuse to restart"
235    );
236}