Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions crates/redisctl-core/src/config/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ pub struct Config {
/// Map of profile name -> profile configuration
#[serde(default)]
pub profiles: HashMap<String, Profile>,
/// Per-profile OIDC login endpoints for `cloud auth login`, keyed by profile name.
/// A missing entry falls back to the built-in production defaults. Kept separate from the
/// profile's stored credentials since it describes *how to log in*, not the resulting keys.
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub cloud_auth: HashMap<String, CloudAuthConfig>,
}

/// Individual profile configuration
Expand All @@ -52,6 +57,46 @@ pub struct Profile {
pub tags: Vec<String>,
}

/// OIDC endpoints used by `cloud auth login` for one environment. Held in `Config.cloud_auth`
/// keyed by profile name, so it serializes under `[cloud_auth.<name>]`.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct CloudAuthConfig {
/// Okta authorization-server issuer, e.g. `https://<your-okta-issuer>/oauth2/default`.
pub okta_issuer: String,
/// Public Okta client id for the redisctl app.
pub okta_client_id: String,
/// SM API base used for the token→CAPI-key exchange, e.g. `https://<sm-api-host>/api/v1`.
pub sm_api_url: String,
/// Public CAPI base recorded in the resulting cloud profile (`api_url`).
#[serde(default = "default_prod_capi_url")]
pub capi_url: String,
}

fn default_prod_capi_url() -> String {
"https://api.redislabs.com/v1".to_string()
}

impl CloudAuthConfig {
/// Built-in production defaults. The Okta issuer/client id and SM host are filled in when
/// IT/IAM provisions the prod app; until then [`CloudAuthConfig::is_complete`] reports
/// what's missing.
pub fn prod_defaults() -> Self {
Self {
okta_issuer: String::new(),
okta_client_id: String::new(),
sm_api_url: String::new(),
capi_url: default_prod_capi_url(),
}
}

/// Whether the login endpoints are fully specified (issuer, client id, SM API base).
pub fn is_complete(&self) -> bool {
!self.okta_issuer.is_empty()
&& !self.okta_client_id.is_empty()
&& !self.sm_api_url.is_empty()
}
}

/// Supported deployment types
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, clap::ValueEnum)]
#[serde(rename_all = "lowercase")]
Expand Down Expand Up @@ -584,6 +629,59 @@ impl Config {
self.profiles.insert(name, profile);
}

/// Effective `cloud auth login` endpoints for a profile: its `cloud_auth` entry, or the
/// built-in production defaults when none is set.
pub fn resolve_cloud_auth(&self, profile_name: &str) -> CloudAuthConfig {
self.cloud_auth
.get(profile_name)
.cloned()
.unwrap_or_else(CloudAuthConfig::prod_defaults)
}

/// Persist a completed `cloud auth login` into `profile_name` and make it the default
/// cloud profile.
///
/// The CAPI key/secret are stored via `store` (keyring when available, else plaintext) and
/// the profile records the resulting references. The Okta refresh token, if present, is
/// stored under `<profile>-okta-refresh` (keyring only — with a plaintext store there is
/// nowhere to keep it, so silent re-auth won't be available and a fresh login is needed
/// when the CAPI key is lost). `cloud_auth` (the login endpoints) is recorded so re-login
/// works without re-specifying it.
pub fn apply_cloud_login(
&mut self,
store: &CredentialStore,
profile_name: &str,
creds: &crate::auth::MintedCredentials,
cloud_auth: Option<CloudAuthConfig>,
) -> Result<()> {
let api_key =
store.store_credential(&format!("{profile_name}-cloud-api-key"), &creds.api_key)?;
let api_secret = store.store_credential(
&format!("{profile_name}-cloud-api-secret"),
&creds.api_secret,
)?;
if let Some(refresh) = &creds.refresh_token {
// Reference is implicit (looked up by the well-known key on refresh); ignore it.
let _ = store.store_credential(&format!("{profile_name}-okta-refresh"), refresh)?;
}
let profile = Profile {
deployment_type: DeploymentType::Cloud,
credentials: ProfileCredentials::Cloud {
api_key,
api_secret,
api_url: creds.api_url.clone(),
},
files_api_key: None,
tags: Vec::new(),
};
self.profiles.insert(profile_name.to_string(), profile);
if let Some(auth) = cloud_auth {
self.cloud_auth.insert(profile_name.to_string(), auth);
}
self.default_cloud = Some(profile_name.to_string());
Ok(())
}

