Skip to content

Commit bbab913

Browse files
KrumTyclaude
andcommitted
feat(core): add cloud_auth config + credential persistence
Adds the config surface that turns a minted API key into a usable profile: - `[cloud_auth.<profile>]` section (`CloudAuthConfig`: okta_issuer, client id, sm_api_url, capi_url) with built-in production defaults and an `is_complete()` check; kept separate from stored credentials since it describes *how to log in*, not the resulting keys. - `Config::resolve_cloud_auth` (profile entry, else prod defaults) and `Config::apply_cloud_login`, which writes an `auth::MintedCredentials` into a normal cloud profile (keyring by default via `CredentialStore`, plaintext fallback) and records the login endpoints for re-login. - `CredentialStore::plaintext()` for the `--allow-plaintext` path. Additive; no existing config behaviour changes. Integration-tested (round-trip, prod-default fallback, profile persistence). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3638ded commit bbab913

5 files changed

Lines changed: 257 additions & 3 deletions

File tree

crates/redisctl-core/src/config/config.rs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ pub struct Config {
3333
/// Map of profile name -> profile configuration
3434
#[serde(default)]
3535
pub profiles: HashMap<String, Profile>,
36+
/// Per-profile OIDC login endpoints for `cloud auth login`, keyed by profile name.
37+
/// A missing entry falls back to the built-in production defaults. Kept separate from the
38+
/// profile's stored credentials since it describes *how to log in*, not the resulting keys.
39+
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
40+
pub cloud_auth: HashMap<String, CloudAuthConfig>,
3641
}
3742

3843
/// Individual profile configuration
@@ -52,6 +57,46 @@ pub struct Profile {
5257
pub tags: Vec<String>,
5358
}
5459

60+
/// OIDC endpoints used by `cloud auth login` for one environment. Held in `Config.cloud_auth`
61+
/// keyed by profile name, so it serializes under `[cloud_auth.<name>]`.
62+
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
63+
pub struct CloudAuthConfig {
64+
/// Okta authorization-server issuer, e.g. `https://<your-okta-issuer>/oauth2/default`.
65+
pub okta_issuer: String,
66+
/// Public Okta client id for the redisctl app.
67+
pub okta_client_id: String,
68+
/// SM API base used for the token→CAPI-key exchange, e.g. `https://<sm-api-host>/api/v1`.
69+
pub sm_api_url: String,
70+
/// Public CAPI base recorded in the resulting cloud profile (`api_url`).
71+
#[serde(default = "default_prod_capi_url")]
72+
pub capi_url: String,
73+
}
74+
75+
fn default_prod_capi_url() -> String {
76+
"https://api.redislabs.com/v1".to_string()
77+
}
78+
79+
impl CloudAuthConfig {
80+
/// Built-in production defaults. The Okta issuer/client id and SM host are filled in when
81+
/// IT/IAM provisions the prod app; until then [`CloudAuthConfig::is_complete`] reports
82+
/// what's missing.
83+
pub fn prod_defaults() -> Self {
84+
Self {
85+
okta_issuer: String::new(),
86+
okta_client_id: String::new(),
87+
sm_api_url: String::new(),
88+
capi_url: default_prod_capi_url(),
89+
}
90+
}
91+
92+
/// Whether the login endpoints are fully specified (issuer, client id, SM API base).
93+
pub fn is_complete(&self) -> bool {
94+
!self.okta_issuer.is_empty()
95+
&& !self.okta_client_id.is_empty()
96+
&& !self.sm_api_url.is_empty()
97+
}
98+
}
99+
55100
/// Supported deployment types
56101
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, clap::ValueEnum)]
57102
#[serde(rename_all = "lowercase")]
@@ -584,6 +629,59 @@ impl Config {
584629
self.profiles.insert(name, profile);
585630
}
586631

