Skip to main content

ironflow_cli/commands/
api_key.rs

1//! API key subcommands: list, create, scopes, delete.
2
3use std::io::Write as _;
4
5use anyhow::{Context, Result};
6use chrono::{DateTime, Utc};
7use clap::{Args, Subcommand};
8use ironflow_sdk::IronflowClient;
9use ironflow_sdk::types::{ApiKeyScope, CreateApiKeyRequest};
10use uuid::Uuid;
11
12use crate::commands::parse_enum;
13use crate::confirm::confirm;
14use crate::output;
15
16/// Arguments for the `api-key` command group.
17#[derive(Debug, Args)]
18pub struct ApiKeyArgs {
19    /// API key subcommand.
20    #[command(subcommand)]
21    pub command: ApiKeyCommands,
22}
23
24/// Available API key subcommands.
25#[derive(Debug, Subcommand)]
26pub enum ApiKeyCommands {
27    /// List API keys. The raw key is never listed, only its prefix.
28    List,
29    /// Create an API key. The raw key is printed once and never again.
30    Create {
31        /// Human-readable name for the key.
32        name: String,
33        /// Scope to grant. Repeat for several scopes. Run `api-key scopes`
34        /// to list the accepted values.
35        #[arg(long = "scope", value_name = "SCOPE", required = true, value_parser = parse_scope)]
36        scopes: Vec<ApiKeyScope>,
37        /// Expiration date (RFC 3339, e.g. `2026-12-31T23:59:59Z`).
38        #[arg(long)]
39        expires_at: Option<DateTime<Utc>>,
40    },
41    /// List the scopes an API key can be granted.
42    Scopes,
43    /// Delete an API key.
44    Delete {
45        /// API key UUID.
46        id: Uuid,
47        /// Skip the interactive confirmation.
48        #[arg(long)]
49        yes: bool,
50    },
51}
52
53/// Every scope the API accepts, in the order the enum declares them.
54const ALL_SCOPES: [ApiKeyScope; 6] = [
55    ApiKeyScope::WorkflowsRead,
56    ApiKeyScope::RunsRead,
57    ApiKeyScope::RunsWrite,
58    ApiKeyScope::RunsManage,
59    ApiKeyScope::StatsRead,
60    ApiKeyScope::Admin,
61];
62
63/// Parse a `--scope` value, listing the accepted values on failure.
64///
65/// # Errors
66///
67/// Returns the list of accepted scopes when `raw` is not one of them.
68fn parse_scope(raw: &str) -> Result<ApiKeyScope, String> {
69    parse_enum(raw, &ALL_SCOPES, "scope")
70}
71
72/// Execute an API key subcommand.
73///
74/// # Errors
75///
76/// Returns an error on API failure or when a destructive command is not
77/// confirmed.
78pub async fn execute(client: &IronflowClient, args: &ApiKeyArgs, json_mode: bool) -> Result<()> {
79    match &args.command {
80        ApiKeyCommands::List => {
81            let response = client.list_api_keys().await?;
82            output::print_output(json_mode, &response, || {
83                output::api_keys_table(&response.data)
84            })?;
85        }
86        ApiKeyCommands::Create {
87            name,
88            scopes,
89            expires_at,
90        } => {
91            let request: CreateApiKeyRequest = CreateApiKeyRequest::builder()
92                .name(name.clone())
93                .scopes(scopes.clone())
94                .expires_at(*expires_at)
95                .try_into()
96                .context("failed to build CreateApiKeyRequest")?;
97
98            let response = client.create_api_key(&request).await?;
99
100            if !json_mode {
101                let mut stderr = std::io::stderr();
102                writeln!(stderr, "This is the only time the key is shown.")?;
103            }
104
105            output::print_output(json_mode, &response, || {
106                output::created_api_key_table(&response.data)
107            })?;
108        }
109        ApiKeyCommands::Scopes => {
110            let response = client.available_scopes().await?;
111            output::print_output(json_mode, &response, || {
112                output::scopes_table(&response.data)
113            })?;
114        }
115        ApiKeyCommands::Delete { id, yes } => {
116            confirm(&format!("Delete API key '{id}'?"), *yes)?;
117            client.delete_api_key(*id).await?;
118            output::report_deletion(json_mode, "api-key", id.to_string())?;
119        }
120    }
121    Ok(())
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn parse_scope_accepts_every_declared_scope() {
130        for scope in ALL_SCOPES {
131            let raw = scope.to_string();
132            assert_eq!(parse_scope(&raw).unwrap(), scope);
133        }
134    }
135
136    #[test]
137    fn parse_scope_rejects_an_unknown_value_and_lists_the_valid_ones() {
138        let err = parse_scope("root").unwrap_err();
139        assert!(err.contains("unknown scope 'root'"), "{err}");
140        assert!(err.contains("runs_read"), "{err}");
141        assert!(err.contains("admin"), "{err}");
142    }
143
144    #[test]
145    fn parse_scope_is_case_sensitive() {
146        assert!(parse_scope("ADMIN").is_err());
147    }
148}