use std::io::Write;
use sqlx::Database;
use crate::core::Column as _;
use crate::sql::{Auto, FetcherPool, UpdaterPool as _};
use crate::tenancy::error::TenancyError;
use crate::tenancy::manage::args::{parse, Parsed, Spec};
use crate::tenancy::manage_interactive;
use crate::tenancy::pools::TenantPools;
const PASSWORD_SWITCHES: [&str; 3] = ["--generate", "--password-stdin", "--skip-validation"];
const CHANGE_PASSWORD_SWITCHES: [&str; 4] = [
"--generate",
"--password-stdin",
"--skip-validation",
"--current-stdin",
];
fn current_password(verb: &str, parsed: &Parsed) -> Result<String, TenancyError> {
use manage_interactive::NewPasswordError;
let current = parsed.value("--current")?.map(str::to_owned);
manage_interactive::read_current_password(current, parsed.has("--current-stdin")).map_err(|e| {
match e {
NewPasswordError::Io(e) => TenancyError::Io(e),
e => TenancyError::Validation(format!("{verb}: {e}")),
}
})
}
fn new_password(verb: &str, parsed: &Parsed, prompt: &str) -> Result<(String, bool), TenancyError> {
use manage_interactive::{NewPasswordError, PasswordInput};
let password = parsed.value("--password")?.map(str::to_owned);
let stdin = parsed.has("--password-stdin");
if parsed.has("--generate") {
if password.is_some() || stdin {
return Err(TenancyError::Validation(format!(
"{verb}: --generate and --password are mutually exclusive"
)));
}
return Ok((crate::tenancy::password::generate(20), true));
}
let err = |e: NewPasswordError| match e {
NewPasswordError::Io(e) => TenancyError::Io(e),
e => TenancyError::Validation(format!("{verb}: {e}")),
};
let input = PasswordInput::from_flags(password, stdin).map_err(err)?;
manage_interactive::read_new_password(input, parsed.has("--skip-validation"), prompt)
.map(|p| (p, false))
.map_err(err)
}
pub(super) async fn create_operator_cmd<W: Write + Send, DB: Database>(
pools: &TenantPools<DB>,
args: &[String],
w: &mut W,
) -> Result<(), TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
const USAGE: &str = "create-operator <username> [--password <p> | --password-stdin | --generate] [--skip-validation]";
let parsed = parse(
args,
&Spec {
verb: "create-operator",
usage: USAGE,
switches: &PASSWORD_SWITCHES,
valued: &["--password"],
max_positionals: 1,
},
)?;
let username_arg = parsed.positional(0).cloned();
let username = match username_arg {
Some(u) => u,
None => manage_interactive::ask("Username: ")
.map_err(TenancyError::Io)?
.ok_or_else(|| {
TenancyError::Validation(
"create-operator requires a username positional argument".into(),
)
})?,
};
let (plain, generated) = new_password("create-operator", &parsed, "Password: ")?;
let registry = pools.registry_pool();
let existing: Vec<crate::tenancy::Operator> = crate::tenancy::Operator::objects()
.where_(crate::tenancy::Operator::username.eq(username.clone()))
.fetch(®istry)
.await?;
if !existing.is_empty() {
return Err(TenancyError::Validation(format!(
"operator `{username}` already exists in the registry"
)));
}
let mut op = crate::tenancy::Operator {
id: Auto::default(),
username: username.clone(),
password_hash: crate::tenancy::password::hash_async(&plain).await?,
active: true,
created_at: chrono::Utc::now(),
password_changed_at: None,
sessions_revoked_at: None,
};
op.insert_pool(®istry).await?;
let id = op.id.get().copied().unwrap_or_default();
if generated {
writeln!(w, "created operator `{username}` (id {id})")?;
writeln!(w, " generated password: {plain}")?;
writeln!(w, " store this safely — it won't be shown again")?;
} else {
writeln!(w, "created operator `{username}` (id {id})")?;
}
Ok(())
}
pub(super) async fn create_user_cmd<W: Write + Send, DB: Database>(
pools: &TenantPools<DB>,
registry_url: &str,
args: &[String],
w: &mut W,
) -> Result<(), TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
let parsed = parse(
args,
&Spec {
verb: "create-user",
usage: "create-user <slug> <username> [--password <p> | --password-stdin | --generate] [--skip-validation] [--superuser]",
switches: &["--generate", "--password-stdin", "--skip-validation", "--superuser"],
valued: &["--password"],
max_positionals: 2,
},
)?;
let slug_arg = parsed.positional(0).cloned();
let username_arg = parsed.positional(1).cloned();
let mut is_superuser = parsed.has("--superuser");
let slug = match slug_arg {
Some(s) => s,
None => manage_interactive::ask("Tenant slug: ")
.map_err(TenancyError::Io)?
.ok_or_else(|| {
TenancyError::Validation(
"create-user requires a tenant slug as the first positional argument".into(),
)
})?,
};
let username = match username_arg {
Some(u) => u,
None => manage_interactive::ask("Username: ")
.map_err(TenancyError::Io)?
.ok_or_else(|| {
TenancyError::Validation(
"create-user requires a username as the second positional argument".into(),
)
})?,
};
let (plain, generated) = new_password("create-user", &parsed, "Password: ")?;
let registry = pools.registry_pool();
let orgs: Vec<crate::tenancy::Org> = crate::tenancy::Org::objects()
.where_(crate::tenancy::Org::slug.eq(slug.clone()))
.fetch(®istry)
.await?;
orgs.into_iter().next().ok_or_else(|| {
TenancyError::Validation(format!("create-user: no tenant with slug `{slug}`"))
})?;
let hash = crate::tenancy::password::hash_async(&plain).await?;
use crate::sql::FetcherPool as _;
let scoped = scoped_tenant_pool(pools, registry_url, &slug).await?;
let mut auto_promoted = false;
if !is_superuser {
let existing: Vec<crate::tenancy::User> = crate::tenancy::User::objects()
.limit(1)
.fetch(&scoped)
.await?;
if existing.is_empty() {
is_superuser = true;
auto_promoted = true;
}
}
let user = crate::tenancy::auth::NewUser {
username: &username,
password_hash: hash,
is_superuser,
email: None,
}
.insert(&scoped)
.await?;
let row_id: i64 = user.id.get().copied().unwrap_or_default();
if auto_promoted {
writeln!(
w,
"created user `{username}` in tenant `{slug}` (id {row_id}, superuser=true) — \
auto-promoted because they're the first user of the tenant; pass `--superuser` \
explicitly to silence this notice on subsequent setups"
)?;
} else {
writeln!(
w,
"created user `{username}` in tenant `{slug}` (id {row_id}, superuser={is_superuser})"
)?;
}
if generated {
writeln!(w, " generated password: {plain}")?;
writeln!(w, " store this safely — it won't be shown again")?;
}
Ok(())
}
pub(super) async fn create_superuser_cmd<W: Write + Send, DB: Database>(
pools: &TenantPools<DB>,
registry_url: &str,
args: &[String],
w: &mut W,
) -> Result<(), TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
let mut forwarded: Vec<String> = args.to_vec();
if !forwarded.iter().any(|s| s == "--superuser") {
forwarded.push("--superuser".into());
}
create_user_cmd(pools, registry_url, &forwarded, w).await
}
pub(super) async fn set_superuser_cmd<W: Write + Send, DB: Database>(
pools: &TenantPools<DB>,
registry_url: &str,
args: &[String],
w: &mut W,
) -> Result<(), TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
const USAGE: &str = "set-superuser <slug> <username> [--on|--off]";
let parsed = parse(
args,
&Spec {
verb: "set-superuser",
usage: USAGE,
switches: &["--on", "--off"],
valued: &[],
max_positionals: 2,
},
)?;
let slug = parsed.required(0, USAGE)?;
let username = parsed.required(1, USAGE)?;
let on = parsed.on_off()?.unwrap_or(true);
let pool = scoped_tenant_pool(pools, registry_url, &slug).await?;
use crate::tenancy::User;
let affected = User::objects()
.where_(User::username.eq(username.clone()))
.update()
.set_typed(User::is_superuser.set(on))
.execute_pool(&pool)
.await
.map_err(|e| TenancyError::Validation(format!("set-superuser: {e}")))?;
if affected == 0 {
return Err(TenancyError::Validation(format!(
"set-superuser: no user `{username}` in tenant `{slug}`"
)));
}
writeln!(
w,
"set is_superuser={on} on user `{username}` in tenant `{slug}`"
)?;
Ok(())
}
#[cfg(feature = "passkey")]
pub(super) async fn revoke_passkey_cmd<W: Write + Send, DB: Database>(
pools: &TenantPools<DB>,
registry_url: &str,
args: &[String],
w: &mut W,
) -> Result<(), TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
const USAGE: &str = "revoke-passkey <slug> <username> [--credential <id>]";
let parsed = parse(
args,
&Spec {
verb: "revoke-passkey",
usage: USAGE,
switches: &[],
valued: &["--credential"],
max_positionals: 2,
},
)?;
let slug = parsed.required(0, USAGE)?;
let username = parsed.required(1, USAGE)?;
let pool = scoped_tenant_pool(pools, registry_url, &slug).await?;
let fail = |e: crate::sql::ExecError| TenancyError::Validation(format!("revoke-passkey: {e}"));
use crate::tenancy::User;
let user = User::objects()
.where_(User::username.eq(username.clone()))
.fetch(&pool)
.await
.map_err(fail)?
.into_iter()
.next()
.ok_or_else(|| {
TenancyError::Validation(format!(
"revoke-passkey: no user `{username}` in tenant `{slug}`"
))
})?;
let uid = user.id.get().copied().unwrap_or_default();
match parsed.value("--credential")? {
Some(cred) => {
if !crate::passkey::revoke::<User>(&pool, uid, cred)
.await
.map_err(fail)?
{
return Err(TenancyError::Validation(format!(
"revoke-passkey: user `{username}` has no passkey `{cred}`"
)));
}
writeln!(w, "revoked passkey `{cred}` of user `{username}` in tenant `{slug}` and ended their sessions")?;
}
None => {
let n = crate::passkey::revoke_all::<User>(&pool, uid)
.await
.map_err(fail)?;
writeln!(w, "revoked {n} passkey(s) of user `{username}` in tenant `{slug}` and ended their sessions")?;
}
}
Ok(())
}
pub(super) async fn reset_password_cmd<W: Write + Send, DB: Database>(
pools: &TenantPools<DB>,
registry_url: &str,
args: &[String],
w: &mut W,
) -> Result<(), TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
const USAGE: &str = "reset-password <slug> <username> [--password <s> | --password-stdin | --generate] [--skip-validation]";
let parsed = parse(
args,
&Spec {
verb: "reset-password",
usage: USAGE,
switches: &PASSWORD_SWITCHES,
valued: &["--password"],
max_positionals: 2,
},
)?;
let slug = parsed.required(0, USAGE)?;
let username = parsed.required(1, USAGE)?;
let (plain, generated) = new_password("reset-password", &parsed, "New password: ")?;
let hash = crate::tenancy::password::hash_async(&plain).await?;
let pool = scoped_tenant_pool(pools, registry_url, &slug).await?;
use crate::tenancy::User;
let affected = User::objects()
.where_(User::username.eq(username.clone()))
.update()
.set_typed(User::password_hash.set(hash))
.set_typed(User::password_changed_at.set(Some(chrono::Utc::now())))
.execute_pool(&pool)
.await
.map_err(|e| TenancyError::Validation(format!("reset-password: {e}")))?;
if affected == 0 {
return Err(TenancyError::Validation(format!(
"reset-password: no user `{username}` in tenant `{slug}`"
)));
}
writeln!(w, "password reset for user `{username}` in tenant `{slug}`")?;
if generated {
writeln!(w, " generated password: {plain}")?;
writeln!(w, " store this safely — it won't be shown again")?;
}
Ok(())
}
pub(super) async fn reset_operator_password_cmd<W: Write + Send, DB: Database>(
pools: &TenantPools<DB>,
args: &[String],
w: &mut W,
) -> Result<(), TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
const USAGE: &str = "reset-operator-password <username> [--password <s> | --password-stdin | --generate] [--skip-validation]";
let parsed = parse(
args,
&Spec {
verb: "reset-operator-password",
usage: USAGE,
switches: &PASSWORD_SWITCHES,
valued: &["--password"],
max_positionals: 1,
},
)?;
let username = parsed.required(0, USAGE)?;
let (plain, generated) = new_password("reset-operator-password", &parsed, "New password: ")?;
let hash = crate::tenancy::password::hash_async(&plain).await?;
let registry = pools.registry_pool();
let existing: Vec<crate::tenancy::Operator> = crate::tenancy::Operator::objects()
.where_(crate::tenancy::Operator::username.eq(username.clone()))
.fetch(®istry)
.await?;
let op = existing.into_iter().next().ok_or_else(|| {
TenancyError::Validation(format!(
"reset-operator-password: no operator named `{username}`"
))
})?;
if !crate::passwords::store_password_change(
®istry,
<crate::tenancy::Operator as crate::core::Model>::SCHEMA,
&op.id,
&op.password_hash,
&hash,
)
.await?
{
return Err(TenancyError::Validation(format!(
"reset-operator-password: `{username}`'s password changed meanwhile; try again"
)));
}
writeln!(w, "password reset for operator `{username}`")?;
if generated {
writeln!(w, " generated password: {plain}")?;
writeln!(w, " store this safely — it won't be shown again")?;
}
Ok(())
}
pub(super) async fn change_password_cmd<W: Write + Send, DB: Database>(
pools: &TenantPools<DB>,
registry_url: &str,
args: &[String],
w: &mut W,
) -> Result<(), TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
const USAGE: &str =
"change-password <slug> <username> [--current <s> | --current-stdin] [--password <s> | --password-stdin | --generate] [--skip-validation]";
let parsed = parse(
args,
&Spec {
verb: "change-password",
usage: USAGE,
switches: &CHANGE_PASSWORD_SWITCHES,
valued: &["--current", "--password"],
max_positionals: 2,
},
)?;
let slug = parsed.required(0, USAGE)?;
let username = parsed.required(1, USAGE)?;
let cur_plain = current_password("change-password", &parsed)?;
let (new_plain, generated) = new_password("change-password", &parsed, "New password: ")?;
let pool = scoped_tenant_pool(pools, registry_url, &slug).await?;
use crate::sql::FetcherPool as _;
let users: Vec<crate::tenancy::User> = crate::tenancy::User::objects()
.where_(crate::tenancy::User::username.eq(username.clone()))
.fetch(&pool)
.await?;
let Some(user) = users.into_iter().next() else {
return Err(TenancyError::Validation(format!(
"change-password: no user `{username}` in tenant `{slug}`"
)));
};
if !crate::tenancy::password::verify_async(&cur_plain, &user.password_hash).await? {
return Err(TenancyError::Validation(
"change-password: current password did not match".into(),
));
}
let hash = crate::tenancy::password::hash_async(&new_plain).await?;
if !crate::passwords::store_password_change(
&pool,
<crate::tenancy::User as crate::core::Model>::SCHEMA,
&user.id,
&user.password_hash,
&hash,
)
.await?
{
return Err(TenancyError::Validation(format!(
"change-password: `{username}`'s password changed meanwhile; try again"
)));
}
writeln!(
w,
"password changed for user `{username}` in tenant `{slug}`"
)?;
if generated {
writeln!(w, " generated password: {new_plain}")?;
writeln!(w, " store this safely — it won't be shown again")?;
}
Ok(())
}
pub(super) async fn change_operator_password_cmd<W: Write + Send, DB: Database>(
pools: &TenantPools<DB>,
args: &[String],
w: &mut W,
) -> Result<(), TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
const USAGE: &str =
"change-operator-password <username> [--current <s> | --current-stdin] [--password <s> | --password-stdin | --generate] [--skip-validation]";
let parsed = parse(
args,
&Spec {
verb: "change-operator-password",
usage: USAGE,
switches: &CHANGE_PASSWORD_SWITCHES,
valued: &["--current", "--password"],
max_positionals: 1,
},
)?;
let username = parsed.required(0, USAGE)?;
let cur_plain = current_password("change-operator-password", &parsed)?;
let (new_plain, generated) =
new_password("change-operator-password", &parsed, "New password: ")?;
let registry = pools.registry_pool();
let existing: Vec<crate::tenancy::Operator> = crate::tenancy::Operator::objects()
.where_(crate::tenancy::Operator::username.eq(username.clone()))
.fetch(®istry)
.await?;
let op = existing.into_iter().next().ok_or_else(|| {
TenancyError::Validation(format!(
"change-operator-password: no operator named `{username}`"
))
})?;
if !crate::tenancy::password::verify_async(&cur_plain, &op.password_hash).await? {
return Err(TenancyError::Validation(
"change-operator-password: current password did not match".into(),
));
}
let hash = crate::tenancy::password::hash_async(&new_plain).await?;
if !crate::passwords::store_password_change(
®istry,
<crate::tenancy::Operator as crate::core::Model>::SCHEMA,
&op.id,
&op.password_hash,
&hash,
)
.await?
{
return Err(TenancyError::Validation(format!(
"change-operator-password: `{username}`'s password changed meanwhile; try again"
)));
}
writeln!(w, "password changed for operator `{username}`")?;
if generated {
writeln!(w, " generated password: {new_plain}")?;
writeln!(w, " store this safely — it won't be shown again")?;
}
Ok(())
}
pub(super) async fn scoped_tenant_pool<DB: Database>(
pools: &TenantPools<DB>,
registry_url: &str,
slug: &str,
) -> Result<rustango::sql::Pool, TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
let orgs: Vec<crate::tenancy::Org> = crate::tenancy::Org::objects()
.where_(crate::tenancy::Org::slug.eq(slug.to_owned()))
.fetch(&pools.registry_pool())
.await?;
let org = orgs
.into_iter()
.next()
.ok_or_else(|| TenancyError::Validation(format!("no tenant with slug `{slug}`")))?;
use crate::tenancy::org::StorageMode;
let mode = StorageMode::parse(&org.storage_mode).map_err(|got| {
TenancyError::Validation(format!("org `{slug}` has unknown storage_mode `{got}`"))
})?;
match mode {
StorageMode::Schema => {
#[cfg(feature = "postgres")]
{
let pg = crate::tenancy::pools::connect_schema_scoped(
registry_url,
org.effective_schema(),
1,
)
.await?;
Ok(rustango::sql::Pool::Postgres(pg))
}
#[cfg(not(feature = "postgres"))]
{
let _ = registry_url;
Err(TenancyError::Validation(format!(
"tenant `{slug}` is schema-mode but `postgres` feature is off"
)))
}
}
StorageMode::Database => {
let tp = pools.database_pool_for_org(&org).await?;
match tp {
crate::tenancy::TenantPool::Database { pool } => {
Ok(rustango::sql::Pool::from((*pool).clone()))
}
#[cfg(feature = "postgres")]
crate::tenancy::TenantPool::Schema { .. } => {
unreachable!("database_pool_for_org rejects schema-mode")
}
}
}
}
}