632+
/// Effective `cloud auth login` endpoints for a profile: its `cloud_auth` entry, or the
633+
/// built-in production defaults when none is set.
634+
pub fn resolve_cloud_auth(&self, profile_name: &str) -> CloudAuthConfig {
635+
self.cloud_auth
636+
.get(profile_name)
637+
.cloned()
638+
.unwrap_or_else(CloudAuthConfig::prod_defaults)
639+
}
640+
641+
/// Persist a completed `cloud auth login` into `profile_name` and make it the default
642+
/// cloud profile.
643+
///
644+
/// The CAPI key/secret are stored via `store` (keyring when available, else plaintext) and
645+
/// the profile records the resulting references. The Okta refresh token, if present, is
646+
/// stored under `<profile>-okta-refresh` (keyring only — with a plaintext store there is
647+
/// nowhere to keep it, so silent re-auth won't be available and a fresh login is needed
648+
/// when the CAPI key is lost). `cloud_auth` (the login endpoints) is recorded so re-login
649+
/// works without re-specifying it.
650+
pub fn apply_cloud_login(
651+
&mut self,
652+
store: &CredentialStore,
653+
profile_name: &str,
654+
creds: &crate::auth::MintedCredentials,
655+
cloud_auth: Option<CloudAuthConfig>,
656+
) -> Result<()> {
657+
let api_key =
658+
store.store_credential(&format!("{profile_name}-cloud-api-key"), &creds.api_key)?;
659+
let api_secret = store.store_credential(
660+
&format!("{profile_name}-cloud-api-secret"),
661+
&creds.api_secret,
662+
)?;
663+
if let Some(refresh) = &creds.refresh_token {
664+
// Reference is implicit (looked up by the well-known key on refresh); ignore it.
665+
let _ = store.store_credential(&format!("{profile_name}-okta-refresh"), refresh)?;
666+
}
667+
let profile = Profile {
668+
deployment_type: DeploymentType::Cloud,
669+
credentials: ProfileCredentials::Cloud {
670+
api_key,
671+
api_secret,
672+
api_url: creds.api_url.clone(),
673+
},
674+
files_api_key: None,
675+
tags: Vec::new(),
676+
};
677+
self.profiles.insert(profile_name.to_string(), profile);
678+
if let Some(auth) = cloud_auth {
679+
self.cloud_auth.insert(profile_name.to_string(), auth);
680+
}
681+
self.default_cloud = Some(profile_name.to_string());
682+
Ok(())
683+
}
684+
587685
/// Remove a profile by name
588686
pub fn remove_profile(&mut self, name: &str) -> Option<Profile> {
589687
// Clear type-specific defaults if this profile was set as default
@@ -596,6 +694,7 @@ impl Config {
596694
if self.default_database.as_deref() == Some(name) {
597695
self.default_database = None;
598696
}
697+
self.cloud_auth.remove(name);
599698
self.profiles.remove(name)
600699
}
601700

crates/redisctl-core/src/config/credential.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,14 @@ impl CredentialStore {
6363
}
6464
}
6565

