use std::io::Write;
use sqlx::Database;
use crate::core::Column as _;
use crate::sql::{Auto, FetcherPool, UpdaterPool as _};
use crate::tenancy::error::TenancyError;
#[cfg(feature = "postgres")]
use crate::tenancy::manage::args::quote_ident;
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"];
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 mut user = crate::tenancy::User {
id: Auto::default(),
username: username.clone(),
password_hash: hash,
#[cfg(feature = "sso")]
email: None,
is_superuser,
active: true,
created_at: chrono::Utc::now(),
data: serde_json::Value::Object(serde_json::Map::new()),
password_changed_at: None,
sessions_revoked_at: None,
};
user.save_pool(&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(())
}
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>] [--password <s> | --password-stdin | --generate] [--skip-validation]";
let parsed = parse(
args,
&Spec {
verb: "change-password",
usage: USAGE,
switches: &PASSWORD_SWITCHES,
valued: &["--current", "--password"],
max_positionals: 2,
},
)?;
let slug = parsed.required(0, USAGE)?;
let username = parsed.required(1, USAGE)?;
let current = parsed.value("--current")?.map(str::to_owned);
let cur_plain = match current {
Some(p) => p,
None => manage_interactive::ask_password("Current password: ")
.map_err(TenancyError::Io)?
.ok_or_else(|| {
TenancyError::Validation("change-password requires --current (or a TTY)".into())
})?,
};
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>] [--password <s> | --password-stdin | --generate] [--skip-validation]";
let parsed = parse(
args,
&Spec {
verb: "change-operator-password",
usage: USAGE,
switches: &PASSWORD_SWITCHES,
valued: &["--current", "--password"],
max_positionals: 1,
},
)?;
let username = parsed.required(0, USAGE)?;
let current = parsed.value("--current")?.map(str::to_owned);
let cur_plain = match current {
Some(p) => p,
None => manage_interactive::ask_password("Current password: ")
.map_err(TenancyError::Io)?
.ok_or_else(|| {
TenancyError::Validation(
"change-operator-password requires --current (or a TTY)".into(),
)
})?,
};
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 = build_schema_scoped_pool(registry_url, org.effective_schema()).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")
}
}
}
}
}
#[cfg(feature = "postgres")]
async fn build_schema_scoped_pool(
registry_url: &str,
schema: &str,
) -> Result<rustango::sql::sqlx::PgPool, TenancyError> {
use crate::sql::sqlx::postgres::PgPoolOptions;
use std::sync::Arc;
let schema_owned: Arc<str> = Arc::from(schema);
let pool = PgPoolOptions::new()
.max_connections(1)
.after_connect(move |conn, _meta| {
let schema = Arc::clone(&schema_owned);
Box::pin(async move {
let stmt = format!("SET search_path TO {}, public", quote_ident(&schema));
rustango::sql::sqlx::query(&stmt).execute(conn).await?;
Ok(())
})
})
.connect(registry_url)
.await?;
Ok(pool)
}