/// Remove a profile by name
pub fn remove_profile(&mut self, name: &str) -> Option<Profile> {
// Clear type-specific defaults if this profile was set as default
Expand All @@ -596,6 +694,7 @@ impl Config {
if self.default_database.as_deref() == Some(name) {
self.default_database = None;
}
self.cloud_auth.remove(name);
self.profiles.remove(name)
}

Expand Down
8 changes: 8 additions & 0 deletions crates/redisctl-core/src/config/credential.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ impl CredentialStore {
}
}

/// Create a store that always uses plaintext (no keyring). Useful for tests and for the
/// explicit `--allow-plaintext` opt-in when no keyring is available.
pub fn plaintext() -> Self {
Self {
storage: CredentialStorage::Plaintext,
}
}

/// Check if keyring is available on this system
#[cfg(feature = "secure-storage")]
fn is_keyring_available() -> bool {
Expand Down
2 changes: 1 addition & 1 deletion crates/redisctl-core/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,6 @@ pub mod credential;
pub mod error;

// Re-export main types for convenience
pub use config::{Config, DeploymentType, Profile, ProfileCredentials};
pub use config::{CloudAuthConfig, Config, DeploymentType, Profile, ProfileCredentials};
pub use credential::{CredentialStorage, CredentialStore};
pub use error::{ConfigError, Result};
4 changes: 2 additions & 2 deletions crates/redisctl-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@ pub use progress::{ProgressCallback, ProgressEvent, poll_task};

// Re-export config types for convenience
pub use config::{
Config, ConfigError, CredentialStorage, CredentialStore, DeploymentType, Profile,
ProfileCredentials,
CloudAuthConfig, Config, ConfigError, CredentialStorage, CredentialStore, DeploymentType,
Profile, ProfileCredentials,
};

// Re-export Layer 1 for convenience (but consumers can also import directly)
Expand Down
147 changes: 147 additions & 0 deletions crates/redisctl-core/tests/cloud_auth_config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
//! Tests for the `cloud_auth` config surface + `apply_cloud_login` persistence.
//! Uses save/load round-trips and a plaintext credential store, so nothing touches the real
//! OS keyring.

use redisctl_core::{
CloudAuthConfig, Config, CredentialStore, DeploymentType, MintedCredentials, Profile,
ProfileCredentials,
};

fn cloud_profile(api_url: &str) -> Profile {
Profile {
deployment_type: DeploymentType::Cloud,
credentials: ProfileCredentials::Cloud {
api_key: "acct-key".to_string(),
api_secret: "user-secret".to_string(),
api_url: api_url.to_string(),
},
files_api_key: None,
tags: Vec::new(),
}
}

fn qa_cloud_auth() -> CloudAuthConfig {
CloudAuthConfig {
okta_issuer: "https://okta.example.com/oauth2/default".to_string(),
okta_client_id: "test-client-id".to_string(),
sm_api_url: "https://sm.example.com/api/v1".to_string(),
capi_url: "https://api.example.com/v1".to_string(),
}
}

fn roundtrip(config: &Config) -> (String, Config) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
config.save_to_path(&path).unwrap();
let toml = std::fs::read_to_string(&path).unwrap();
let loaded = Config::load_from_path(&path).unwrap();
(toml, loaded)
}