66+
/// Create a store that always uses plaintext (no keyring). Useful for tests and for the
67+
/// explicit `--allow-plaintext` opt-in when no keyring is available.
68+
pub fn plaintext() -> Self {
69+
Self {
70+
storage: CredentialStorage::Plaintext,
71+
}
72+
}
73+
6674
/// Check if keyring is available on this system
6775
#[cfg(feature = "secure-storage")]
6876
fn is_keyring_available() -> bool {

crates/redisctl-core/src/config/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,6 @@ pub mod credential;
2020
pub mod error;
2121

2222
// Re-export main types for convenience
23-
pub use config::{Config, DeploymentType, Profile, ProfileCredentials};
23+
pub use config::{CloudAuthConfig, Config, DeploymentType, Profile, ProfileCredentials};
2424
pub use credential::{CredentialStorage, CredentialStore};
2525
pub use error::{ConfigError, Result};

crates/redisctl-core/src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,8 @@ pub use progress::{ProgressCallback, ProgressEvent, poll_task};
8484

8585
// Re-export config types for convenience
8686
pub use config::{
87-
Config, ConfigError, CredentialStorage, CredentialStore, DeploymentType, Profile,
88-
ProfileCredentials,
87+
CloudAuthConfig, Config, ConfigError, CredentialStorage, CredentialStore, DeploymentType,
88+
Profile, ProfileCredentials,
8989
};
9090

9191
// Re-export Layer 1 for convenience (but consumers can also import directly)
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
//! Tests for the `cloud_auth` config surface + `apply_cloud_login` persistence.
2+
//! Uses save/load round-trips and a plaintext credential store, so nothing touches the real
3+
//! OS keyring.
4+
5+
use redisctl_core::{
6+
CloudAuthConfig, Config, CredentialStore, DeploymentType, MintedCredentials, Profile,
7+
ProfileCredentials,
8+
};
9+
10+
fn cloud_profile(api_url: &str) -> Profile {
11+
Profile {
12+
deployment_type: DeploymentType::Cloud,
13+
credentials: ProfileCredentials::Cloud {
14+
api_key: "acct-key".to_string(),
15+
api_secret: "user-secret".to_string(),
16+
api_url: api_url.to_string(),
17+
},
18+
files_api_key: None,
19+
tags: Vec::new(),
20+
}
21+
}
22+
23+
fn qa_cloud_auth() -> CloudAuthConfig {
24+
CloudAuthConfig {
25+
okta_issuer: "https://okta.example.com/oauth2/default".to_string(),
26+
okta_client_id: "test-client-id".to_string(),
27+
sm_api_url: "https://sm.example.com/api/v1".to_string(),
28+
capi_url: "https://api.example.com/v1".to_string(),
29+
}
30+
}
31+
32+
fn roundtrip(config: &Config) -> (String, Config) {
33+
let dir = tempfile::tempdir().unwrap();
34+
let path = dir.path().join("config.toml");
35+
config.save_to_path(&path).unwrap();
36+
let toml = std::fs::read_to_string(&path).unwrap();
37+
let loaded = Config::load_from_path(&path).unwrap();
38+
(toml, loaded)
39+
}
40+
41+
#[test]
42+
fn config_without_cloud_auth_roundtrips_and_omits_the_key() {
43+
let mut config = Config::default();
44+
config.set_profile(
45+
"prod".to_string(),
46+
cloud_profile("https://api.redislabs.com/v1"),
47+
);
48+
49+
let (toml, loaded) = roundtrip(&config);
50+
// Backward compatible: no cloud_auth section is written when none is set.
51+
assert!(
52+
!toml.contains("cloud_auth"),
53+
"unexpected cloud_auth in:\n{toml}"
54+
);
55+
assert!(loaded.profiles.contains_key("prod"));
56+
assert_eq!(
57+
loaded.profiles["prod"].cloud_credentials().map(|c| c.2),
58+
Some("https://api.redislabs.com/v1")
59+
);
60+
}
61+
62+
#[test]
63+
fn config_with_cloud_auth_roundtrips() {
64+
let mut config = Config::default();
65+
config.set_profile(
66+
"qa".to_string(),
67+
cloud_profile("https://api.example.com/v1"),
68+
);
69+
config.cloud_auth.insert("qa".to_string(), qa_cloud_auth());
70+
71+
let (toml, loaded) = roundtrip(&config);
72+
assert!(
73+
toml.contains("[cloud_auth.qa]"),
74+
"expected cloud_auth table in:\n{toml}"
75+
);
76+
assert_eq!(loaded.resolve_cloud_auth("qa"), qa_cloud_auth());
77+
}
78+
79+
#[test]
80+
fn resolve_cloud_auth_falls_back_to_prod_defaults() {
81+
let config = Config::default();
82+
let resolved = config.resolve_cloud_auth("anything");
83+
assert_eq!(resolved, CloudAuthConfig::prod_defaults());
84+
// Prod endpoints aren't provisioned yet, so it isn't complete, but the CAPI base is known.
85+
assert!(!resolved.is_complete());
86+
assert_eq!(resolved.capi_url, "https://api.redislabs.com/v1");
87+
}
88+
89+
#[test]
90+
fn apply_cloud_login_writes_profile_default_and_endpoints() {
91+
let mut config = Config::default();
92+
let store = CredentialStore::plaintext(); // never touches the keyring
93+
let creds = MintedCredentials {
94+
account_id: Some("112117".to_string()),
95+
email: Some("u@e.com".to_string()),
96+
api_key: "ACCT-KEY".to_string(),
97+
api_secret: "USER-SECRET".to_string(),
98+
api_url: "https://api.example.com/v1".to_string(),
99+
refresh_token: Some("RT".to_string()),
100+
capi_key_name: "redisctl-demo".to_string(),
101+
redisctl_key_count: 1,
102+
};
103+
104+
config
105+
.apply_cloud_login(&store, "qa", &creds, Some(qa_cloud_auth()))
106+
.unwrap();
107+
108+
// Default cloud profile is set.
109+
assert_eq!(config.default_cloud.as_deref(), Some("qa"));
110+
// Profile is a Cloud profile; plaintext store records the values as-is.
111+
let (key, secret, url) = config.profiles["qa"].cloud_credentials().unwrap();
112+
assert_eq!(key, "ACCT-KEY");
113+
assert_eq!(secret, "USER-SECRET");
114+
assert_eq!(url, "https://api.example.com/v1");
115+
// Login endpoints were recorded for re-login.
116+
assert_eq!(config.resolve_cloud_auth("qa"), qa_cloud_auth());
117+
118+
// And it survives a save/load round-trip.
119+
let (_, loaded) = roundtrip(&config);
120+
assert_eq!(loaded.default_cloud.as_deref(), Some("qa"));
121+
assert_eq!(loaded.resolve_cloud_auth("qa"), qa_cloud_auth());
122+
}
123+
124+
/// Mirrors what `cloud auth logout` does at the config layer: it removes the profile (and its
125+
/// credentials) but must PRESERVE the `[cloud_auth.<profile>]` login endpoints, so a later
126+
/// `auth login` still resolves them instead of failing with "not configured".
127+
#[test]
128+
fn logout_removes_profile_but_preserves_cloud_auth_endpoints() {
129+
let mut config = Config::default();
130+
config.set_profile("qa".to_string(), cloud_profile("https://api-qa.example/v1"));
131+
config.cloud_auth.insert("qa".to_string(), qa_cloud_auth());
132+
133+
// The logout sequence: save endpoints, remove the profile, restore endpoints.
134+
let saved = config.cloud_auth.get("qa").cloned();
135+
config.remove_profile("qa");
136+
if let Some(auth) = saved {
137+
config.cloud_auth.insert("qa".to_string(), auth);
138+
}
139+
140+
let (_, loaded) = roundtrip(&config);
141+
// Profile (credentials) is gone…
142+
assert!(!loaded.list_profiles().iter().any(|(n, _)| *n == "qa"));
143+
// …but the login endpoints survive so re-login works.
144+
let auth = loaded.resolve_cloud_auth("qa");
145+
assert!(auth.is_complete());
146+
assert_eq!(auth.okta_client_id, "test-client-id");
147+
}

0 commit comments

Comments
 (0)