Skip to content

Commit 12b80b4

Browse files
joshrotenbergHuman
andcommitted
feat: add comprehensive Files.com API key management with secure storage
This commit adds secure, persistent storage for Files.com API keys used for support package uploads, addressing the critical need for users who receive static keys from Redis Support. - Made secure-storage feature part of default build - Keys stored in OS keyring (macOS Keychain, Windows Credential Manager, Linux Secret Service) - Eliminates need for plaintext API keys in environment variables - Global files_api_key in config (applies to all profiles) - Per-profile files_api_key override for flexibility - Supports keyring: prefix for secure reference (e.g., files_api_key = "keyring:my-key") 1. REDIS_ENTERPRISE_FILES_API_KEY environment variable (CI/CD) 2. Profile-specific files_api_key in config 3. Global files_api_key in config 4. System keyring (direct CLI storage) 5. REDIS_FILES_API_KEY environment variable (fallback) - redisctl files-key set <key> --use-keyring # Secure (recommended) - redisctl files-key set <key> --global # Config file - redisctl files-key set <key> --profile <name> # Per-profile - redisctl files-key get [--profile <name>] # View current key - redisctl files-key remove [--keyring|--global|--profile <name>] Global key: files_api_key = "keyring:files-api-key" Per-profile: [profiles.prod] files_api_key = "keyring:prod-files-key" - Update Cargo.toml: secure-storage now in default features - Add Config fields: global files_api_key + per-profile override - Enhance upload.rs: full priority chain with config support - Add files_key.rs: comprehensive key management handlers - Add CLI commands: FilesKeyCommands enum with set/get/remove - Update main.rs: routing and Profile initialization - Fix clippy: collapsible if, needless return, test fixtures - All builds pass with default features - cargo fmt and clippy clean - Config backward compatible (files_api_key is optional) Closes #<issue> (if applicable) Co-authored-by: Human <user@example.com>
1 parent 522659f commit 12b80b4

File tree

5 files changed

+369
-1
lines changed

5 files changed

+369
-1
lines changed

crates/redisctl/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ pager = "0.16"
6161

6262
# Conditional dependencies for feature-gated binaries
6363
[features]
64-
default = ["full"]
64+
default = ["full", "secure-storage"]
6565
full = ["cloud", "enterprise", "upload"]
6666
cloud = []
6767
enterprise = []

crates/redisctl/src/cli.rs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ pub enum Commands {
8080
#[command(subcommand, visible_alias = "ent", visible_alias = "en")]
8181
Enterprise(EnterpriseCommands),
8282

83+
/// Files.com API key management (for support package uploads)
84+
#[command(subcommand, visible_alias = "fk")]
85+
FilesKey(FilesKeyCommands),
86+
8387
/// Version information
8488
#[command(visible_alias = "ver", visible_alias = "v")]
8589
Version,
@@ -230,6 +234,55 @@ pub enum ProfileCommands {
230234
},
231235
}
232236