#[test]
fn config_without_cloud_auth_roundtrips_and_omits_the_key() {
let mut config = Config::default();
config.set_profile(
"prod".to_string(),
cloud_profile("https://api.redislabs.com/v1"),
);

let (toml, loaded) = roundtrip(&config);
// Backward compatible: no cloud_auth section is written when none is set.
assert!(
!toml.contains("cloud_auth"),
"unexpected cloud_auth in:\n{toml}"
);
assert!(loaded.profiles.contains_key("prod"));
assert_eq!(
loaded.profiles["prod"].cloud_credentials().map(|c| c.2),
Some("https://api.redislabs.com/v1")
);
}

#[test]
fn config_with_cloud_auth_roundtrips() {
let mut config = Config::default();
config.set_profile(
"qa".to_string(),
cloud_profile("https://api.example.com/v1"),
);
config.cloud_auth.insert("qa".to_string(), qa_cloud_auth());

let (toml, loaded) = roundtrip(&config);
assert!(
toml.contains("[cloud_auth.qa]"),
"expected cloud_auth table in:\n{toml}"
);
assert_eq!(loaded.resolve_cloud_auth("qa"), qa_cloud_auth());
}

#[test]
fn resolve_cloud_auth_falls_back_to_prod_defaults() {
let config = Config::default();
let resolved = config.resolve_cloud_auth("anything");
assert_eq!(resolved, CloudAuthConfig::prod_defaults());
// Prod endpoints aren't provisioned yet, so it isn't complete, but the CAPI base is known.
assert!(!resolved.is_complete());
assert_eq!(resolved.capi_url, "https://api.redislabs.com/v1");
}

#[test]
fn apply_cloud_login_writes_profile_default_and_endpoints() {
let mut config = Config::default();
let store = CredentialStore::plaintext(); // never touches the keyring
let creds = MintedCredentials {
account_id: Some("112117".to_string()),
email: Some("u@e.com".to_string()),
api_key: "ACCT-KEY".to_string(),
api_secret: "USER-SECRET".to_string(),
api_url: "https://api.example.com/v1".to_string(),
refresh_token: Some("RT".to_string()),
capi_key_name: "redisctl-demo".to_string(),
redisctl_key_count: 1,
};

config
.apply_cloud_login(&store, "qa", &creds, Some(qa_cloud_auth()))
.unwrap();

// Default cloud profile is set.
assert_eq!(config.default_cloud.as_deref(), Some("qa"));
// Profile is a Cloud profile; plaintext store records the values as-is.
let (key, secret, url) = config.profiles["qa"].cloud_credentials().unwrap();
assert_eq!(key, "ACCT-KEY");
assert_eq!(secret, "USER-SECRET");
assert_eq!(url, "https://api.example.com/v1");
// Login endpoints were recorded for re-login.
assert_eq!(config.resolve_cloud_auth("qa"), qa_cloud_auth());

// And it survives a save/load round-trip.
let (_, loaded) = roundtrip(&config);
assert_eq!(loaded.default_cloud.as_deref(), Some("qa"));
assert_eq!(loaded.resolve_cloud_auth("qa"), qa_cloud_auth());
}

/// Mirrors what `cloud auth logout` does at the config layer: it removes the profile (and its
/// credentials) but must PRESERVE the `[cloud_auth.<profile>]` login endpoints, so a later
/// `auth login` still resolves them instead of failing with "not configured".
#[test]
fn logout_removes_profile_but_preserves_cloud_auth_endpoints() {
let mut config = Config::default();
config.set_profile("qa".to_string(), cloud_profile("https://api-qa.example/v1"));
config.cloud_auth.insert("qa".to_string(), qa_cloud_auth());

// The logout sequence: save endpoints, remove the profile, restore endpoints.
let saved = config.cloud_auth.get("qa").cloned();
config.remove_profile("qa");
if let Some(auth) = saved {
config.cloud_auth.insert("qa".to_string(), auth);
}

let (_, loaded) = roundtrip(&config);
// Profile (credentials) is gone…
assert!(!loaded.list_profiles().iter().any(|(n, _)| *n == "qa"));
// …but the login endpoints survive so re-login works.
let auth = loaded.resolve_cloud_auth("qa");
assert!(auth.is_complete());
assert_eq!(auth.okta_client_id, "test-client-id");
}
Loading