use std::path::Path;
use sqlx::Database;
use crate::core::Column as _;
use crate::migrate::file::Migration;
use crate::sql::{Auto, FetcherPool};
use super::error::TenancyError;
use super::migrate as tenant_migrate;
use super::org::{BackendKind, Org, StorageMode};
use super::pools::TenantPools;
use super::preflight;
#[derive(Debug, Clone)]
pub struct ProvisionRequest {
pub slug: String,
pub mode: StorageMode,
pub backend: BackendKind,
pub display_name: Option<String>,
pub database_url: Option<String>,
pub schema_name: Option<String>,
pub host_pattern: Option<String>,
pub port: Option<i32>,
pub path_prefix: Option<String>,
pub run_migrations: bool,
pub preflight: preflight::Preflight,
}
impl ProvisionRequest {
#[must_use]
pub fn database(slug: impl Into<String>, database_url: impl Into<String>) -> Self {
Self {
slug: slug.into(),
mode: StorageMode::Database,
backend: BackendKind::default(),
display_name: None,
database_url: Some(database_url.into()),
schema_name: None,
host_pattern: None,
port: None,
path_prefix: None,
run_migrations: true,
preflight: preflight::Preflight::default(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProvisionStep {
Validate,
CheckConnection,
ProvisionStorage,
RegisterOrg,
Migrate,
Activate,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StepStatus {
Started,
Ok,
Skipped(String),
Failed(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProvisionEvent {
Step {
step: ProvisionStep,
status: StepStatus,
},
Registered { org_id: i64 },
Migration(tenant_migrate::TenantMigrationEvent),
}
pub trait ProvisionObserver: Send + Sync {
fn on_event(&self, event: ProvisionEvent);
}
impl<F> ProvisionObserver for F
where
F: Fn(ProvisionEvent) + Send + Sync,
{
fn on_event(&self, event: ProvisionEvent) {
self(event);
}
}
#[derive(Debug, Clone)]
pub enum MigrationsOutcome {
Skipped,
NotMatched,
Applied(Vec<Migration>),
Failed(String),
}
#[derive(Debug, Clone)]
pub struct ProvisionOutcome {
pub org_id: i64,
pub slug: String,
pub mode: StorageMode,
pub migrations: MigrationsOutcome,
}
pub(super) struct Reporter<'a> {
observer: Option<&'a dyn ProvisionObserver>,
store: Option<RunStore<'a>>,
resume: bool,
}
struct RunStore<'a> {
registry: &'a crate::sql::Pool,
run_id: i64,
seq: std::sync::atomic::AtomicI64,
buffered: std::sync::Mutex<Vec<(String, String, String)>>,
}
impl<'a> Reporter<'a> {
pub(super) fn new(observer: Option<&'a dyn ProvisionObserver>) -> Self {
Self {
observer,
store: None,
resume: false,
}
}
pub(super) fn persisting(mut self, registry: &'a crate::sql::Pool, run_id: i64) -> Self {
self.store = Some(RunStore {
registry,
run_id,
seq: std::sync::atomic::AtomicI64::new(1),
buffered: std::sync::Mutex::new(Vec::new()),
});
self
}
async fn registered(&self, org_id: i64) {
let Some(store) = &self.store else {
return;
};
let attach = || super::provision_store::attach_org(store.registry, store.run_id, org_id);
if let Err(e) = match attach().await {
Err(_) => attach().await,
ok => ok,
} {
tracing::warn!(target: "rustango::tenancy::provision", error = %e, "could not attach org id to run");
}
}
fn notify(&self, event: ProvisionEvent) {
if let Some(observer) = self.observer {
observer.on_event(event);
}
}
async fn write(&self, step: &str, status: &str, message: &str) {
let Some(store) = &self.store else {
return;
};
let seq = store.seq.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if let Err(e) = super::provision_store::append_event(
store.registry,
store.run_id,
seq,
step,
status,
message,
)
.await
{
tracing::warn!(
target: "rustango::tenancy::provision",
run_id = store.run_id,
error = %e,
"could not record a provisioning event; the run continues"
);
}
}
fn buffer(&self, step: &str, status: &str, message: &str) {
let Some(store) = &self.store else {
return;
};
if let Ok(mut buf) = store.buffered.lock() {
buf.push((step.to_owned(), status.to_owned(), message.to_owned()));
}
}
async fn flush(&self) {
let Some(store) = &self.store else {
return;
};
let pending = match store.buffered.lock() {
Ok(mut buf) => std::mem::take(&mut *buf),
Err(_) => return,
};
for (step, status, message) in pending {
self.write(&step, &status, &message).await;
}
}
}
impl Reporter<'_> {
async fn step(&self, step: ProvisionStep, status: StepStatus) {
self.write(step.as_str(), status.as_str(), status.detail())
.await;
self.notify(ProvisionEvent::Step { step, status });
}
async fn fail<T>(&self, at: ProvisionStep, e: TenancyError) -> Result<T, TenancyError> {
self.step(at, StepStatus::Failed(e.to_string())).await;
Err(e)
}
fn migration(&self, event: tenant_migrate::TenantMigrationEvent) {
self.buffer("migration", "info", &render_migration(&event));
self.notify(ProvisionEvent::Migration(event));
}
}
impl ProvisionStep {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Validate => "validate",
Self::CheckConnection => "check_connection",
Self::ProvisionStorage => "provision_storage",
Self::RegisterOrg => "register_org",
Self::Migrate => "migrate",
Self::Activate => "activate",
}
}
}
impl StepStatus {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Started => "started",
Self::Ok => "ok",
Self::Skipped(_) => "skipped",
Self::Failed(_) => "failed",
}
}
#[must_use]
pub fn detail(&self) -> &str {
match self {
Self::Started | Self::Ok => "",
Self::Skipped(why) | Self::Failed(why) => why,
}
}
}
fn render_migration(event: &tenant_migrate::TenantMigrationEvent) -> String {
use crate::migrate::{MigrationEvent as M, Outcome};
use tenant_migrate::{Chain, TenantMigrationEvent as E};
let chain_tag = |c: Chain| match c {
Chain::System => "system",
Chain::Project => "app",
};
match event {
E::Planned { tenants } => format!("migrating {tenants} tenant(s)"),
E::TenantStarted { slug, .. } => format!("tenant {slug}"),
E::TenantFinished {
slug,
applied,
error,
..
} => match error {
Some(e) => format!("tenant {slug} failed: {e}"),
None => format!("tenant {slug}: {applied} migration(s)"),
},
E::Migration { chain, event, .. } => match event {
M::Planned { total } => format!("{}: {total} pending", chain_tag(*chain)),
M::Started { name, .. } => format!("{}/{name} started", chain_tag(*chain)),
M::Finished {
name,
outcome,
elapsed,
..
} => {
let verb = match outcome {
Outcome::Ran => "applied",
Outcome::RanPartial { .. } => "applied (partial)",
Outcome::Faked => "faked",
};
format!(
"{verb} {}/{name} ({:.1}s)",
chain_tag(*chain),
elapsed.as_secs_f64()
)
}
M::Failed { name, error, .. } => {
format!("{}/{name} failed: {error}", chain_tag(*chain))
}
},
}
}
pub async fn provision_tenant<DB: Database>(
pools: &TenantPools<DB>,
registry_url: &str,
dir: &Path,
request: &ProvisionRequest,
observer: Option<&dyn ProvisionObserver>,
) -> Result<ProvisionOutcome, TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
let rep = Reporter::new(observer);
provision_reported(pools, registry_url, dir, request, &rep).await
}
pub async fn provision_tenant_recorded<DB: Database>(
pools: &TenantPools<DB>,
registry_url: &str,
dir: &Path,
request: &ProvisionRequest,
observer: Option<&dyn ProvisionObserver>,
requested_by: Option<&str>,
idempotency_key: Option<&str>,
) -> Result<(super::provision_store::ProvisioningRun, ProvisionOutcome), TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
use super::provision_store as store;
let registry = pools.registry_pool();
let run = store::open_run(
®istry,
&request.slug,
request.mode.as_str(),
request.backend.as_str(),
request.database_url.as_deref(),
requested_by,
idempotency_key,
)
.await?;
let run_id = run.id.get().copied().unwrap_or_default();
let outcome = in_run(pools, registry_url, dir, request, observer, run_id, false)
.await
?;
let refreshed = store::run_by_id(®istry, run_id).await?.unwrap_or(run);
Ok((refreshed, outcome))
}
pub async fn provision_tenant_in_run<DB: Database>(
pools: &TenantPools<DB>,
registry_url: &str,
dir: &Path,
request: &ProvisionRequest,
observer: Option<&dyn ProvisionObserver>,
run_id: i64,
) -> Result<ProvisionOutcome, TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
in_run(pools, registry_url, dir, request, observer, run_id, true).await
}
async fn in_run<DB: Database>(
pools: &TenantPools<DB>,
registry_url: &str,
dir: &Path,
request: &ProvisionRequest,
observer: Option<&dyn ProvisionObserver>,
run_id: i64,
resume: bool,
) -> Result<ProvisionOutcome, TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
use super::provision_store::{self as store, RunState};
let registry = pools.registry_pool();
let mut rep = Reporter::new(observer).persisting(®istry, run_id);
rep.resume = resume;
let result = provision_reported(pools, registry_url, dir, request, &rep).await;
let (state, error) = match &result {
Ok(outcome) => match &outcome.migrations {
MigrationsOutcome::Failed(e) => (RunState::Failed, Some(e.clone())),
_ => (RunState::Succeeded, None),
},
Err(e) => (RunState::Failed, Some(e.to_string())),
};
if let Err(e) = store::finish_run(®istry, run_id, state, error.as_deref()).await {
tracing::warn!(target: "rustango::tenancy::provision", error = %e, "could not close provisioning run");
}
result
}
async fn provision_reported<DB: Database>(
pools: &TenantPools<DB>,
registry_url: &str,
dir: &Path,
request: &ProvisionRequest,
rep: &Reporter<'_>,
) -> Result<ProvisionOutcome, TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
rep.step(ProvisionStep::Validate, StepStatus::Started).await;
let registry = pools.registry_pool();
let existing: Vec<Org> = match Org::objects()
.where_(Org::slug.eq(request.slug.clone()))
.fetch(®istry)
.await
{
Ok(rows) => rows,
Err(e) => return rep.fail(ProvisionStep::Validate, e.into()).await,
};
if let Some(org) = existing.into_iter().next() {
let resumable = rep.resume
&& !org.active
&& org.storage_mode == request.mode.as_str()
&& match super::provision_store::org_left_by_failed_run(
®istry,
org.id.get().copied().unwrap_or_default(),
)
.await
{
Ok(left) => left,
Err(e) => return rep.fail(ProvisionStep::Validate, e).await,
};
if !resumable {
return rep
.fail(
ProvisionStep::Validate,
TenancyError::Validation(format!(
"tenant slug `{}` already exists",
request.slug
)),
)
.await;
}
rep.step(ProvisionStep::Validate, StepStatus::Ok).await;
rep.step(
ProvisionStep::RegisterOrg,
StepStatus::Skipped("resuming the tenant an earlier run left inactive".into()),
)
.await;
let org_id = org.id.get().copied().unwrap_or_default();
rep.registered(org_id).await;
return migrate_and_activate(pools, registry_url, dir, request, &org, rep).await;
}
let normalized = match checked_request(®istry, request).await {
Ok(r) => r,
Err(e) => return rep.fail(ProvisionStep::Validate, e).await,
};
let request = &normalized;
if request.mode == StorageMode::Database && request.database_url.is_none() {
return rep
.fail(
ProvisionStep::Validate,
TenancyError::Validation("database mode needs a database URL".into()),
)
.await;
}
if let Some(url) = &request.database_url {
if let Err(msg) = refuse_registry_url(url, registry_url) {
return rep
.fail(ProvisionStep::Validate, TenancyError::Validation(msg))
.await;
}
}
rep.step(ProvisionStep::Validate, StepStatus::Ok).await;
check_connection(request, rep).await?;
let schema_name = schema_name_for(request);
provision_storage(pools, request, schema_name.as_deref(), rep).await?;
rep.step(ProvisionStep::RegisterOrg, StepStatus::Started)
.await;
let mut org = new_org_row(request, schema_name);
if let Err(e) = org.insert_pool(®istry).await {
return rep.fail(ProvisionStep::RegisterOrg, e.into()).await;
}
super::invalidate_org_cache();
let org_id = org.id.get().copied().unwrap_or_default();
rep.registered(org_id).await;
rep.step(ProvisionStep::RegisterOrg, StepStatus::Ok).await;
rep.notify(ProvisionEvent::Registered { org_id });
migrate_and_activate(pools, registry_url, dir, request, &org, rep).await
}
async fn migrate_and_activate<DB: Database>(
pools: &TenantPools<DB>,
registry_url: &str,
dir: &Path,
request: &ProvisionRequest,
org: &Org,
rep: &Reporter<'_>,
) -> Result<ProvisionOutcome, TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
let registry = pools.registry_pool();
let org_id = org.id.get().copied().unwrap_or_default();
let migrations = if request.run_migrations {
rep.step(ProvisionStep::Migrate, StepStatus::Started).await;
let outcome = migrate_new_tenant(pools, registry_url, dir, org, rep).await;
rep.flush().await;
match &outcome {
MigrationsOutcome::Failed(e) => {
rep.step(ProvisionStep::Migrate, StepStatus::Failed(e.clone()))
.await;
}
_ => rep.step(ProvisionStep::Migrate, StepStatus::Ok).await,
}
outcome
} else {
rep.step(
ProvisionStep::Migrate,
StepStatus::Skipped("caller asked for no migrations".into()),
)
.await;
MigrationsOutcome::Skipped
};
if matches!(migrations, MigrationsOutcome::Failed(_)) {
rep.step(
ProvisionStep::Activate,
StepStatus::Skipped("migrations failed; the tenant stays inactive".into()),
)
.await;
} else {
rep.step(ProvisionStep::Activate, StepStatus::Started).await;
if let Err(e) = activate(®istry, org_id).await {
return rep.fail(ProvisionStep::Activate, e).await;
}
super::invalidate_org_cache();
rep.step(ProvisionStep::Activate, StepStatus::Ok).await;
}
Ok(ProvisionOutcome {
org_id,
slug: request.slug.clone(),
mode: request.mode,
migrations,
})
}
async fn check_connection(
request: &ProvisionRequest,
rep: &Reporter<'_>,
) -> Result<(), TenancyError> {
let (Some(url), StorageMode::Database) = (&request.database_url, request.mode) else {
rep.step(
ProvisionStep::CheckConnection,
StepStatus::Skipped("schema-mode tenants share the registry's database".into()),
)
.await;
return Ok(());
};
rep.step(ProvisionStep::CheckConnection, StepStatus::Started)
.await;
match preflight::check(url, &request.preflight).await {
Ok(_) => {
rep.step(ProvisionStep::CheckConnection, StepStatus::Ok)
.await;
Ok(())
}
Err(d) => {
rep.fail(
ProvisionStep::CheckConnection,
TenancyError::Validation(d.to_string()),
)
.await
}
}
}
async fn provision_storage<DB: Database>(
pools: &TenantPools<DB>,
request: &ProvisionRequest,
schema_name: Option<&str>,
rep: &Reporter<'_>,
) -> Result<(), TenancyError> {
if request.mode != StorageMode::Schema {
rep.step(
ProvisionStep::ProvisionStorage,
StepStatus::Skipped("database-mode tenants bring their own database".into()),
)
.await;
return Ok(());
}
rep.step(ProvisionStep::ProvisionStorage, StepStatus::Started)
.await;
let schema = schema_name.unwrap_or(&request.slug);
if let Err(e) = provision_schema(pools, schema).await {
return rep.fail(ProvisionStep::ProvisionStorage, e).await;
}
rep.step(ProvisionStep::ProvisionStorage, StepStatus::Ok)
.await;
Ok(())
}
fn validate_slug(slug: &str) -> Result<(), String> {
if slug.is_empty() {
return Err("a slug is required".into());
}
if slug.len() > 63 {
return Err(format!(
"slug `{slug}` is {} characters; a hostname label allows at most 63",
slug.len()
));
}
let legal = |b: u8| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-';
if let Some(bad) = slug.bytes().find(|b| !legal(*b)) {
return Err(format!(
"slug `{slug}` contains `{}` — only lowercase letters, digits and hyphens are \
allowed, because the slug becomes a database name, a schema name and a \
hostname label",
bad as char
));
}
if slug.starts_with('-') || slug.ends_with('-') {
return Err(format!(
"slug `{slug}` may not start or end with a hyphen — a hostname label cannot"
));
}
Ok(())
}
fn validate_fields(request: &ProvisionRequest) -> Result<ProvisionRequest, String> {
validate_slug(&request.slug)?;
if let Some(schema) = schema_name_for(request) {
validate_schema_name(&schema)?;
}
if let Some(prefix) = &request.path_prefix {
validate_path_prefix(prefix)?;
}
if let Some(port) = request.port {
validate_port(port)?;
}
let mut out = request.clone();
let pattern = request.host_pattern.clone().or_else(|| {
std::env::var("RUSTANGO_APEX_DOMAIN")
.ok()
.map(|apex| format!("{}.{apex}", request.slug))
});
out.host_pattern = pattern.as_deref().map(validate_host_pattern).transpose()?;
Ok(out)
}
pub(crate) fn validate_schema_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("a schema name is required".into());
}
if name.len() > 63 {
return Err(format!(
"schema name `{name}` is {} characters; Postgres allows at most 63",
name.len()
));
}
let legal = |b: u8| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-';
if let Some(bad) = name.bytes().find(|b| !legal(*b)) {
return Err(format!(
"schema name `{name}` contains `{}` — only lowercase letters, digits, underscores \
and hyphens are allowed",
bad as char
));
}
if name.starts_with('-') {
return Err(format!("schema name `{name}` may not start with a hyphen"));
}
if name.starts_with("pg_") || name == "information_schema" {
return Err(format!(
"schema name `{name}` is reserved by Postgres — choose another"
));
}
if name == "public" {
return Err(format!(
"schema name `{name}` is the registry's schema — choose another"
));
}
Ok(())
}
pub(crate) fn validate_host_pattern(pattern: &str) -> Result<String, String> {
if pattern.contains(':') {
return Err(format!(
"host pattern `{pattern}` carries a port — the `Host` header is matched with the \
port stripped, so this could never match. Put the port in the port field"
));
}
if pattern.contains('*') {
return Err(format!(
"host pattern `{pattern}` uses a wildcard — patterns are matched exactly, so this \
could never match. Register one tenant per hostname"
));
}
if pattern.len() > 253 {
return Err(format!(
"host pattern `{pattern}` is {} characters; a hostname allows at most 253",
pattern.len()
));
}
let normalized = pattern.to_ascii_lowercase();
for label in normalized.split('.') {
if label.is_empty() {
return Err(format!(
"host pattern `{pattern}` has an empty label — check for a doubled or trailing dot"
));
}
if label.len() > 63 {
return Err(format!(
"host pattern `{pattern}` has a label longer than the 63 characters a hostname \
allows"
));
}
let legal = |b: u8| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-';
if let Some(bad) = label.bytes().find(|b| !legal(*b)) {
return Err(format!(
"host pattern `{pattern}` contains `{}` — a hostname allows only letters, \
digits, hyphens and dots",
bad as char
));
}
if label.starts_with('-') || label.ends_with('-') {
return Err(format!(
"host pattern `{pattern}` has a label starting or ending with a hyphen, which a \
hostname cannot"
));
}
}
Ok(normalized)
}
pub(crate) fn validate_path_prefix(prefix: &str) -> Result<(), String> {
let Some(segment) = prefix.strip_prefix('/') else {
return Err(format!(
"path prefix `{prefix}` must start with `/` — the resolver looks up `/<segment>`"
));
};
if segment.is_empty() {
return Err("path prefix `/` is the apex, which resolves to no tenant".into());
}
if segment.contains('/') {
return Err(format!(
"path prefix `{prefix}` has more than one segment — only the first segment of the \
URL is matched, so this could never match"
));
}
if segment.bytes().all(|b| b == b'.') {
return Err(format!(
"path prefix `{prefix}` is a relative-path segment, not a tenant prefix"
));
}
let legal = |b: u8| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~');
if let Some(bad) = segment.bytes().find(|b| !legal(*b)) {
return Err(format!(
"path prefix `{prefix}` contains `{}` — a path segment that needs escaping would \
not match the decoded path",
bad as char
));
}
Ok(())
}
pub(crate) fn validate_port(port: i32) -> Result<(), String> {
if !(1..=65535).contains(&port) {
return Err(format!(
"port {port} is outside the 1–65535 a TCP port can be"
));
}
Ok(())
}
pub(crate) async fn checked_request(
registry: &crate::sql::Pool,
request: &ProvisionRequest,
) -> Result<ProvisionRequest, TenancyError> {
let normalized = validate_fields(request).map_err(TenancyError::Validation)?;
if let Some(clash) = routing_clash(registry, &normalized).await? {
return Err(TenancyError::Validation(format!(
"{clash} is already used by another tenant"
)));
}
Ok(normalized)
}
async fn routing_clash(
registry: &crate::sql::Pool,
request: &ProvisionRequest,
) -> Result<Option<String>, crate::sql::ExecError> {
use super::org_host::{host_claimed, port_claimed, prefix_claimed};
if let Some(host) = &request.host_pattern {
if host_claimed(registry, host, None).await? {
return Ok(Some(format!("host `{host}`")));
}
}
if let Some(prefix) = &request.path_prefix {
if prefix_claimed(registry, prefix, None).await? {
return Ok(Some(format!("path prefix `{prefix}`")));
}
}
if let Some(port) = request.port {
if port_claimed(registry, port, None).await? {
return Ok(Some(format!("port {port}")));
}
}
Ok(None)
}
pub(crate) fn refuse_registry_url(tenant_url: &str, registry_url: &str) -> Result<(), String> {
if endpoint_identity(tenant_url) == endpoint_identity(registry_url) {
return Err(format!(
"this is the registry's own database ({}). A tenant needs its own — pointing one \
here would run the tenant migrations over the registry",
crate::sql::connect_diagnosis::redact(tenant_url)
));
}
Ok(())
}
#[must_use]
pub fn tenant_url_on_registry_server(registry_url: &str, database: &str) -> Option<String> {
if let Some(path) = registry_url.strip_prefix("sqlite://") {
let path = path.split('?').next().unwrap_or(path);
if path.is_empty() || path.starts_with(":memory:") {
return None;
}
let dir = path.rsplit_once('/').map_or(".", |(d, _)| d);
let file = if std::path::Path::new(database)
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("db"))
{
database.to_owned()
} else {
format!("{database}.db")
};
return Some(format!("sqlite://{dir}/{file}?mode=rwc"));
}
let (scheme, rest) = registry_url.split_once("://")?;
let (rest, query) = match rest.split_once('?') {
Some((path, q)) => (path, Some(q)),
None => (rest, None),
};
let (authority, _old_db) = rest.rsplit_once('/')?;
if authority.is_empty() {
return None;
}
let tls: Vec<&str> = query
.unwrap_or("")
.split('&')
.filter(|chunk| {
let key = chunk.split_once('=').map_or(*chunk, |(k, _)| k);
TLS_QUERY_KEYS.contains(&crate::url_codec::url_decode(key).as_str())
})
.collect();
Some(if tls.is_empty() {
format!("{scheme}://{authority}/{database}")
} else {
format!("{scheme}://{authority}/{database}?{}", tls.join("&"))
})
}
const TLS_QUERY_KEYS: &[&str] = &[
"sslmode",
"ssl-mode",
"sslrootcert",
"ssl-root-cert",
"sslca",
"ssl-ca",
"sslcert",
"ssl-cert",
"sslkey",
"ssl-key",
];
fn endpoint_identity(url: &str) -> String {
let (scheme, rest) = url.split_once("://").unwrap_or_else(|| {
url.split_once(':').unwrap_or(("", url))
});
let rest = rest.rsplit_once('@').map_or(rest, |(_, after)| after);
let (rest, query) = rest.split_once('?').unwrap_or((rest, ""));
let scheme = match scheme.to_ascii_lowercase().as_str() {
"postgresql" => "postgres".to_owned(),
other => other.to_owned(),
};
let dbname = if scheme == "postgres" {
crate::urls::parse_query_pairs(query)
.into_iter()
.rev()
.find(|(k, _)| k == "dbname")
.map(|(_, v)| v)
} else {
None
};
let rest = match dbname {
Some(db) => {
let authority = rest.split_once('/').map_or(rest, |(a, _)| a);
format!("{authority}/{db}")
}
None => rest.to_owned(),
};
format!("{scheme}://{}", rest.to_ascii_lowercase())
}
fn schema_name_for(request: &ProvisionRequest) -> Option<String> {
match request.mode {
StorageMode::Schema => Some(
request
.schema_name
.clone()
.unwrap_or_else(|| request.slug.clone()),
),
StorageMode::Database => None,
}
}
fn new_org_row(request: &ProvisionRequest, schema_name: Option<String>) -> Org {
Org {
id: Auto::default(),
slug: request.slug.clone(),
display_name: request
.display_name
.clone()
.unwrap_or_else(|| request.slug.clone()),
storage_mode: request.mode.as_str().into(),
backend_kind: request.backend.as_str().into(),
database_url: request.database_url.clone(),
schema_name,
host_pattern: request.host_pattern.clone(),
port: request.port,
path_prefix: request.path_prefix.clone(),
active: false,
created_at: chrono::Utc::now(),
brand_name: None,
brand_tagline: None,
logo_path: None,
favicon_path: None,
primary_color: None,
theme_mode: None,
}
}
async fn provision_schema<DB: Database>(
pools: &TenantPools<DB>,
schema: &str,
) -> Result<(), TenancyError> {
#[cfg(feature = "postgres")]
{
use crate::sql::Dialect as _;
let pg_pools = (pools as &dyn std::any::Any)
.downcast_ref::<TenantPools<sqlx::Postgres>>()
.ok_or_else(|| {
TenancyError::Validation(
"schema-mode tenants require a Postgres registry — pass --mode database \
on sqlite/mysql"
.into(),
)
})?;
let sql = format!(
"CREATE SCHEMA IF NOT EXISTS {}",
crate::sql::Postgres.quote_ident(schema)
);
rustango::sql::sqlx::query(&sql)
.execute(pg_pools.registry())
.await?;
Ok(())
}
#[cfg(not(feature = "postgres"))]
{
let _ = (pools, schema);
Err(TenancyError::Validation(
"schema-mode tenants require the `postgres` feature — pass --mode database \
on sqlite/mysql builds"
.into(),
))
}
}
async fn activate(registry: &crate::sql::Pool, org_id: i64) -> Result<(), TenancyError> {
use crate::sql::UpdaterPool as _;
let updated = Org::objects()
.where_(Org::id.eq(org_id))
.update()
.set("active", true)
.execute_pool(registry)
.await?;
if updated == 0 {
return Err(TenancyError::Validation(format!(
"activate: no row updated for org {org_id} — was it deleted mid-provision?"
)));
}
Ok(())
}
async fn migrate_new_tenant<DB: Database>(
pools: &TenantPools<DB>,
registry_url: &str,
dir: &Path,
org: &Org,
rep: &Reporter<'_>,
) -> MigrationsOutcome
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
let forward = move |event: tenant_migrate::TenantMigrationEvent| rep.migration(event);
let forward: Option<&dyn tenant_migrate::TenantMigrationObserver> = Some(&forward);
match tenant_migrate::migrate_one_tenant(pools, org, dir, registry_url, forward).await {
Ok(applied) => MigrationsOutcome::Applied(applied),
Err(e) => MigrationsOutcome::Failed(e.to_string()),
}
}
pub trait TenantProvisioner: Send + Sync {
fn provision<'a>(
&'a self,
request: &'a ProvisionRequest,
requested_by: Option<&'a str>,
idempotency_key: Option<&'a str>,
) -> BoxFuture<'a, (super::provision_store::ProvisioningRun, ProvisionOutcome)>;
fn provision_in_run<'a>(
&'a self,
run_id: i64,
request: &'a ProvisionRequest,
) -> BoxFuture<'a, ProvisionOutcome>;
fn migrate_in_run<'a>(&'a self, run_id: i64, slug: Option<&'a str>) -> BoxFuture<'a, ()>;
fn registry_url(&self) -> String;
fn registry(&self) -> crate::sql::Pool;
}
pub type BoxFuture<'a, T> =
std::pin::Pin<Box<dyn std::future::Future<Output = Result<T, TenancyError>> + Send + 'a>>;
pub struct Provisioner<DB: Database> {
pools: std::sync::Arc<TenantPools<DB>>,
registry_url: String,
migrations_dir: std::path::PathBuf,
}
impl<DB: Database> Provisioner<DB> {
pub fn new(
pools: std::sync::Arc<TenantPools<DB>>,
registry_url: impl Into<String>,
migrations_dir: impl Into<std::path::PathBuf>,
) -> Self {
Self {
pools,
registry_url: registry_url.into(),
migrations_dir: migrations_dir.into(),
}
}
#[must_use]
pub fn erased(self) -> std::sync::Arc<dyn TenantProvisioner>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
std::sync::Arc::new(self)
}
}
impl<DB: Database> TenantProvisioner for Provisioner<DB>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
fn provision<'a>(
&'a self,
request: &'a ProvisionRequest,
requested_by: Option<&'a str>,
idempotency_key: Option<&'a str>,
) -> BoxFuture<'a, (super::provision_store::ProvisioningRun, ProvisionOutcome)> {
Box::pin(async move {
provision_tenant_recorded(
self.pools.as_ref(),
&self.registry_url,
&self.migrations_dir,
request,
None,
requested_by,
idempotency_key,
)
.await
})
}
fn provision_in_run<'a>(
&'a self,
run_id: i64,
request: &'a ProvisionRequest,
) -> BoxFuture<'a, ProvisionOutcome> {
Box::pin(async move {
provision_tenant_in_run(
self.pools.as_ref(),
&self.registry_url,
&self.migrations_dir,
request,
None,
run_id,
)
.await
})
}
fn migrate_in_run<'a>(&'a self, run_id: i64, slug: Option<&'a str>) -> BoxFuture<'a, ()> {
Box::pin(async move {
super::migrate_run::migrate_in_run(
self.pools.as_ref(),
&self.migrations_dir,
&self.registry_url,
run_id,
slug,
)
.await
})
}
fn registry_url(&self) -> String {
self.registry_url.clone()
}
fn registry(&self) -> crate::sql::Pool {
self.pools.registry_pool()
}
}
#[cfg(test)]
mod validation_tests {
use super::*;
#[test]
fn a_slug_with_a_space_is_refused() {
let err = validate_slug("tennant 1").expect_err("a space is not a hostname character");
assert!(err.contains("tennant 1"), "{err}");
assert!(err.contains("hostname"), "should say why: {err}");
}
#[test]
fn a_slug_is_restricted_to_what_is_legal_in_all_three_uses() {
for bad in [
"", "Acme", "ac me", "acme_corp", "acme.corp", "acme;DROP", "../etc", "-acme", "acme-", ] {
assert!(
validate_slug(bad).is_err(),
"slug `{bad}` should have been refused"
);
}
for good in ["acme", "acme-2", "a", "tenant-42"] {
assert!(
validate_slug(good).is_ok(),
"slug `{good}` should be allowed"
);
}
}
#[test]
fn the_schema_names_that_got_through_qa_are_refused() {
for injected in [
r#"x"; CREATE TABLE public.pwned_a(i int); --"#,
r#"y"; DROP TABLE public.rustango_operators; --"#,
] {
let err =
validate_schema_name(injected).expect_err("an SQL fragment is not a schema name");
assert!(err.contains("only lowercase"), "{err}");
}
}
#[test]
fn a_schema_name_is_a_postgres_identifier() {
for bad in [
"", "Acme", "ac me", "acme;DROP", "acme.other", "-acme", "pg_toast", "pg_anything", "information_schema", "public", ] {
assert!(
validate_schema_name(bad).is_err(),
"schema name `{bad}` should have been refused"
);
}
for good in ["acme", "acme_corp", "tenant-42", "a", "2acme"] {
assert!(
validate_schema_name(good).is_ok(),
"schema name `{good}` should be allowed"
);
}
assert!(validate_schema_name(&"a".repeat(63)).is_ok());
assert!(
validate_schema_name(&"a".repeat(64)).is_err(),
"Postgres would silently truncate it"
);
}
#[test]
fn a_host_pattern_that_could_never_match_is_refused() {
let err = validate_host_pattern("acme.example.com:8080")
.expect_err("the port is stripped before matching");
assert!(
err.contains("port field"),
"should say where it goes: {err}"
);
let err = validate_host_pattern("*.example.com").expect_err("patterns are matched exactly");
assert!(err.contains("wildcard"), "{err}");
for bad in [
"not a hostname", "acme..com", "acme.com.", "-acme.com", "acme-.com", r#"acme";DROP--"#, ] {
assert!(
validate_host_pattern(bad).is_err(),
"host pattern `{bad}` should have been refused"
);
}
}
#[test]
fn an_uppercase_host_pattern_is_lowercased_not_refused() {
assert_eq!(
validate_host_pattern("Acme.Example.COM").as_deref(),
Ok("acme.example.com")
);
}
#[test]
fn a_path_prefix_must_be_one_leading_slash_segment() {
for bad in [
"acme", "/", "/acme/", "/acme/dashboard", "../../etc/passwd", "/..", "/ac me", ] {
assert!(
validate_path_prefix(bad).is_err(),
"path prefix `{bad}` should have been refused"
);
}
for good in ["/acme", "/tenant-42", "/a_b", "/v1.0"] {
assert!(
validate_path_prefix(good).is_ok(),
"path prefix `{good}` should be allowed"
);
}
}
#[test]
fn a_port_outside_the_tcp_range_is_refused() {
assert!(validate_port(-1).is_err());
assert!(validate_port(0).is_err());
assert!(validate_port(999_999).is_err());
assert!(validate_port(1).is_ok());
assert!(validate_port(8080).is_ok());
assert!(validate_port(65_535).is_ok());
}
#[test]
fn validate_fields_checks_the_slug_derived_schema_name_too() {
let mut req = ProvisionRequest::database("acme-2", "postgres://h/tenant");
req.mode = StorageMode::Schema;
req.database_url = None;
assert!(validate_fields(&req).is_ok());
req.schema_name = Some(r#"x"; DROP TABLE t; --"#.into());
assert!(validate_fields(&req).is_err());
}
#[test]
fn validate_fields_hands_back_a_normalized_host_pattern() {
let mut req = ProvisionRequest::database("acme", "postgres://h/tenant");
req.host_pattern = Some("ACME.Example.com".into());
let out = validate_fields(&req).expect("a legal hostname");
assert_eq!(out.host_pattern.as_deref(), Some("acme.example.com"));
}
#[test]
fn an_over_long_slug_is_refused_at_the_hostname_limit() {
assert!(validate_slug(&"a".repeat(63)).is_ok());
let err = validate_slug(&"a".repeat(64)).expect_err("too long for a hostname label");
assert!(err.contains("63"), "{err}");
}
#[test]
fn a_tenant_url_naming_the_registry_database_is_refused() {
let registry = "postgres://rustango:rustango@localhost:5432/orgdemo_dev";
let err = refuse_registry_url(registry, registry).expect_err("same database");
assert!(err.contains("registry's own database"), "{err}");
assert!(!err.contains("rustango:rustango"), "password leaked: {err}");
}
#[test]
fn different_credentials_for_the_same_database_are_still_refused() {
assert!(refuse_registry_url(
"postgres://someone_else:other@localhost:5432/orgdemo_dev",
"postgres://rustango:rustango@localhost:5432/orgdemo_dev",
)
.is_err());
}
#[test]
fn a_query_string_does_not_disguise_the_same_database() {
assert!(refuse_registry_url(
"sqlite:///var/app/reg.db?mode=rwc",
"sqlite:///var/app/reg.db",
)
.is_err());
}
#[test]
fn a_genuinely_separate_database_is_allowed() {
let registry = "postgres://rustango:rustango@localhost:5432/orgdemo_dev";
for ok in [
"postgres://rustango:rustango@localhost:5432/acme_tenant", "postgres://rustango:rustango@otherhost:5432/orgdemo_dev", "postgres://rustango:rustango@localhost:5433/orgdemo_dev", ] {
assert!(
refuse_registry_url(ok, registry).is_ok(),
"`{ok}` is a different database and should be allowed"
);
}
}
#[test]
fn a_server_url_keeps_everything_but_the_database() {
assert_eq!(
tenant_url_on_registry_server(
"postgres://app:pw@db.internal:5432/orgdemo_dev",
"tenant_acme"
)
.as_deref(),
Some("postgres://app:pw@db.internal:5432/tenant_acme")
);
assert_eq!(
tenant_url_on_registry_server("mysql://app:pw@db.internal:3306/orgdemo_dev", "t_acme")
.as_deref(),
Some("mysql://app:pw@db.internal:3306/t_acme")
);
}
#[test]
fn a_server_url_keeps_its_query_string() {
for (registry, want) in [
(
"postgres://app@db:5432/reg?sslmode=verify-full&sslrootcert=/etc/ssl/ca.pem",
"postgres://app@db:5432/tenant_acme?sslmode=verify-full&sslrootcert=/etc/ssl/ca.pem",
),
(
"postgres://app@db:5432/reg?sslmode=require",
"postgres://app@db:5432/tenant_acme?sslmode=require",
),
(
"mysql://app@db:3306/reg?ssl-mode=REQUIRED",
"mysql://app@db:3306/tenant_acme?ssl-mode=REQUIRED",
),
] {
assert_eq!(
tenant_url_on_registry_server(registry, "tenant_acme").as_deref(),
Some(want),
"registry `{registry}`"
);
}
assert!(tenant_url_on_registry_server("postgres://db?sslrootcert=/ca.pem", "t").is_none());
}
#[test]
fn a_server_url_drops_every_non_tls_query_key() {
for (registry, want) in [
(
"postgres://app@db:5432/reg?dbname=reg",
"postgres://app@db:5432/tenant_acme",
),
(
"postgres://app@db:5432/reg?password=s3cret&sslmode=require",
"postgres://app@db:5432/tenant_acme?sslmode=require",
),
(
"mysql://app@db:3306/reg?ssl-ca=/ca.pem&socket=/tmp/x",
"mysql://app@db:3306/tenant_acme?ssl-ca=/ca.pem",
),
] {
let derived = tenant_url_on_registry_server(registry, "tenant_acme");
assert_eq!(derived.as_deref(), Some(want), "registry `{registry}`");
assert!(refuse_registry_url(&derived.unwrap(), registry).is_ok());
}
}
#[test]
fn a_dbname_query_naming_the_registry_is_refused() {
let registry = "postgres://app:pw@db:5432/reg";
for tenant in [
"postgres://other@db:5432/tenant_acme?dbname=reg",
"postgres://db:5432?dbname=reg",
"postgresql://db:5432/x?dbname=t&dbname=reg",
] {
assert!(
refuse_registry_url(tenant, registry).is_err(),
"`{tenant}` names the registry"
);
}
assert!(refuse_registry_url("postgres://db:5432/reg?dbname=t", registry).is_ok());
}
#[test]
fn a_sqlite_url_derives_a_sibling_file() {
for (registry, want) in [
(
"sqlite://./demo_dev.db?mode=rwc",
"sqlite://./tenant_acme.db?mode=rwc",
),
(
"sqlite:///var/app/reg.db",
"sqlite:///var/app/tenant_acme.db?mode=rwc",
),
("sqlite://reg.db", "sqlite://./tenant_acme.db?mode=rwc"),
] {
assert_eq!(
tenant_url_on_registry_server(registry, "tenant_acme").as_deref(),
Some(want),
"registry `{registry}`"
);
}
}
#[test]
fn a_sqlite_database_name_is_not_given_two_extensions() {
for named in ["acme.db", "acme.DB"] {
assert_eq!(
tenant_url_on_registry_server("sqlite://./reg.db", named).as_deref(),
Some(format!("sqlite://./{named}?mode=rwc")).as_deref(),
"database name `{named}`"
);
}
assert_eq!(
tenant_url_on_registry_server("sqlite://./reg.db", "acme.v2").as_deref(),
Some("sqlite://./acme.v2.db?mode=rwc")
);
}
#[test]
fn a_url_with_no_database_to_replace_derives_nothing() {
for undrivable in [
"sqlite://:memory:", "sqlite://", "postgres://localhost", "not a url", "postgres:///orgdemo", ] {
assert!(
tenant_url_on_registry_server(undrivable, "tenant_acme").is_none(),
"`{undrivable}` should not derive a URL"
);
}
}
#[test]
fn a_derived_url_is_never_the_registry_itself() {
for registry in [
"postgres://app:pw@db.internal:5432/orgdemo_dev",
"sqlite://./demo_dev.db?mode=rwc",
] {
let derived =
tenant_url_on_registry_server(registry, "tenant_acme").expect("derivable registry");
assert!(
refuse_registry_url(&derived, registry).is_ok(),
"derived `{derived}` collided with registry `{registry}`"
);
}
}
}