237+
/// Files.com API key management commands
238+
#[derive(Subcommand, Debug)]
239+
pub enum FilesKeyCommands {
240+
/// Store Files.com API key (globally or in config)
241+
#[command(visible_alias = "add")]
242+
Set {
243+
/// The Files.com API key provided by Redis Support
244+
api_key: String,
245+
246+
/// Store in system keyring (most secure - recommended)
247+
#[cfg(feature = "secure-storage")]
248+
#[arg(long)]
249+
use_keyring: bool,
250+
251+
/// Store globally in config file (plaintext - not recommended)
252+
#[arg(long, conflicts_with = "use_keyring")]
253+
global: bool,
254+
255+
/// Store in specific profile's config (plaintext - not recommended)
256+
#[arg(long, conflicts_with_all = ["use_keyring", "global"])]
257+
profile: Option<String>,
258+
},
259+
260+
/// Get the currently configured Files.com API key
261+
#[command(visible_alias = "show")]
262+
Get {
263+
/// Show for specific profile
264+
#[arg(long)]
265+
profile: Option<String>,
266+
},
267+
268+
/// Remove Files.com API key
269+
#[command(visible_alias = "rm", visible_alias = "delete")]
270+
Remove {
271+
/// Remove from keyring
272+
#[cfg(feature = "secure-storage")]
273+
#[arg(long)]
274+
keyring: bool,
275+
276+
/// Remove from global config
277+
#[arg(long, conflicts_with = "keyring")]
278+
global: bool,
279+
280+
/// Remove from specific profile
281+
#[arg(long, conflicts_with_all = ["keyring", "global"])]
282+
profile: Option<String>,
283+
},
284+
}
285+
233286
/// Cloud Connectivity Commands
234287
#[derive(Subcommand, Debug)]
235288
pub enum CloudConnectivityCommands {
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
//! Files.com API key management commands
2+
//!
3+
//! This module provides commands for managing Files.com API keys used for
4+
//! support package uploads. Keys can be stored in the system keyring (secure)
5+
//! or in the config file (plaintext).
6+
7+
#![allow(dead_code)] // Functions are called from main.rs router
8+
9+
use crate::config::Config;
10+
use anyhow::{Context, Result};
11+
12+
/// Handle the files-key set command
13+
pub async fn handle_set(
14+
api_key: String,
15+
#[cfg(feature = "secure-storage")] use_keyring: bool,
16+
global: bool,
17+
profile: Option<String>,
18+
) -> Result<()> {
19+
#[cfg(feature = "secure-storage")]
20+
if use_keyring {
21+
// Store in system keyring
22+
let entry = keyring::Entry::new("redisctl", "files-api-key")
23+
.context("Failed to access system keyring")?;
24+
25+
entry
26+
.set_password(&api_key)
27+
.context("Failed to store API key in keyring")?;
28+
29+
println!("✓ Files.com API key stored securely in system keyring");
30+
println!("\nThe key will be used automatically for support package uploads.");
31+
println!("To use it in config, reference it as: files_api_key = \"keyring:files-api-key\"");
32+
return Ok(());
33+
}
34+
35+
// Store in config file
36+
let mut config = Config::load().unwrap_or_default();
37+
38+
if let Some(profile_name) = profile {
39+
// Store in specific profile
40+
if let Some(prof) = config.profiles.get_mut(&profile_name) {
41+
prof.files_api_key = Some(api_key);
42+
config.save()?;
43+
println!("✓ Files.com API key stored in profile '{}'", profile_name);
44+
println!("\n⚠️ Warning: Key is stored in plaintext in config file");
45+
#[cfg(feature = "secure-storage")]
46+
println!(" For better security, use: redisctl files-key set <key> --use-keyring");
47+
} else {
48+
anyhow::bail!("Profile '{}' not found", profile_name);
49+
}
50+
} else if global {
51+
// Store globally
52+
config.files_api_key = Some(api_key);
53+
config.save()?;
54+
println!("✓ Files.com API key stored globally in config");
55+
println!("\n⚠️ Warning: Key is stored in plaintext in config file");
56+
#[cfg(feature = "secure-storage")]
57+
println!(" For better security, use: redisctl files-key set <key> --use-keyring");
58+
} else {
59+
// Default behavior
60+
#[cfg(feature = "secure-storage")]
61+
{
62+
println!("Please specify where to store the key:");
63+
println!(" --use-keyring Store securely in system keyring (recommended)");
64+
println!(" --global Store in global config (plaintext)");
65+
println!(" --profile <name> Store in specific profile (plaintext)");
66+
anyhow::bail!("No storage location specified");
67+
}
68+
69+
#[cfg(not(feature = "secure-storage"))]
70+
{
71+
// Without secure-storage, default to global
72+
config.files_api_key = Some(api_key);
73+
config.save()?;
74+
println!("✓ Files.com API key stored globally in config");
75+
}
76+
}
77+
78+
Ok(())
79+
}
80+
81+
/// Handle the files-key get command
82+
pub async fn handle_get(profile: Option<String>) -> Result<()> {
83+
let config = Config::load().context("Failed to load config")?;
84+
85+
// Check profile-specific key
86+
if let Some(profile_name) = &profile {
87+
if let Some(prof) = config.profiles.get(profile_name) {
88+
if let Some(key) = &prof.files_api_key {
89+
if key.starts_with("keyring:") {
90+
println!("Profile '{}' uses keyring: {}", profile_name, key);
91+
92+
#[cfg(feature = "secure-storage")]
93+
{
94+
let keyring_key = key.strip_prefix("keyring:").unwrap();
95+
let entry = keyring::Entry::new("redisctl", keyring_key)
96+
.context("Failed to access system keyring")?;
97+
match entry.get_password() {
98+
Ok(actual_key) => {
99+
println!(
100+
"Key retrieved: {}...{}",
101+
&actual_key[..8.min(actual_key.len())],
102+
if actual_key.len() > 8 {
103+
&actual_key[actual_key.len() - 4..]
104+
} else {
105+
""
106+
}
107+
);
108+
}
109+
Err(e) => println!("⚠️ Failed to retrieve from keyring: {}", e),
110+
}
111+
}
112+
} else {
113+
println!(
114+
"Profile '{}' key: {}...{}",
115+
profile_name,
116+
&key[..8.min(key.len())],
117+
if key.len() > 8 {
118+
&key[key.len() - 4..]
119+
} else {
120+
""
121+
}
122+
);
123+
}
124+
return Ok(());
125+
} else {
126+
println!("No Files.com API key set for profile '{}'", profile_name);
127+
}
128+
} else {
129+
anyhow::bail!("Profile '{}' not found", profile_name);
130+
}
131+
}
132+
133+
// Check global key
134+
if let Some(key) = &config.files_api_key {
135+
if key.starts_with("keyring:") {
136+
println!("Global key uses keyring: {}", key);
137+
138+
#[cfg(feature = "secure-storage")]
139+
{
140+
let keyring_key = key.strip_prefix("keyring:").unwrap();
141+
let entry = keyring::Entry::new("redisctl", keyring_key)
142+
.context("Failed to access system keyring")?;
143+
match entry.get_password() {
144+
Ok(actual_key) => {
145+
println!(
146+
"Key retrieved: {}...{}",
147+
&actual_key[..8.min(actual_key.len())],
148+
if actual_key.len() > 8 {
149+
&actual_key[actual_key.len() - 4..]
150+
} else {
151+
""
152+
}
153+
);
154+
}
155+
Err(e) => println!("⚠️ Failed to retrieve from keyring: {}", e),
156+
}
157+
}
158+
} else {
159+
println!(
160+
"Global key: {}...{}",
161+
&key[..8.min(key.len())],
162+
if key.len() > 8 {
163+
&key[key.len() - 4..]
164+
} else {
165+
""
166+
}
167+
);
168+
}
169+
return Ok(());
170+
}
171+
172+
// Check keyring directly
173+
#[cfg(feature = "secure-storage")]
174+
{
175+
let entry = keyring::Entry::new("redisctl", "files-api-key")
176+
.context("Failed to access system keyring")?;
177+
if let Ok(key) = entry.get_password() {
178+
println!(
179+
"Key found in keyring: {}...{}",
180+
&key[..8.min(key.len())],
181+
if key.len() > 8 {
182+
&key[key.len() - 4..]
183+
} else {
184+
""
185+
}
186+
);
187+
return Ok(());
188+
}
189+
}
190+
191+
println!("No Files.com API key configured");
192+
println!("\nTo set a key:");
193+
println!(" redisctl files-key set <key> --use-keyring (recommended)");
194+
println!(" redisctl files-key set <key> --global");
195+
println!(" redisctl files-key set <key> --profile <name>");
196+
197+
Ok(())
198+
}
199+
200+
/// Handle the files-key remove command
201+
pub async fn handle_remove(
202+
#[cfg(feature = "secure-storage")] keyring: bool,
203+
global: bool,
204+
profile: Option<String>,
205+
) -> Result<()> {
206+
#[cfg(feature = "secure-storage")]
207+
if keyring {
208+
let entry = keyring::Entry::new("redisctl", "files-api-key")
209+
.context("Failed to access system keyring")?;
210+
211+
entry
212+
.delete_credential()
213+
.context("Failed to delete API key from keyring")?;
214+
215+
println!("✓ Files.com API key removed from keyring");
216+
return Ok(());
217+
}
218+
219+
let mut config = Config::load().context("Failed to load config")?;
220+
let mut modified = false;
221+
222+
if let Some(profile_name) = profile {
223+
// Remove from specific profile
224+
if let Some(prof) = config.profiles.get_mut(&profile_name) {
225+
if prof.files_api_key.is_some() {
226+
prof.files_api_key = None;
227+
modified = true;
228+
println!(
229+
"✓ Files.com API key removed from profile '{}'",
230+
profile_name
231+
);
232+
} else {
233+
println!("No Files.com API key set for profile '{}'", profile_name);
234+
}
235+
} else {
236+
anyhow::bail!("Profile '{}' not found", profile_name);
237+
}
238+
} else if global {
239+
// Remove global key
240+
if config.files_api_key.is_some() {
241+
config.files_api_key = None;
242+
modified = true;
243+
println!("✓ Files.com API key removed from global config");
244+
} else {
245+
println!("No global Files.com API key set");
246+
}
247+
} else {
248+
println!("Please specify what to remove:");
249+
#[cfg(feature = "secure-storage")]
250+
println!(" --keyring Remove from system keyring");
251+
println!(" --global Remove from global config");
252+
println!(" --profile <name> Remove from specific profile");
253+
anyhow::bail!("No removal target specified");
254+
}
255+
256+
if modified {
257+
config.save()?;
258+
}
259+
260+
Ok(())
261+
}

crates/redisctl/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
pub mod api;
44
pub mod cloud;
55
pub mod enterprise;
6+
pub mod files_key;

0 commit comments

Comments
 (0)