use std::io;
use std::path::Path;
use std::sync::Arc;
use crate::platform::adapters::data::{DataAdapter, build_data_adapter};
use crate::platform::boot;
use crate::platform::model::{IDENTITY_WRITE_ACTION_CREATED, PlatformConfig, slug_segment};
use crate::platform::services::UserService;
use crate::platform::services::cluster::local_authority::{
OfficeJoinFile, OfficeLocalAuthorityService,
};
const CATALOG_REL: &str = "platform/catalog.db";
pub async fn run(args: &[String]) -> Result<(), io::Error> {
match args.first().map(String::as_str) {
Some("reset-password") => run_reset_password(&args[1..]).await,
Some("break-glass") => run_break_glass(&args[1..]).await,
Some("detach") => run_detach(&args[1..]).await,
_ => Err(io::Error::new(io::ErrorKind::InvalidInput, usage())),
}
}
fn usage() -> String {
let zeb = super::program();
format!(
"usage:\n \
{zeb} admin reset-password <owner>\n \
{zeb} admin break-glass [<owner>]\n \
{zeb} admin detach"
)
}
async fn run_reset_password(args: &[String]) -> Result<(), io::Error> {
let [owner] = args else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("usage: {} admin reset-password <owner>", super::program()),
));
};
let data_root = offline_data_root("reset").await?;
let password = reset_against(&data_root, owner)?;
println!("New password for {owner}: {password}");
println!(
"Printed once, not stored. The account is marked `generated` again, so the next \
browser login is forced to choose a new password. Sessions live only in a running \
server's memory, and none is running here."
);
Ok(())
}
async fn run_break_glass(args: &[String]) -> Result<(), io::Error> {
let owner = match args {
[] => None,
[owner] => Some(owner.clone()),
_ => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("usage: {} admin break-glass [<owner>]", super::program()),
));
}
};
let data_root = offline_data_root("break-glass").await?;
let data = open_data(&data_root)?;
let authority = OfficeLocalAuthorityService::new(data.clone(), data_root.clone());
let join = authority.join_file();
if let OfficeJoinFile::Absent = join {
return Err(io::Error::other(format!(
"{} has joined no controller, so its local authority was never disabled and there \
is nothing to break out of. Local login already works here; use `{} admin \
reset-password <owner>` if the password is what is missing.",
data_root.display(),
super::program()
)));
}
if let Some(owner) = owner.as_deref() {
let users = UserService::new(data.clone());
if users.get_user(owner).map_err(to_io)?.is_none() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!(
"no local account '{owner}' on {}. Run without an owner to re-enable local \
authority on its own.",
data_root.display()
),
));
}
refuse_controller_created(data.as_ref(), owner)?;
}
let detail = match owner.as_deref() {
Some(owner) => format!(
"local authority re-enabled from the host; the password of local account '{owner}' \
was rotated in the same act"
),
None => "local authority re-enabled from the host".to_string(),
};
let event = authority
.break_glass(owner.as_deref().unwrap_or_default(), &detail)
.map_err(to_io)?;
let rotated = match owner.as_deref() {
Some(owner) => Some((
owner,
UserService::new(data.clone())
.reset_password_generated(owner)
.map_err(to_io)?,
)),
None => None,
};
println!(
"Local authority re-enabled on {} (office '{}').",
data_root.display(),
event.office_id
);
match rotated {
Some((owner, password)) => {
println!("New password for {owner}: {password}");
println!(
"Printed once, not stored. The account is marked `generated` again, so the next \
browser login is forced to choose a new password."
);
}
None => {
println!(
"No password was changed. Run `{} admin reset-password <owner>` if you also \
need a credential.",
super::program()
);
}
}
println!(
"This office is still joined: the controller may still place projects here and still \
vouches for identities here. `{} admin detach` is what leaves.",
super::program()
);
println!(
"Recorded locally as {} and readable at GET /api/platform/office/local-authority by this \
office's own superadmin. It is reported to the controller on the next successful \
registration; until then it stays here, and if this office never reconnects it stays \
here for good.",
event.event_id
);
Ok(())
}
async fn run_detach(args: &[String]) -> Result<(), io::Error> {
if !args.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("usage: {} admin detach", super::program()),
));
}
let data_root = offline_data_root("detach").await?;
let data = open_data(&data_root)?;
let authority = OfficeLocalAuthorityService::new(data, data_root.clone());
let office_id = authority.join_file().office_id().to_string();
let event = authority
.detach("left the controller from the host")
.map_err(to_io)?;
println!(
"Detached {} from its controller{}.",
data_root.display(),
if office_id.is_empty() {
String::new()
} else {
format!(" (office '{office_id}')")
}
);
println!(
"Nothing was removed but the join token. Projects, data/, files/, the blessed shelf, \
and the public surface are unchanged, and local accounts are live again — they were \
disabled, never deleted."
);
println!(
"Unset ZEBFLOW_CLUSTER_JOIN_TOKEN before starting this instance again, or the variable \
will join it straight back. Ask the controller to revoke this office's token as well: \
detaching is this instance's act and the controller still holds the record."
);
println!("Recorded locally as {}.", event.event_id);
Ok(())
}
fn refuse_controller_created(data: &dyn DataAdapter, owner: &str) -> Result<(), io::Error> {
let owner = slug_segment(owner);
let created_by_controller = data
.office_identity_write_exists(&owner, IDENTITY_WRITE_ACTION_CREATED)
.map_err(to_io)?;
if created_by_controller {
return Err(io::Error::other(format!(
"'{owner}' exists on this office only because its controller vouched for it. That \
account has no usable password by construction and is reachable by vouch and by \
nothing else; re-enabling local authority does not change it. Break the glass on \
one of this office's own local accounts instead."
)));
}
Ok(())
}
async fn offline_data_root(act: &str) -> Result<std::path::PathBuf, io::Error> {
let url = boot::local_instance_url();
if super::local::listening(&url).await {
return Err(io::Error::other(format!(
"a server is answering at {url} and owns this data root. Stop it first — this \
{act} writes to the catalog the running server holds open, and every live session \
lives in its memory."
)));
}
let data_root = boot::default_data_root();
if !data_root.join(CATALOG_REL).exists() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!(
"no instance at {} (missing {CATALOG_REL}); nothing to {act}",
data_root.display()
),
));
}
Ok(data_root)
}
fn open_data(data_root: &Path) -> Result<Arc<dyn DataAdapter>, io::Error> {
build_data_adapter(PlatformConfig::default().data_adapter, data_root)
.map_err(|err| io::Error::other(err.message))
}
fn to_io(err: crate::platform::error::PlatformError) -> io::Error {
io::Error::other(err.message)
}
fn reset_against(data_root: &Path, owner: &str) -> Result<String, io::Error> {
let data = open_data(data_root)?;
UserService::new(data)
.reset_password_generated(owner)
.map_err(to_io)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::platform::model::{DataAdapterKind, PlatformOfficeIdentityWrite, now_ts};
fn temp_root(name: &str) -> (tempfile::TempDir, std::path::PathBuf) {
let tmp = tempfile::Builder::new()
.prefix(&format!("zebflow-admin-{name}-"))
.tempdir()
.expect("tempdir");
let root = tmp.path().to_path_buf();
(tmp, root)
}
fn write(data: &dyn DataAdapter, id: &str, owner: &str, action: &str, at: i64) {
data.put_office_identity_write(&PlatformOfficeIdentityWrite {
write_id: id.to_string(),
office_id: "office-a".to_string(),
owner: owner.to_string(),
action: action.to_string(),
role: "superadmin".to_string(),
source: "controller-vouch".to_string(),
detail: String::new(),
written_at: at,
})
.expect("identity write");
}
#[test]
fn a_controller_created_account_is_refused_however_long_the_log_grows() {
let (_tmp, root) = temp_root("deep-identity-log");
let data = build_data_adapter(DataAdapterKind::Sqlite, &root).expect("adapter");
let now = now_ts();
write(
data.as_ref(),
"created",
"remote-admin",
IDENTITY_WRITE_ACTION_CREATED,
now,
);
refuse_controller_created(data.as_ref(), "local-admin").expect("a local account is fine");
let err = refuse_controller_created(data.as_ref(), "remote-admin")
.expect_err("a controller-created account is refused");
assert!(err.to_string().contains("reachable by vouch"), "{err}");
for index in 0..1_500 {
write(
data.as_ref(),
&format!("later-{index}"),
"someone-else",
"linked",
now + 1 + index,
);
}
let err = refuse_controller_created(data.as_ref(), "remote-admin")
.expect_err("still refused past any window");
assert!(err.to_string().contains("reachable by vouch"), "{err}");
refuse_controller_created(data.as_ref(), "local-admin")
.expect("a local account is still eligible");
}
}