use crate::casts::{Cast, EncryptedString};
use crate::sql::Auto;
#[derive(crate::Model, Debug, Clone)]
#[rustango(
table = "rustango_sso_providers",
admin(
list_display = "slug, label, kind, enabled, sort_order",
ordering = "sort_order",
readonly_fields = "created_at, updated_at",
formfield_overrides = "client_secret: password",
)
)]
#[allow(dead_code)]
pub struct SsoProvider {
#[rustango(primary_key)]
pub id: Auto<i64>,
#[rustango(max_length = 64, unique)]
pub slug: String,
#[rustango(max_length = 150)]
pub label: String,
#[rustango(max_length = 32)]
pub kind: String,
#[rustango(max_length = 255)]
pub issuer_url: Option<String>,
#[rustango(max_length = 255)]
pub client_id: String,
#[rustango(max_length = 1024)]
pub client_secret: Cast<EncryptedString>,
#[rustango(default = "true")]
pub enabled: bool,
#[rustango(default = "0")]
pub sort_order: i32,
#[rustango(max_length = 255)]
pub scopes: Option<String>,
#[rustango(default = "false")]
pub allow_email_link: bool,
#[rustango(max_length = 2000)]
pub allowed_email_domains: Option<String>,
#[rustango(max_length = 2000)]
pub blocked_email_domains: Option<String>,
#[rustango(max_length = 1000)]
pub allowed_idp_tenants: Option<String>,
#[rustango(max_length = 1000)]
pub allowed_hosted_domains: Option<String>,
#[rustango(default = "'{}'")]
pub required_claims: serde_json::Value,
#[rustango(
max_length = 16,
choices = "closed:Closed, invite_only:Invite only, allowlist:Allowlist, open:Open"
)]
pub signup_mode: Option<String>,
#[rustango(max_length = 1000)]
pub default_roles: Option<String>,
#[rustango(default = "'{}'")]
pub role_map: serde_json::Value,
#[rustango(max_length = 255)]
pub role_claims: Option<String>,
#[rustango(default = "false")]
pub sync_roles_on_login: bool,
#[rustango(
max_length = 24,
default = "'email_local'",
choices = "email_local:Email local part, email:Email, preferred_username:Preferred username"
)]
pub username_from: String,
#[rustango(
max_length = 16,
default = "'suffix'",
choices = "suffix:Add a suffix, refuse:Refuse"
)]
pub username_clash: String,
#[rustango(default = "false")]
pub sync_profile: bool,
#[rustango(default = "false")]
pub allow_email_link_staff: bool,
#[rustango(default = "false")]
pub sso_only: bool,
pub auto_deactivate_after_days: Option<i32>,
pub session_ttl_secs: Option<i64>,
pub superuser_session_ttl_secs: Option<i64>,
#[rustango(auto_now_add)]
pub created_at: Auto<chrono::DateTime<chrono::Utc>>,
#[rustango(auto_now)]
pub updated_at: Auto<chrono::DateTime<chrono::Utc>>,
}
use super::link::{LinkSource, ProviderKey};
use super::{parse_scopes, ProviderButton, ResolvedSso, SsoError};
use crate::core::{Model, SqlValue};
use crate::query::QuerySet;
use crate::sql::{ExecError, Pool};
#[cfg(feature = "admin")]
pub(crate) fn admin_encrypt_secret<'a>(
values: &'a mut Vec<(&'static str, SqlValue)>,
_before: Option<&'a serde_json::Value>,
) -> crate::admin::derived_fields::DeriveFuture<'a> {
let out = match crate::admin::derived_fields::take_secret(values, "client_secret") {
None => Ok(()),
Some(plain) => crate::casts::encrypt(plain.as_bytes())
.map(|c| values.push(("client_secret", SqlValue::String(c))))
.map_err(|e| e.to_string()),
};
Box::pin(async move { out })
}
#[cfg(feature = "admin")]
inventory::submit! {
crate::admin::derived_fields::AdminDerivedField {
table: "rustango_sso_providers",
derive: admin_encrypt_secret,
}
}
const DEFERRED: &[&str] = &[
"allow_email_link",
"created_at",
"updated_at",
"allowed_email_domains",
"blocked_email_domains",
"allowed_idp_tenants",
"allowed_hosted_domains",
"required_claims",
"signup_mode",
"default_roles",
"role_map",
"role_claims",
"sync_roles_on_login",
"username_from",
"username_clash",
"sync_profile",
"allow_email_link_staff",
"sso_only",
"auto_deactivate_after_days",
"session_ttl_secs",
"superuser_session_ttl_secs",
];
const LIFECYCLE_COLUMNS: &[&str] = &[
"sso_only",
"auto_deactivate_after_days",
"session_ttl_secs",
"superuser_session_ttl_secs",
];
const POLICY_COLUMNS: &[&str] = &[
"allowed_email_domains",
"blocked_email_domains",
"allowed_idp_tenants",
"allowed_hosted_domains",
"required_claims",
"signup_mode",
"default_roles",
"role_map",
"role_claims",
"sync_roles_on_login",
"username_from",
"username_clash",
"sync_profile",
"allow_email_link_staff",
"sso_only",
"auto_deactivate_after_days",
"session_ttl_secs",
"superuser_session_ttl_secs",
];
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum Columns {
None,
Some(Vec<&'static str>),
All,
}
pub(crate) async fn columns_present(
pool: &Pool,
table: &str,
cols: &[&'static str],
) -> Result<Columns, String> {
let have = crate::migrate::ensure::live_columns(pool, table)
.await
.map_err(|e| format!("catalog: {e}"))?;
if have.is_empty() {
return Err(format!("the catalog shows no columns for {table}"));
}
let present: Vec<&'static str> = cols.iter().copied().filter(|c| have.contains(*c)).collect();
Ok(match present.len() {
0 => Columns::None,
n if n == cols.len() => Columns::All,
_ => Columns::Some(present),
})
}
async fn read_columns<T: Model>(
pool: &Pool,
id: i64,
cols: &'static [&'static str],
) -> Result<Option<std::collections::HashMap<String, SqlValue>>, super::policy::PolicyError> {
use super::policy::PolicyError;
let err = match QuerySet::<T>::new()
.filter("id", id)
.only(cols)
.fetch(pool)
.await
{
Ok(mut rows) => {
if rows.is_empty() {
return Err(PolicyError("the provider row vanished".into()));
}
let r = rows.swap_remove(0);
if cols.iter().all(|c| r.contains_key(*c)) {
return Ok(Some(r));
}
"the read returned no policy columns".to_owned()
}
Err(e) => e.to_string(),
};
let present = columns_present(pool, T::SCHEMA.table, cols).await;
columns_after_read_error(present, T::SCHEMA.table, &err).map(|()| None)
}
pub(crate) async fn read_rules<T: Model>(pool: &Pool, id: i64) -> Rules {
use super::policy::{Lifecycle, SignInPolicy, SignupPolicy};
match read_columns::<T>(pool, id, POLICY_COLUMNS).await {
Ok(Some(r)) => (policy_of_row(&r), signup_of_row(&r), lifecycle_of_row(&r)),
Ok(None) => (
Ok(SignInPolicy::default()),
Ok(SignupPolicy::default()),
Ok(Lifecycle::default()),
),
Err(e) => (Err(e.clone()), Err(e.clone()), Err(e)),
}
}
pub(crate) type Rules = (
Result<super::policy::SignInPolicy, super::policy::PolicyError>,
Result<super::policy::SignupPolicy, super::policy::PolicyError>,
Result<super::policy::Lifecycle, super::policy::PolicyError>,
);
pub(crate) async fn read_lifecycle<T: Model>(
pool: &Pool,
id: i64,
) -> Result<super::policy::Lifecycle, super::policy::PolicyError> {
match read_columns::<T>(pool, id, LIFECYCLE_COLUMNS).await? {
Some(r) => lifecycle_of_row(&r),
None => Ok(super::policy::Lifecycle::default()),
}
}
fn lifecycle_of_row(
r: &std::collections::HashMap<String, SqlValue>,
) -> Result<super::policy::Lifecycle, super::policy::PolicyError> {
use super::policy::{Lifecycle, PolicyError};
let bad = |c: &str| PolicyError(format!("{c}: unexpected value {:?}", r.get(c)));
let number = |c: &str| match r.get(c) {
Some(SqlValue::Null) => Ok(None),
Some(SqlValue::I64(n)) => Ok(Some(*n)),
Some(SqlValue::I32(n)) => Ok(Some(i64::from(*n))),
Some(SqlValue::I16(n)) => Ok(Some(i64::from(*n))),
_ => Err(bad(c)),
};
Lifecycle::parse(
flag(r, "sso_only").ok_or_else(|| bad("sso_only"))?,
number("auto_deactivate_after_days")?,
number("session_ttl_secs")?,
number("superuser_session_ttl_secs")?,
)
}
fn columns_after_read_error(
present: Result<Columns, String>,
table: &str,
err: &str,
) -> Result<(), super::policy::PolicyError> {
use super::policy::PolicyError;
match present {
Ok(Columns::None) => {
tracing::warn!(
target: "rustango::sso",
table,
"SSO policy columns missing; run `migrate`. Using the default policy: {err}"
);
Ok(())
}
Ok(Columns::Some(have)) => Err(PolicyError(format!(
"only some policy columns exist ({}); run `migrate`",
have.join(", ")
))),
Ok(Columns::All) => Err(PolicyError(format!("read: {err}"))),
Err(e) => Err(PolicyError(format!("read: {err}; {e}"))),
}
}
fn flag(r: &std::collections::HashMap<String, SqlValue>, c: &str) -> Option<bool> {
match r.get(c) {
Some(SqlValue::Bool(b)) => Some(*b),
Some(SqlValue::I64(n)) => Some(*n != 0),
Some(SqlValue::I32(n)) => Some(*n != 0),
Some(SqlValue::I16(n)) => Some(*n != 0),
_ => None,
}
}
fn json_of(
r: &std::collections::HashMap<String, SqlValue>,
c: &str,
) -> Result<serde_json::Value, super::policy::PolicyError> {
use super::policy::PolicyError;
match r.get(c) {
Some(SqlValue::Json(v)) => Ok(v.clone()),
Some(SqlValue::String(s)) => {
serde_json::from_str(s).map_err(|e| PolicyError(format!("{c}: {e}")))
}
other => Err(PolicyError(format!("{c}: unexpected value {other:?}"))),
}
}
fn signup_of_row(
r: &std::collections::HashMap<String, SqlValue>,
) -> Result<super::policy::SignupPolicy, super::policy::PolicyError> {
use super::policy::{PolicyError, SignupColumns, SignupPolicy};
let bad = |c: &str| PolicyError(format!("{c}: unexpected value {:?}", r.get(c)));
let text = |c: &str| match r.get(c) {
Some(SqlValue::Null) => Ok(None),
Some(SqlValue::String(s)) => Ok(Some(s.as_str())),
_ => Err(bad(c)),
};
let on = |c: &str| flag(r, c).ok_or_else(|| bad(c));
let role_map = json_of(r, "role_map")?;
SignupPolicy::parse(&SignupColumns {
signup_mode: text("signup_mode")?,
default_roles: text("default_roles")?,
role_map: Some(&role_map),
role_claims: text("role_claims")?,
sync_roles_on_login: on("sync_roles_on_login")?,
username_from: text("username_from")?,
username_clash: text("username_clash")?,
sync_profile: on("sync_profile")?,
allow_email_link_staff: on("allow_email_link_staff")?,
})
}
fn policy_of_row(
r: &std::collections::HashMap<String, SqlValue>,
) -> Result<super::policy::SignInPolicy, super::policy::PolicyError> {
use super::policy::{PolicyError, SignInColumns, SignInPolicy};
let bad = |c: &str, v: Option<&SqlValue>| PolicyError(format!("{c}: unexpected value {v:?}"));
let list = |c: &str| match r.get(c) {
Some(SqlValue::Null) => Ok(None),
Some(SqlValue::String(s)) => Ok(Some(s.as_str())),
other => Err(bad(c, other)),
};
let claims = json_of(r, "required_claims")?;
SignInPolicy::parse(&SignInColumns {
allowed_email_domains: list("allowed_email_domains")?,
blocked_email_domains: list("blocked_email_domains")?,
allowed_idp_tenants: list("allowed_idp_tenants")?,
allowed_hosted_domains: list("allowed_hosted_domains")?,
required_claims: Some(&claims),
})
}
pub(crate) struct ProviderRow {
pub id: i64,
pub slug: String,
pub label: String,
pub kind: String,
pub issuer_url: Option<String>,
pub client_id: String,
client_secret: String,
pub enabled: bool,
pub sort_order: i32,
pub scopes: Option<String>,
}
pub(super) fn text(v: Option<&SqlValue>) -> Option<String> {
match v {
Some(SqlValue::String(s)) => Some(s.clone()),
_ => None,
}
}
pub(super) fn int(v: Option<&SqlValue>) -> i64 {
match v {
Some(SqlValue::I64(n)) => *n,
Some(SqlValue::I32(n)) => i64::from(*n),
Some(SqlValue::I16(n)) => i64::from(*n),
Some(SqlValue::Bool(b)) => i64::from(*b),
_ => 0,
}
}
pub(crate) async fn load_rows<T: Model>(
qs: QuerySet<T>,
pool: &Pool,
) -> Result<Vec<ProviderRow>, ExecError> {
if !crate::migrate::try_table_exists_here(pool, T::SCHEMA.table).await? {
return Ok(Vec::new());
}
let rows = qs.defer(DEFERRED).fetch(pool).await?;
Ok(rows
.iter()
.map(|r| ProviderRow {
id: int(r.get("id")),
slug: text(r.get("slug")).unwrap_or_default(),
label: text(r.get("label")).unwrap_or_default(),
kind: text(r.get("kind")).unwrap_or_default(),
issuer_url: text(r.get("issuer_url")),
client_id: text(r.get("client_id")).unwrap_or_default(),
client_secret: text(r.get("client_secret")).unwrap_or_default(),
enabled: int(r.get("enabled")) != 0,
sort_order: i32::try_from(int(r.get("sort_order"))).unwrap_or(0),
scopes: text(r.get("scopes")),
})
.collect())
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ResolvedProvider {
pub sso: ResolvedSso,
pub id: i64,
pub allow_email_link: bool,
pub policy: Result<super::policy::SignInPolicy, super::policy::PolicyError>,
pub signup: Result<super::policy::SignupPolicy, super::policy::PolicyError>,
pub lifecycle: Result<super::policy::Lifecycle, super::policy::PolicyError>,
}
impl ResolvedProvider {
#[must_use]
pub fn key(&self, source: LinkSource) -> ProviderKey {
ProviderKey::for_row(
source,
self.id,
&self.sso.provider,
self.sso.issuer_url.as_deref(),
)
}
#[must_use]
pub fn effective_policy(&self) -> super::policy::EffectivePolicy {
super::policy::EffectivePolicy::new(self.policy.clone(), self.allow_email_link)
.with_signup(self.signup.clone())
.with_lifecycle(self.lifecycle.clone())
}
}
pub(crate) async fn resolve_row<T: Model>(
pool: &Pool,
slug: &str,
redirect_uri: String,
) -> Result<Option<ResolvedProvider>, SsoError> {
Ok(match lookup_row::<T>(pool, slug, redirect_uri).await? {
Lookup::Enabled(p) => Some(*p),
_ => None,
})
}
#[cfg_attr(not(all(feature = "tenancy", feature = "admin-sso")), allow(dead_code))]
pub(crate) enum Lookup {
Enabled(Box<ResolvedProvider>),
Disabled,
Missing,
}
pub(crate) async fn lookup_row<T: Model>(
pool: &Pool,
slug: &str,
redirect_uri: String,
) -> Result<Lookup, SsoError> {
use crate::casts::CastValue as _;
let rows = load_rows(QuerySet::<T>::new().filter("slug", slug.to_owned()), pool)
.await
.map_err(|e| SsoError::Config(format!("db: {e}")))?;
let disabled = rows.iter().any(|r| r.slug == slug);
let Some(r) = rows.into_iter().find(|r| r.enabled) else {
return Ok(if disabled {
Lookup::Disabled
} else {
Lookup::Missing
});
};
let client_secret =
EncryptedString::from_db(&r.client_secret).map_err(|e| SsoError::Secret(e.to_string()))?;
let allow_email_link = match QuerySet::<T>::new()
.filter("id", r.id)
.values_list_flat("allow_email_link")
.fetch::<bool>(pool)
.await
{
Ok(v) => v.first().copied().unwrap_or(false),
Err(e) => {
tracing::warn!(target: "rustango::sso", "allow_email_link unreadable, treated as off: {e}");
false
}
};
let (policy, signup, lifecycle) = read_rules::<T>(pool, r.id).await;
Ok(Lookup::Enabled(Box::new(ResolvedProvider {
policy,
signup,
lifecycle,
sso: ResolvedSso {
provider: r.kind,
issuer_url: r.issuer_url,
client_id: r.client_id,
client_secret,
redirect_uri,
scopes: parse_scopes(r.scopes.as_deref()),
},
id: r.id,
allow_email_link,
})))
}
pub async fn list_enabled(pool: &Pool, login_base: &str) -> Vec<ProviderButton> {
let mut rows = load_rows(QuerySet::<SsoProvider>::new(), pool)
.await
.unwrap_or_default();
rows.retain(|r| r.enabled);
rows.sort_by_key(|r| r.sort_order);
rows.into_iter()
.map(|r| ProviderButton {
login_url: format!("{login_base}/sso/{}", r.slug),
slug: r.slug,
label: r.label,
})
.collect()
}
pub async fn resolve_by_slug(
pool: &Pool,
slug: &str,
redirect_uri: String,
) -> Result<Option<ResolvedProvider>, SsoError> {
resolve_row::<SsoProvider>(pool, slug, redirect_uri).await
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn a_read_error_falls_back_only_when_no_policy_column_exists() {
let t = "rustango_sso_providers";
assert_eq!(columns_after_read_error(Ok(Columns::None), t, "e"), Ok(()));
for refused in [
Ok(Columns::Some(vec!["allowed_email_domains"])),
Ok(Columns::All),
Err("the catalog shows no columns".to_owned()),
] {
assert!(columns_after_read_error(refused, t, "e").is_err());
}
}
fn row(over: &[(&str, SqlValue)]) -> HashMap<String, SqlValue> {
let mut r: HashMap<String, SqlValue> = [
(
"allowed_email_domains",
SqlValue::String("client.org".into()),
),
("blocked_email_domains", SqlValue::Null),
("allowed_idp_tenants", SqlValue::Null),
("allowed_hosted_domains", SqlValue::Null),
("required_claims", SqlValue::String("{}".into())),
]
.into_iter()
.map(|(k, v)| (k.to_owned(), v))
.collect();
for (k, v) in over {
r.insert((*k).to_owned(), v.clone());
}
r
}
#[test]
fn an_unexpected_value_refuses() {
let p = policy_of_row(&row(&[])).unwrap();
assert!(p.allowed_email_domains.is_some());
for bad in [
("allowed_email_domains", SqlValue::I64(5)),
("blocked_email_domains", SqlValue::Bool(true)),
("required_claims", SqlValue::Null),
] {
assert!(policy_of_row(&row(&[bad.clone()])).is_err(), "{bad:?}");
}
let mut gone = row(&[]);
gone.remove("allowed_idp_tenants");
assert!(policy_of_row(&gone).is_err(), "a column the read lacks");
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn an_empty_catalog_is_an_error() {
let pool = Pool::connect("sqlite::memory:").await.unwrap();
assert!(
columns_present(&pool, "rustango_sso_providers", POLICY_COLUMNS)
.await
.is_err()
);
}
}