pub mod matrix;
#[cfg(feature = "runtime")]
mod tracing_capture;
#[cfg(feature = "runtime")]
pub use tracing_capture::CaptureWriter;
use crate::core::ModelSchema;
use crate::migrate::{ddl, MigrateError};
use crate::sql::Pool;
pub async fn create_framework_tables(pool: &Pool) -> Result<(), MigrateError> {
let models: Vec<&'static ModelSchema> = crate::migrate::registered_models()
.into_iter()
.filter(|m| m.managed && m.table.starts_with("rustango_"))
.collect();
emit_tables(pool, &models).await
}
pub async fn create_tables(
pool: &Pool,
models: &[&'static ModelSchema],
) -> Result<(), MigrateError> {
emit_tables(pool, models).await
}
pub async fn create_tables_for<M: crate::core::Model>(pool: &Pool) -> Result<(), MigrateError> {
emit_tables(pool, &[M::SCHEMA]).await
}
pub async fn create_tables_in_schema(
pool: &Pool,
schema: &str,
models: &[&'static ModelSchema],
) -> Result<(), MigrateError> {
let dialect = pool.dialect();
for model in models {
let bare = ddl::create_table_if_not_exists_sql_with_dialect(dialect, model);
let quoted = dialect.quote_ident(model.table);
let qualified_target = format!("\"{schema}\".{quoted}");
let sql = bare.replacen("ed, &qualified_target, 1);
crate::sql::raw_execute_pool(pool, &sql, ::std::vec::Vec::new()).await?;
}
emit_indexes(pool, models, Some(schema)).await
}
async fn emit_indexes(
pool: &Pool,
models: &[&'static ModelSchema],
schema: Option<&str>,
) -> Result<(), MigrateError> {
let snapshot = crate::migrate::SchemaSnapshot::from_models_forced(models);
let indexes: Vec<crate::migrate::SchemaChange> =
crate::migrate::detect_changes(&crate::migrate::SchemaSnapshot::default(), &snapshot)
.into_iter()
.filter(|c| matches!(c, crate::migrate::SchemaChange::CreateIndex { .. }))
.collect();
match schema {
Some(s) => {
crate::migrate::ensure::apply_changes_idempotent_in(pool, &snapshot, &indexes, Some(s))
.await?;
}
None => crate::migrate::ensure::apply_changes_idempotent(pool, &snapshot, &indexes).await?,
}
Ok(())
}
async fn emit_tables(pool: &Pool, models: &[&'static ModelSchema]) -> Result<(), MigrateError> {
let dialect = pool.dialect();
for model in models {
ddl::check_on_delete(dialect, model).map_err(MigrateError::Validation)?;
}
if let Some(sql) = ddl::ci_text_extension_sql(dialect, models) {
crate::sql::raw_execute_pool(pool, sql, ::std::vec::Vec::new()).await?;
}
for model in models {
let sql = ddl::create_table_if_not_exists_sql_with_dialect(dialect, model);
crate::sql::raw_execute_pool(pool, &sql, ::std::vec::Vec::new()).await?;
}
for model in models {
for sql in ddl::create_constraints_sql_with_dialect(dialect, model) {
match crate::sql::raw_execute_pool(pool, &sql, ::std::vec::Vec::new()).await {
Err(e) if !crate::migrate::ensure::is_already_exists(&e, dialect.name(), &sql) => {
return Err(e.into());
}
_ => {}
}
}
}
for model in models {
for sql in ddl::column_comment_statements_with_dialect(dialect, model) {
crate::sql::raw_execute_pool(pool, &sql, ::std::vec::Vec::new()).await?;
}
}
for model in models {
for sql in ddl::table_comment_statements_with_dialect(dialect, model) {
crate::sql::raw_execute_pool(pool, &sql, ::std::vec::Vec::new()).await?;
}
}
emit_indexes(pool, models, None).await
}
pub async fn migrate_framework(pool: &Pool) -> Result<(), MigrateError> {
let mut seen = std::collections::HashSet::new();
let models: Vec<&'static ModelSchema> = crate::migrate::registered_models()
.into_iter()
.filter(|m| m.managed && m.table.starts_with("rustango_") && seen.insert(m.table))
.collect();
let snapshot = crate::migrate::SchemaSnapshot::from_models(&models);
crate::migrate::apply_idempotent(pool, &snapshot)
.await
.map_err(|e| MigrateError::Validation(format!("{e}")))?;
Ok(())
}
#[cfg(feature = "tenancy")]
#[must_use]
pub fn org() -> crate::tenancy::Org {
crate::tenancy::Org {
id: crate::sql::Auto::Unset,
slug: "acme".into(),
display_name: "Acme".into(),
storage_mode: "database".into(),
backend_kind: "postgres".into(),
database_url: None,
schema_name: None,
host_pattern: None,
port: None,
path_prefix: None,
active: true,
created_at: chrono::Utc::now(),
brand_name: None,
brand_tagline: None,
logo_path: None,
favicon_path: None,
primary_color: None,
theme_mode: None,
}
}
#[cfg(feature = "tenancy")]
#[must_use]
pub fn user() -> crate::tenancy::User {
crate::tenancy::User {
id: crate::sql::Auto::Unset,
username: "alice".into(),
password_hash: String::new(),
#[cfg(feature = "sso")]
email: None,
is_superuser: false,
active: true,
created_at: chrono::Utc::now(),
data: serde_json::json!({}),
password_changed_at: None,
sessions_revoked_at: None,
}
}
#[cfg(feature = "admin")]
#[must_use]
pub fn admin_user() -> crate::admin::AdminUser {
crate::admin::AdminUser {
id: crate::sql::Auto::Unset,
username: "admin".into(),
password_hash: String::new(),
#[cfg(feature = "admin-sso")]
email: None,
is_superuser: true,
active: true,
created_at: chrono::Utc::now(),
sessions_revoked_at: None,
}
}
#[cfg(feature = "admin")]
#[must_use]
pub fn with_strict_csp(router: axum::Router) -> axum::Router {
use crate::csp_nonce::{CspNonceLayer, CspNonceRouterExt as _, CSP_NONCE_PLACEHOLDER};
use crate::security_headers::{SecurityHeadersLayer, SecurityHeadersRouterExt as _};
let csp = format!(
"default-src 'self'; script-src {CSP_NONCE_PLACEHOLDER}; style-src {CSP_NONCE_PLACEHOLDER}"
);
router
.security_headers(SecurityHeadersLayer::strict().csp(csp))
.csp_nonce(CspNonceLayer::default())
}
#[cfg(feature = "admin")]
pub async fn assert_strict_csp_page(resp: axum::response::Response, what: &str) -> String {
assert_eq!(resp.status(), axum::http::StatusCode::OK, "{what}");
let csp = resp.headers()["content-security-policy"]
.to_str()
.unwrap()
.to_owned();
let nonce = csp
.split("'nonce-")
.nth(1)
.and_then(|s| s.split('\'').next())
.unwrap_or_default()
.to_owned();
assert!(
!nonce.is_empty() && !nonce.contains("RUSTANGO"),
"{what}: no nonce in {csp}"
);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8_lossy(&body).into_owned();
assert_strict_csp_html(&html, &nonce, what);
html
}
#[cfg(feature = "admin")]
pub fn assert_strict_csp_html(html: &str, nonce: &str, what: &str) {
assert!(
html.contains("<script") || html.contains("<style"),
"{what}: nothing to check"
);
for tag in ["<script", "<style"] {
for (i, _) in html.match_indices(tag) {
let open = &html[i..i + html[i..].find('>').unwrap_or(0)];
if open.contains("application/json") {
continue;
}
assert!(
open.contains(&format!(r#"nonce="{nonce}""#)),
"{what}: {open}"
);
}
}
for bad in [
" onclick=",
" onsubmit=",
" onchange=",
" oninput=",
" onload=",
" style=\"",
] {
assert!(!html.contains(bad), "{what}: {bad} survives a strict CSP");
}
}
#[cfg(feature = "tenancy")]
pub fn reset_host_generation() {
crate::tenancy::reset_generation();
}
#[cfg(feature = "tenancy")]
pub fn expire_host_generation() {
crate::tenancy::expire_generation();
}
#[cfg(feature = "tenancy")]
pub fn reset_registry_breaker() {
crate::tenancy::reset_registry_breaker();
}
#[cfg(feature = "tenancy")]
pub fn reset_org_cache() {
crate::tenancy::reset_org_cache();
}
#[cfg(all(feature = "sso", any(feature = "tenancy", feature = "admin-sso")))]
pub mod sso_check {
#[cfg(feature = "admin-sso")]
pub use crate::sso::check::admin_providers;
#[cfg(all(feature = "tenancy", feature = "admin-sso"))]
pub use crate::sso::check::{shared_providers, SharedProviders};
#[cfg(feature = "tenancy")]
pub use crate::sso::check::{tenant_providers, Refusal, Stranded};
#[cfg(all(feature = "tenancy", feature = "admin-sso"))]
pub use crate::tenancy::sso::SharedSsoProvider;
}
#[cfg(all(test, feature = "sqlite", feature = "tenancy", feature = "admin"))]
mod tests {
use super::*;
#[tokio::test]
async fn framework_tables_and_factories_roundtrip() {
let path = std::env::temp_dir().join("rustango_testkit_selftest.db");
let _ = std::fs::remove_file(&path);
let url = format!("sqlite:{}?mode=rwc", path.display());
let pool = crate::sql::Pool::connect(&url).await.expect("connect");
create_tables_for::<crate::tenancy::Org>(&pool)
.await
.expect("orgs");
create_tables_for::<crate::tenancy::User>(&pool)
.await
.expect("users");
create_tables_for::<crate::admin::AdminUser>(&pool)
.await
.expect("admin_users");
let mut org = crate::tenancy::Org {
slug: "acme".into(),
..org()
};
org.insert_pool(&pool).await.expect("insert org");
let mut admin = crate::admin::AdminUser {
username: "root".into(),
..admin_user()
};
admin.insert_pool(&pool).await.expect("insert admin");
let mut user = crate::tenancy::User {
username: "alice".into(),
..user()
};
user.insert_pool(&pool).await.expect("insert user");
let _ = std::fs::remove_file(&path);
}
}