use std::collections::HashMap;
use crate::core::SqlValue;
use crate::query::QuerySet;
use crate::sql::Pool;
use super::link::{LinkSource, SsoLink};
use super::policy::Lifecycle;
use super::provider::{load_rows, read_lifecycle, SsoProvider};
const GONE: Lifecycle = Lifecycle {
sso_only: true,
auto_deactivate_after_days: None,
session_ttl_secs: None,
superuser_session_ttl_secs: None,
};
#[cfg(feature = "tenancy")]
pub async fn password_login_allowed(
pool: &Pool,
registry: Option<&Pool>,
user: &crate::tenancy::User,
) -> bool {
let settings = match super::policy::TenantSettings::load(pool).await {
Ok(s) => s,
Err(e) => {
tracing::error!(target: "rustango::sso", "tenant SSO settings unreadable, only superusers use a password: {e}");
return user.is_superuser;
}
};
if !settings.password_allowed(user.is_superuser) {
return false;
}
if user.is_superuser {
return true;
}
let Some(&user_id) = user.id.get() else {
return false;
};
let sources = [LinkSource::Tenant, LinkSource::Shared];
match sso_only(pool, registry, user_id, &sources).await {
Ok(only) => !only,
Err(e) => {
tracing::error!(target: "rustango::sso", user_id, "SSO rules unreadable, password refused: {e}");
false
}
}
}
#[cfg(feature = "admin")]
pub(crate) async fn admin_password_allowed(pool: &Pool, user_id: i64, superuser: bool) -> bool {
if superuser {
return true;
}
match sso_only(pool, None, user_id, &[LinkSource::Admin]).await {
Ok(only) => !only,
Err(e) => {
tracing::error!(target: "rustango::sso", user_id, "SSO rules unreadable, password refused: {e}");
false
}
}
}
async fn links_of(pool: &Pool, user_id: i64) -> Result<Vec<(String, i64)>, String> {
let rows = match QuerySet::<SsoLink>::new()
.filter("user_id", user_id)
.only(&["provider_source", "provider_id"])
.fetch(pool)
.await
{
Ok(rows) => rows,
Err(e) => {
return match crate::migrate::try_table_exists_here(pool, "rustango_sso_links").await {
Ok(false) => Ok(Vec::new()),
_ => Err(format!("links: {e}")),
}
}
};
Ok(rows
.iter()
.map(|r| (text(r, "provider_source"), int(r, "provider_id")))
.collect())
}
fn text(r: &HashMap<String, SqlValue>, c: &str) -> String {
match r.get(c) {
Some(SqlValue::String(s)) => s.clone(),
_ => String::new(),
}
}
fn int(r: &HashMap<String, SqlValue>, c: &str) -> i64 {
match r.get(c) {
Some(SqlValue::I64(n)) => *n,
Some(SqlValue::I32(n)) => i64::from(*n),
_ => 0,
}
}
async fn provider_rules(
pool: &Pool,
registry: Option<&Pool>,
source: &str,
id: i64,
) -> Result<Option<Lifecycle>, String> {
if source == LinkSource::Tenant.as_str() || source == LinkSource::Admin.as_str() {
let rows = load_rows(QuerySet::<SsoProvider>::new().filter("id", id), pool)
.await
.map_err(|e| e.to_string())?;
if rows.is_empty() {
return Ok(Some(GONE));
}
return read_lifecycle::<SsoProvider>(pool, id)
.await
.map(Some)
.map_err(|e| e.0);
}
if source == LinkSource::Shared.as_str() {
return shared_rules(pool, registry, id).await;
}
Ok(None)
}
#[cfg(all(feature = "tenancy", feature = "admin-sso"))]
async fn shared_rules(
pool: &Pool,
registry: Option<&Pool>,
id: i64,
) -> Result<Option<Lifecycle>, String> {
use crate::tenancy::sso::SharedSsoProvider;
let registry = registry.ok_or("a shared provider needs the registry to read")?;
let rows = load_rows(
QuerySet::<SharedSsoProvider>::new().filter("id", id),
registry,
)
.await
.map_err(|e| e.to_string())?;
let Some(row) = rows.first() else {
return Ok(Some(GONE));
};
let own = read_lifecycle::<SharedSsoProvider>(registry, id)
.await
.map_err(|e| e.0)?;
let key = super::link::ProviderKey::for_row(
LinkSource::Shared,
row.id,
&row.kind,
row.issuer_url.as_deref(),
);
let tenant = super::policy::SharedOverride::load(pool, &key, &row.slug)
.await
.map_err(|e| e.0)?;
Ok(Some(match tenant {
Some(ov) => own.tighten(ov.lifecycle),
None => own,
}))
}
#[cfg(not(all(feature = "tenancy", feature = "admin-sso")))]
async fn shared_rules(_: &Pool, _: Option<&Pool>, _: i64) -> Result<Option<Lifecycle>, String> {
Ok(None)
}
async fn sso_only(
pool: &Pool,
registry: Option<&Pool>,
user_id: i64,
sources: &[LinkSource],
) -> Result<bool, String> {
for (source, id) in links_of(pool, user_id).await? {
if !sources.iter().any(|s| s.as_str() == source) {
continue;
}
if provider_rules(pool, registry, &source, id)
.await?
.is_some_and(|l| l.sso_only)
{
return Ok(true);
}
}
Ok(false)
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct IdleSweep {
pub deactivated: Vec<i64>,
pub failed: usize,
}
#[cfg(feature = "tenancy")]
pub async fn deactivate_idle(pool: &Pool, registry: Option<&Pool>) -> Result<IdleSweep, String> {
use crate::sql::FetcherPool as _;
use crate::tenancy::User;
let links = match SsoLink::objects().fetch(pool).await {
Ok(l) => l,
Err(e) => {
return match crate::migrate::try_table_exists_here(pool, "rustango_sso_links").await {
Ok(false) => Ok(IdleSweep::default()),
_ => Err(format!("links: {e}")),
}
}
};
let settings = super::policy::TenantSettings::load(pool)
.await
.map_err(|e| format!("settings: {e}"))?;
let mut by_user: HashMap<i64, Vec<&SsoLink>> = HashMap::new();
for l in &links {
by_user.entry(l.user_id).or_default().push(l);
}
let now = chrono::Utc::now();
let mut rules: HashMap<(String, i64), Option<Lifecycle>> = HashMap::new();
let mut idle: HashMap<i64, (chrono::DateTime<chrono::Utc>, bool)> = HashMap::new();
'users: for (user_id, links) in by_user {
let mut earliest = now;
let mut sso_only = false;
for l in &links {
if !l.provisioned {
continue 'users;
}
let key = (l.provider_source.clone(), l.provider_id);
let rule = match rules.get(&key) {
Some(r) => *r,
None => {
let r = provider_rules(pool, registry, &key.0, key.1)
.await
.unwrap_or_else(|e| {
tracing::warn!(target: "rustango::sso", user_id, "idle check skipped: {e}");
None
});
rules.insert(key, r);
r
}
};
let Some(days) = rule.and_then(|r| r.auto_deactivate_after_days) else {
continue 'users;
};
let cutoff = now - chrono::TimeDelta::days(i64::from(days));
let last = l.last_used_at.or_else(|| l.created_at.get().copied());
if last.is_none_or(|at| at >= cutoff) {
continue 'users;
}
earliest = earliest.min(cutoff);
sso_only |= rule.is_some_and(|r| r.sso_only)
&& [LinkSource::Tenant, LinkSource::Shared]
.iter()
.any(|s| s.as_str() == l.provider_source);
}
idle.insert(user_id, (earliest, sso_only));
}
let mut out = IdleSweep::default();
let ids: Vec<i64> = idle.keys().copied().collect();
for chunk in ids.chunks(crate::list_params::MAX_IN_VALUES) {
let list = chunk.iter().copied().map(SqlValue::I64).collect();
let users = match User::objects()
.filter("id__in", SqlValue::List(list))
.filter("is_superuser", false)
.filter("active", true)
.fetch(pool)
.await
{
Ok(u) => u,
Err(e) => {
tracing::error!(target: "rustango::sso", error = %e, "idle users unreadable, skipped");
out.failed += chunk.len();
continue;
}
};
for user in users {
let Some(&user_id) = user.id.get() else {
continue;
};
let Some(&(cutoff, sso_only)) = idle.get(&user_id) else {
continue;
};
if settings.password_allowed(false) && !sso_only {
continue;
}
match deactivate(pool, user_id, cutoff, now).await {
Ok(true) => out.deactivated.push(user_id),
Ok(false) => {}
Err(e) => {
tracing::error!(target: "rustango::sso", user_id, "idle deactivation failed: {e}");
out.failed += 1;
}
}
}
}
Ok(out)
}
#[cfg(feature = "tenancy")]
async fn deactivate(
pool: &Pool,
user_id: i64,
cutoff: chrono::DateTime<chrono::Utc>,
now: chrono::DateTime<chrono::Utc>,
) -> Result<bool, String> {
use crate::sql::UpdaterPool as _;
use crate::tenancy::User;
let used_since = SsoLink::objects()
.filter("user_id", user_id)
.filter("last_used_at__gte", cutoff)
.values_list_flat("user_id")
.compile()
.map_err(|e| e.to_string())?;
let n = User::objects()
.filter("id", user_id)
.filter("is_superuser", false)
.filter("active", true)
.where_not_in_subquery("id", used_since)
.update()
.set("active", false)
.set("sessions_revoked_at", now)
.execute_pool(pool)
.await
.map_err(|e| format!("deactivate user {user_id}: {e}"))?;
if n == 0 {
return Ok(false);
}
let entry = crate::audit::PendingEntry {
entity_table: "rustango_users",
entity_pk: user_id.to_string(),
operation: crate::audit::AuditOp::Action,
source: crate::audit::AuditSource::Custom("sso".into()),
changes: serde_json::json!({"event": "sso_deactivated_idle", "active": false}),
};
if let Err(e) = crate::audit::emit_one(pool, &entry).await {
tracing::error!(target: "rustango::sso", user_id, error = %e, "an idle deactivation was NOT written to the audit log");
}
Ok(true)
}