use std::time::Duration;
use secrecy::{ExposeSecret, SecretString};
use crate::cli::commands::auth::{
attempt_server_revocation, make_file_store, pinned_organization, sign_in_with_code,
validate_online_full, Exchanged, Intent, Org, SignInCtx,
};
use crate::cli::output::{OutputConfig, OutputFormat};
use crate::cli::AuthSwitchArgs;
use crate::core::auth::{CredentialStore, KeyringCredentialStore};
use crate::core::pending_switch::{self, PendingSwitch};
use crate::error::{OlError, ERR_NO_CREDENTIALS, ERR_SWITCH_DAEMON, ERR_SWITCH_ENV_KEY};
const BEGIN_TIMEOUT_MS: u64 = 10_000;
const ROUTE_TIMEOUT: Duration = Duration::from_secs(45);
const NOTHING_CHANGED: &str = "Nothing changed: this machine keeps its organization and its \
key. Fix the storage problem, then run the command again.";
pub fn run_switch(args: &AuthSwitchArgs, output: &OutputConfig) -> Result<(), OlError> {
if std::env::var("OPENLATCH_API_KEY").is_ok_and(|k| !k.is_empty()) {
return Err(OlError::new(
ERR_SWITCH_ENV_KEY,
"The key comes from OPENLATCH_API_KEY, so this command cannot change it",
)
.with_suggestion("Set OPENLATCH_API_KEY to a key from the target organization instead."));
}
let Some(pinned) = pinned_organization()? else {
return Err(OlError::new(
ERR_NO_CREDENTIALS,
"This machine is not enrolled in any organization yet",
)
.with_suggestion("Run `openlatch init` to enroll it."));
};
let host =
crate::cli::commands::auth::system_hostname().unwrap_or_else(|| "This machine".to_string());
if args.org == pinned {
let current = Org {
id: pinned.clone(),
slug: String::new(),
name: pinned.clone(),
};
render_already_in(output, ¤t);
return Ok(());
}
let rt = tokio::runtime::Runtime::new().map_err(|e| {
OlError::new(
ERR_SWITCH_DAEMON,
format!("Failed to create async runtime: {e}"),
)
})?;
let ctx = SignInCtx::from_config(output, false, args.no_browser);
let keychain = KeyringCredentialStore::new();
let file_store = make_file_store();
let old = crate::core::auth::retrieve_stored(&keychain, &file_store).map(|(k, _)| k);
let old_name = old
.as_ref()
.map(|k| {
rt.block_on(validate_online_full(
k.expose_secret(),
&ctx.api_url,
&ctx.egress,
))
})
.filter(|v| v.online && v.org_id == pinned && !v.org_name.is_empty())
.map(|v| v.org_name);
let from = Org {
id: pinned.clone(),
slug: String::new(),
name: old_name.unwrap_or_else(|| pinned.clone()),
};
let (new_key, to) = match rt.block_on(sign_in_with_code(
&ctx,
Intent::Switch,
Some(&args.org),
Some(&pinned),
))? {
Exchanged::AlreadyIn(org) => {
render_already_in(output, &org);
return Ok(());
}
Exchanged::New { key, organization } => (key, organization),
};
let revoke_new = || {
rt.block_on(attempt_server_revocation(
new_key.expose_secret(),
&ctx.api_url,
&ctx.egress,
))
};
let daemon = match Daemon::locate() {
Ok(d) => d,
Err(reason) => {
revoke_new();
return Err(daemon_refused(&reason));
}
};
let mut events_sent = 0u64;
if let Some(d) = daemon.as_ref().filter(|d| d.running()) {
match d.begin() {
Ok(sent) => events_sent = sent,
Err(reason) => {
d.abort();
revoke_new();
return Err(daemon_refused(&reason));
}
}
}
let openlatch_dir = crate::config::openlatch_dir();
let marker = PendingSwitch::new(&to.id, &new_key, pending_switch::now_unix_secs());
let stored = pending_switch::write(&openlatch_dir, &marker)
.map_err(|e| {
OlError::new(
crate::error::ERR_FILE_FALLBACK_ERROR,
format!("Could not write the pending-switch marker: {e}"),
)
.with_suggestion(NOTHING_CHANGED)
})
.and_then(|()| store_switching(&keychain, &file_store, &new_key));
if let Err(e) = stored {
let _ = pending_switch::delete(&openlatch_dir);
if let Some(d) = daemon.as_ref() {
d.abort();
}
revoke_new();
return Err(e);
}
let mut events_dropped: Option<u64> = None;
let mut warning: Option<&'static str> = None;
let mut applied_now = false;
if let Some(d) = daemon.as_ref().filter(|d| d.running()) {
match d.commit() {
Commit::Applied(dropped) => {
events_dropped = Some(dropped);
applied_now = true;
}
Commit::AlreadyApplied => applied_now = true,
Commit::Failed if d.restart() => applied_now = true,
Commit::Failed => warning = Some("the switch completes when the daemon next starts"),
}
}
let old_key_revoked = match old.as_ref() {
Some(k) => rt.block_on(attempt_server_revocation(
k.expose_secret(),
&ctx.api_url,
&ctx.egress,
)),
None => false,
};
render_moved(
output,
&Moved {
host: &host,
from: &from,
to: &to,
events_sent,
events_dropped,
applied_now,
old_key_revoked,
had_old_key: old.is_some(),
warning,
},
);
Ok(())
}
pub(crate) fn store_switching(
keychain: &dyn CredentialStore,
file: &dyn CredentialStore,
key: &SecretString,
) -> Result<(), OlError> {
let before_keychain = keychain.retrieve().ok();
let before_file = file.retrieve().ok();
let before = crate::core::auth::retrieve_stored(keychain, file).map(|(k, _)| k);
let result = match keychain.store(key.clone()) {
Ok(()) => file.delete(),
Err(keychain_err) => file.store(key.clone()).map_err(|file_err| {
OlError::new(
file_err.code,
format!(
"Could not store the new key in the OS keychain ({}) or the encrypted \
file ({})",
keychain_err.message, file_err.message
),
)
}),
};
let result = result.and_then(|()| {
if keychain
.retrieve()
.is_ok_and(|k| k.expose_secret() != key.expose_secret())
{
keychain.delete()?;
keychain.invalidate();
}
match crate::core::auth::retrieve_stored(keychain, file) {
Some((k, _)) if k.expose_secret() == key.expose_secret() => Ok(()),
_ => Err(OlError::new(
crate::error::ERR_FILE_FALLBACK_ERROR,
"The new key was stored, but the credential lookup does not return it",
)),
}
});
match result {
Ok(()) => Ok(()),
Err(e) => {
restore(keychain, before_keychain);
restore(file, before_file);
let after = crate::core::auth::retrieve_stored(keychain, file).map(|(k, _)| k);
let restored = match (&before, &after) {
(Some(b), Some(a)) => b.expose_secret() == a.expose_secret(),
(None, None) => true,
_ => false,
};
if restored {
Err(e.with_suggestion(NOTHING_CHANGED))
} else {
Err(OlError::new(
e.code,
format!(
"{}; the previous credential could not be restored either",
e.message
),
)
.with_suggestion(
"Run `openlatch system auth status` to see which key is stored, then run \
this command again.",
))
}
}
}
}
fn restore(store: &dyn CredentialStore, value: Option<SecretString>) {
let _ = match value {
Some(v) => store.store(v),
None => store.delete(),
};
}
fn daemon_refused(reason: &str) -> OlError {
OlError::new(
ERR_SWITCH_DAEMON,
format!("The daemon did not accept the switch ({reason}); nothing changed"),
)
.with_suggestion("Check `openlatch status`, then run the command again.")
}
struct Daemon {
port: u16,
token: String,
client: reqwest::blocking::Client,
}
enum Commit {
Applied(u64),
AlreadyApplied,
Failed,
}
impl Daemon {
fn locate() -> Result<Option<Self>, String> {
let Ok(cfg) = crate::config::Config::load(None, None, false) else {
return Ok(None);
};
if !crate::cli::commands::lifecycle::check_health(cfg.port) {
return Ok(None);
}
let token = std::fs::read_to_string(crate::config::openlatch_dir().join("daemon.token"))
.map_err(|e| format!("the daemon is running but its admin token cannot be read: {e}"))?
.trim()
.to_string();
if token.is_empty() {
return Err("the daemon is running but its admin token is empty".to_string());
}
let client = crate::egress::blocking_client_builder()
.timeout(ROUTE_TIMEOUT)
.build()
.map_err(|e| format!("cannot build the admin client: {e}"))?;
Ok(Some(Self {
port: cfg.port,
token,
client,
}))
}
fn running(&self) -> bool {
crate::cli::commands::lifecycle::check_health(self.port)
}
fn post(
&self,
route: &str,
body: serde_json::Value,
) -> Result<(u16, serde_json::Value), String> {
let resp = self
.client
.post(format!(
"http://127.0.0.1:{}/admin/switch/{route}",
self.port
))
.bearer_auth(&self.token)
.json(&body)
.send()
.map_err(|e| format!("daemon unreachable: {e}"))?;
let status = resp.status().as_u16();
let body = resp.json().unwrap_or(serde_json::Value::Null);
Ok((status, body))
}
fn begin(&self) -> Result<u64, String> {
let (status, body) = self.post(
"begin",
serde_json::json!({ "timeout_ms": BEGIN_TIMEOUT_MS }),
)?;
if status == 200 {
Ok(body["sent"].as_u64().unwrap_or(0))
} else {
Err(route_reason(status, &body))
}
}
fn commit(&self) -> Commit {
match self.post("commit", serde_json::json!({})) {
Ok((200, body)) => Commit::Applied(body["dropped"].as_u64().unwrap_or(0)),
Ok((409, _))
if matches!(
pending_switch::read(&crate::config::openlatch_dir()),
Ok(None)
) =>
{
Commit::AlreadyApplied
}
Ok((status, body)) => {
tracing::warn!(reason = %route_reason(status, &body), "switch commit refused; restarting the daemon");
Commit::Failed
}
Err(e) => {
tracing::warn!(error = %e, "switch commit failed; restarting the daemon");
Commit::Failed
}
}
}
fn abort(&self) {
let _ = self.post("abort", serde_json::json!({}));
}
fn restart(&self) -> bool {
use crate::cli::commands::lifecycle as lc;
if !lc::stop_via_supervisor() {
lc::send_shutdown_request(self.port, &self.token);
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while std::time::Instant::now() < deadline && lc::check_health(self.port) {
std::thread::sleep(Duration::from_millis(100));
}
}
if lc::start_via_supervisor(self.port) {
return true;
}
lc::spawn_daemon_background(self.port, &self.token).is_ok()
&& lc::wait_for_health(self.port, 10)
}
}
fn route_reason(status: u16, body: &serde_json::Value) -> String {
let reason = body["error"]["reason"]
.as_str()
.unwrap_or("unexpected reply");
format!("HTTP {status}, {reason}")
}
fn org_json(org: &Org) -> serde_json::Value {
serde_json::json!({ "id": org.id, "name": org.name })
}
fn render_already_in(output: &OutputConfig, org: &Org) {
if output.format == OutputFormat::Json {
output.print_json(&serde_json::json!({
"switched": false,
"from": org_json(org),
"to": org_json(org),
"events_sent": 0,
"events_dropped": 0,
"old_key_revoked": false,
}));
} else {
output.print_step(&format!("Already in {}", org.name));
}
}
struct Moved<'a> {
host: &'a str,
from: &'a Org,
to: &'a Org,
events_sent: u64,
events_dropped: Option<u64>,
applied_now: bool,
old_key_revoked: bool,
had_old_key: bool,
warning: Option<&'static str>,
}
fn render_moved(output: &OutputConfig, m: &Moved<'_>) {
if output.format == OutputFormat::Json {
output.print_json(&serde_json::json!({
"switched": true,
"from": org_json(m.from),
"to": org_json(m.to),
"events_sent": m.events_sent,
"events_dropped": m.events_dropped,
"old_key_revoked": m.old_key_revoked,
}));
if let Some(w) = m.warning {
output.print_note(&format!("Warning: {w}"));
}
return;
}
output.print_step(&format!(
"Moved {} from {} to {}",
m.host, m.from.name, m.to.name
));
match m.events_dropped {
Some(n) if n > 0 => output.print_substep(&format!("{n} queued events dropped")),
Some(_) => {}
None if !m.applied_now => {
output.print_substep("queued events will be dropped when the daemon next starts")
}
None => {}
}
if let Some(w) = m.warning {
output.print_note(&format!("Warning: {w}"));
}
if m.had_old_key && !m.old_key_revoked {
output.print_note("old key not revoked — revoke it in the console");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::auth::{retrieve_stored, InMemoryCredentialStore};
struct Refusing(InMemoryCredentialStore);
impl CredentialStore for Refusing {
fn store(&self, _key: SecretString) -> Result<(), OlError> {
Err(OlError::new("OL-TEST", "refused"))
}
fn retrieve(&self) -> Result<SecretString, OlError> {
self.0.retrieve()
}
fn delete(&self) -> Result<(), OlError> {
Err(OlError::new("OL-TEST", "refused"))
}
}
struct Undeletable(InMemoryCredentialStore);
impl CredentialStore for Undeletable {
fn store(&self, key: SecretString) -> Result<(), OlError> {
self.0.store(key)
}
fn retrieve(&self) -> Result<SecretString, OlError> {
self.0.retrieve()
}
fn delete(&self) -> Result<(), OlError> {
Err(OlError::new("OL-TEST", "refused"))
}
}
fn key(s: &str) -> SecretString {
SecretString::from(s.to_string())
}
fn stored(primary: &dyn CredentialStore, fallback: &dyn CredentialStore) -> Option<String> {
retrieve_stored(primary, fallback).map(|(k, _)| k.expose_secret().to_string())
}
#[test]
fn switch_store_goes_to_the_keychain_and_clears_the_file() {
let keychain = InMemoryCredentialStore::new();
let file = InMemoryCredentialStore::new();
file.store(key("old")).unwrap();
store_switching(&keychain, &file, &key("new")).expect("stored");
assert_eq!(keychain.retrieve().unwrap().expose_secret(), "new");
assert!(file.retrieve().is_err(), "the other tier is deleted");
}
struct ReadOnlyKeychain(InMemoryCredentialStore);
impl CredentialStore for ReadOnlyKeychain {
fn store(&self, _key: SecretString) -> Result<(), OlError> {
Err(OlError::new("OL-TEST", "refused"))
}
fn retrieve(&self) -> Result<SecretString, OlError> {
self.0.retrieve()
}
fn delete(&self) -> Result<(), OlError> {
self.0.delete()
}
}
#[test]
fn switch_store_falls_back_to_the_file_and_clears_a_stale_keychain_entry() {
let keychain = ReadOnlyKeychain(InMemoryCredentialStore::new());
keychain.0.store(key("old")).unwrap();
let file = InMemoryCredentialStore::new();
store_switching(&keychain, &file, &key("new")).expect("stored");
assert!(keychain.retrieve().is_err(), "the stale entry is gone");
assert_eq!(stored(&keychain, &file).as_deref(), Some("new"));
}
#[test]
fn switch_store_fails_when_a_stale_keychain_entry_cannot_be_cleared() {
let keychain = Refusing(InMemoryCredentialStore::new());
keychain.0.store(key("old")).unwrap();
let file = InMemoryCredentialStore::new();
let err = store_switching(&keychain, &file, &key("new")).expect_err("shadowed");
assert_eq!(err.suggestion.as_deref(), Some(NOTHING_CHANGED));
assert!(file.retrieve().is_err(), "the file tier is as it was");
assert_eq!(stored(&keychain, &file).as_deref(), Some("old"));
}
#[test]
fn switch_store_failure_restores_the_old_key_in_its_tier() {
let keychain = Refusing(InMemoryCredentialStore::new());
let file = Refusing(InMemoryCredentialStore::new());
file.0.store(key("old")).unwrap();
let err =
store_switching(&keychain, &file, &key("ol_secret_9f3a")).expect_err("both refuse");
assert!(
!err.message.contains("ol_secret_9f3a"),
"the key never reaches a message"
);
assert_eq!(err.suggestion.as_deref(), Some(NOTHING_CHANGED));
assert_eq!(stored(&keychain, &file).as_deref(), Some("old"));
}
#[test]
fn switch_store_failure_after_the_keychain_write_rolls_the_keychain_back() {
let keychain = InMemoryCredentialStore::new();
let file = Refusing(InMemoryCredentialStore::new());
file.0.store(key("old")).unwrap();
store_switching(&keychain, &file, &key("new")).expect_err("the file delete fails");
assert!(keychain.retrieve().is_err(), "the keychain is empty again");
assert_eq!(stored(&keychain, &file).as_deref(), Some("old"));
}
#[test]
fn switch_store_failure_reports_a_rollback_the_read_back_does_not_confirm() {
let keychain = Undeletable(InMemoryCredentialStore::new());
let file = Refusing(InMemoryCredentialStore::new());
file.0.store(key("old")).unwrap();
let err =
store_switching(&keychain, &file, &key("new")).expect_err("the file delete fails");
assert!(
err.message.contains("could not be restored"),
"the failed read-back is reported: {}",
err.message
);
assert_ne!(err.suggestion.as_deref(), Some(NOTHING_CHANGED));
}
}