use std::path::Path;
use crate::core::Model as _;
use crate::migrate::{Migration, MigrationScope, Operation, SchemaChange, SchemaSnapshot};
use super::auth::{validate_tenant_user_schema, Operator, TenantUserModel, User};
use super::auth_backends::ApiKey;
use super::error::TenancyError;
use super::org::Org;
use super::permissions::{Role, RolePermission, UserPermission, UserRole};
pub const REGISTRY_BOOTSTRAP_NAME: &str = "0001_rustango_registry_initial";
pub const TENANT_BOOTSTRAP_NAME: &str = "0001_rustango_tenant_initial";
const BOOTSTRAP_TIMESTAMP: &str = "2026-04-29T00:00:00Z";
#[must_use]
pub fn registry_bootstrap_migration() -> Migration {
registry_bootstrap_migration_for::<User>()
}
#[must_use]
pub fn tenant_bootstrap_migration() -> Migration {
tenant_bootstrap_migration_for::<User>()
}
#[must_use]
pub fn registry_bootstrap_migration_for<U: TenantUserModel>() -> Migration {
Migration {
name: REGISTRY_BOOTSTRAP_NAME.to_owned(),
created_at: BOOTSTRAP_TIMESTAMP.to_owned(),
prev: None,
atomic: true,
scope: MigrationScope::Registry,
snapshot: full_snapshot_for::<U>(),
forward: vec![
Operation::Schema(SchemaChange::CreateTable("rustango_orgs".into())),
Operation::Schema(SchemaChange::CreateTable("rustango_operators".into())),
],
}
}
#[must_use]
pub fn tenant_bootstrap_migration_for<U: TenantUserModel>() -> Migration {
Migration {
name: TENANT_BOOTSTRAP_NAME.to_owned(),
created_at: BOOTSTRAP_TIMESTAMP.to_owned(),
prev: None,
atomic: true,
scope: MigrationScope::Tenant,
snapshot: full_snapshot_for::<U>(),
forward: vec![
Operation::Schema(SchemaChange::CreateTable("rustango_users".into())),
],
}
}
fn full_snapshot_for<U: TenantUserModel>() -> SchemaSnapshot {
if let Err(e) = validate_tenant_user_schema(&U::SCHEMA) {
panic!("invalid TenantUserModel: {e}");
}
SchemaSnapshot::from_models(&[
Org::SCHEMA,
Operator::SCHEMA,
U::SCHEMA,
Role::SCHEMA,
RolePermission::SCHEMA,
UserRole::SCHEMA,
UserPermission::SCHEMA,
ApiKey::SCHEMA,
])
}
#[derive(Debug, Default)]
pub struct InitTenancyReport {
pub written: Vec<String>,
pub skipped: Vec<String>,
}
pub fn init_tenancy(dir: &Path) -> Result<InitTenancyReport, TenancyError> {
init_tenancy_with::<User>(dir)
}
pub fn init_tenancy_with<U: TenantUserModel>(
dir: &Path,
) -> Result<InitTenancyReport, TenancyError> {
if !dir.exists() {
std::fs::create_dir_all(dir)?;
}
let mut report = InitTenancyReport::default();
for mig in [
registry_bootstrap_migration_for::<U>(),
tenant_bootstrap_migration_for::<U>(),
] {
let path = dir.join(format!("{}.json", mig.name));
if path.exists() {
report.skipped.push(mig.name);
continue;
}
rustango::migrate::file::write(&path, &mig)?;
report.written.push(mig.name);
}
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_migration_is_registry_scoped() {
let m = registry_bootstrap_migration();
assert_eq!(m.scope, MigrationScope::Registry);
assert_eq!(m.name, REGISTRY_BOOTSTRAP_NAME);
assert!(m.prev.is_none());
assert_eq!(m.forward.len(), 2);
}
#[test]
fn tenant_migration_is_tenant_scoped() {
let m = tenant_bootstrap_migration();
assert_eq!(m.scope, MigrationScope::Tenant);
assert_eq!(m.name, TENANT_BOOTSTRAP_NAME);
assert!(m.prev.is_none());
assert_eq!(m.forward.len(), 1);
}
#[test]
fn snapshots_contain_all_three_tenancy_tables() {
let s = full_snapshot_for::<User>();
let names: Vec<&str> = s.tables.iter().map(|t| t.name.as_str()).collect();
assert!(names.contains(&"rustango_orgs"));
assert!(names.contains(&"rustango_operators"));
assert!(names.contains(&"rustango_users"));
}
#[derive(crate::Model, Debug, Clone)]
#[rustango(table = "rustango_users")]
#[allow(dead_code)]
pub struct AppUserExt {
#[rustango(primary_key)]
pub id: rustango::sql::Auto<i64>,
#[rustango(max_length = 64, unique)]
pub username: String,
#[rustango(max_length = 255)]
pub password_hash: String,
pub is_superuser: bool,
pub active: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
#[rustango(default = "'{}'")]
pub data: serde_json::Value,
#[rustango(max_length = 128, default = "''")]
pub display_name: String,
#[rustango(max_length = 64, default = "'UTC'")]
pub timezone: String,
}
impl super::TenantUserModel for AppUserExt {}
#[test]
fn user_model_override_lands_extras_in_tenant_snapshot() {
let m = tenant_bootstrap_migration_for::<AppUserExt>();
let users = m.snapshot.table("rustango_users").expect("rustango_users in snapshot");
let cols: Vec<&str> = users.fields.iter().map(|f| f.column.as_str()).collect();
for required in super::super::auth::REQUIRED_USER_COLUMNS {
assert!(cols.contains(required), "missing required column {required}");
}
assert!(cols.contains(&"display_name"), "extras must be in snapshot");
assert!(cols.contains(&"timezone"));
}
#[test]
fn user_model_override_also_propagates_to_registry_snapshot() {
let m = registry_bootstrap_migration_for::<AppUserExt>();
let users = m.snapshot.table("rustango_users").unwrap();
let cols: Vec<&str> = users.fields.iter().map(|f| f.column.as_str()).collect();
assert!(cols.contains(&"display_name"));
}
#[test]
fn default_user_snapshot_has_no_extras() {
let m = tenant_bootstrap_migration();
let users = m.snapshot.table("rustango_users").unwrap();
let cols: Vec<&str> = users.fields.iter().map(|f| f.column.as_str()).collect();
assert!(!cols.contains(&"display_name"));
assert!(!cols.contains(&"timezone"));
}
#[test]
fn init_tenancy_with_writes_override_migration() {
let dir = tempdir();
let report = init_tenancy_with::<AppUserExt>(&dir).unwrap();
assert_eq!(report.written.len(), 2);
let path = dir.join(format!("{TENANT_BOOTSTRAP_NAME}.json"));
let mig = rustango::migrate::file::load(&path).unwrap();
let users = mig.snapshot.table("rustango_users").unwrap();
let cols: Vec<&str> = users.fields.iter().map(|f| f.column.as_str()).collect();
assert!(cols.contains(&"display_name"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn init_tenancy_writes_then_skips_idempotently() {
let dir = tempdir();
let first = init_tenancy(&dir).unwrap();
assert_eq!(first.written.len(), 2);
assert!(first.skipped.is_empty());
assert!(dir.join(format!("{REGISTRY_BOOTSTRAP_NAME}.json")).exists());
assert!(dir.join(format!("{TENANT_BOOTSTRAP_NAME}.json")).exists());
let second = init_tenancy(&dir).unwrap();
assert!(second.written.is_empty());
assert_eq!(second.skipped.len(), 2);
let _ = std::fs::remove_dir_all(&dir);
}
fn tempdir() -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let n = N.fetch_add(1, Ordering::SeqCst);
let pid = std::process::id();
let mut p = std::env::temp_dir();
p.push(format!("rustango_tenancy_bootstrap_test_{pid}_{n}"));
let _ = std::fs::remove_dir_all(&p);
p
}
}