use crate::runtime::authn::AuthnConfig;
use crate::runtime::security::SecurityConfig;
#[derive(Debug, Clone)]
pub(crate) struct AuthReadinessCheck {
pub name: String,
pub ok: bool,
pub detail: String,
}
#[derive(Debug, Clone)]
pub(crate) struct AuthReadinessReport {
pub ok: bool,
pub checks: Vec<AuthReadinessCheck>,
}
impl AuthReadinessCheck {
fn pass(name: &str, detail: impl Into<String>) -> Self {
Self {
name: name.to_string(),
ok: true,
detail: detail.into(),
}
}
fn fail(name: &str, detail: impl Into<String>) -> Self {
Self {
name: name.to_string(),
ok: false,
detail: detail.into(),
}
}
}
pub(crate) async fn check_auth_readiness(security: &SecurityConfig) -> AuthReadinessReport {
let mut checks = Vec::new();
if let Some(key_src) = security
.jwt_private_key
.as_deref()
.filter(|k| !k.trim().is_empty())
{
checks.push(check_rsa_pem(
"jwt_signing_key",
key_src,
"failed to read JWT private key",
"invalid JWT private key",
|bytes| jsonwebtoken::EncodingKey::from_rsa_pem(bytes).map(|_| ()),
));
}
if let Some(key_src) = security
.jwt_public_key
.as_deref()
.filter(|k| !k.trim().is_empty())
{
checks.push(check_rsa_pem(
"jwt_verification_key",
key_src,
"failed to read JWT public key",
"invalid JWT public key format",
|bytes| jsonwebtoken::DecodingKey::from_rsa_pem(bytes).map(|_| ()),
));
}
checks.push(check_casbin_model().await);
let hardened =
SecurityConfig::current().is_production() || crate::runtime::security::fail_closed_mode();
if let Some(url) = security
.jwt_jwks_url
.as_deref()
.filter(|u| !u.trim().is_empty())
{
checks.push(check_jwks_reachable(url.trim()).await);
}
checks.push(check_audit_sink(security.audit_sink_url.trim(), hardened));
let signing = security
.jwt_private_key
.as_deref()
.is_some_and(|k| !k.trim().is_empty());
let verification = security
.jwt_public_key
.as_deref()
.is_some_and(|k| !k.trim().is_empty());
let jwks = security
.jwt_jwks_url
.as_deref()
.is_some_and(|u| !u.trim().is_empty());
if !signing && !verification && !jwks {
checks.push(AuthReadinessCheck::pass(
"token_issuance",
"no JWT signing/verification key or JWKS URL configured; UDB-issued JWTs are \
disabled (server-side sessions only) — valid for dev",
));
}
let authn = AuthnConfig::from_env();
checks.push(check_auth_schema_migration(hardened));
checks.push(check_session_revocation_store(&authn));
checks.push(check_outbox_table(hardened));
checks.push(check_policy_snapshot().await);
checks.push(check_signing_key_registry());
checks.push(check_revocation_subscription(hardened));
checks.push(check_idp_provider_support());
let ok = checks.iter().all(|c| c.ok);
AuthReadinessReport { ok, checks }
}
fn check_audit_sink(audit_sink: &str, hardened: bool) -> AuthReadinessCheck {
if audit_sink.is_empty() {
return AuthReadinessCheck::pass(
"audit_sink",
"no external audit sink configured; using local/stdout auditing (dev)",
);
}
if audit_sink.starts_with("http://") || audit_sink.starts_with("https://") {
return AuthReadinessCheck::pass(
"audit_sink",
"external audit sink configured (http endpoint)",
);
}
if audit_sink.starts_with("noop://") && !hardened {
return AuthReadinessCheck::pass(
"audit_sink",
"no-op audit sink configured for non-production development",
);
}
if audit_sink.starts_with("noop://") {
return AuthReadinessCheck::fail(
"audit_sink",
"no-op audit sink is not allowed in production or fail-closed posture",
);
}
AuthReadinessCheck::fail(
"audit_sink",
"audit sink URL is set but is not an http(s) endpoint",
)
}
fn env_on(key: &str) -> bool {
std::env::var(key)
.map(|v| {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
.unwrap_or(false)
}
fn auth_postgres_dsn_env_present() -> bool {
["UDB_PG_DSN", "DATABASE_URL"]
.iter()
.any(|k| std::env::var(k).ok().is_some_and(|v| !v.trim().is_empty()))
}
fn check_auth_schema_migration(hardened: bool) -> AuthReadinessCheck {
if auth_postgres_dsn_env_present() {
AuthReadinessCheck::pass(
"auth_schema_migration",
"Postgres DSN configured (UDB_PG_DSN / DATABASE_URL); native auth schema migrates \
via the proto-migration path (live schema state is in backend health probes)",
)
} else if hardened {
AuthReadinessCheck::pass(
"auth_schema_migration",
"no Postgres DSN env (UDB_PG_DSN / DATABASE_URL) detected in a hardened posture — \
if Postgres is configured via services.yaml this is fine; otherwise the native \
auth schema cannot be persisted. Confirm via backend health probes.",
)
} else {
AuthReadinessCheck::pass(
"auth_schema_migration",
"no Postgres DSN env detected; native auth schema may be unavailable \
(sessions-disabled fallback) — valid for dev",
)
}
}
fn check_session_revocation_store(authn: &AuthnConfig) -> AuthReadinessCheck {
let wants_redis = authn.session_backend.eq_ignore_ascii_case("redis");
if wants_redis {
#[cfg(not(feature = "redis"))]
{
return AuthReadinessCheck::fail(
"session_revocation_store",
"UDB_SESSION_BACKEND=redis but this binary was built without the `redis` \
feature — the session/revocation store silently degrades to Postgres",
);
}
#[cfg(feature = "redis")]
{
return AuthReadinessCheck::pass(
"session_revocation_store",
"Redis session/revocation store selected (falls back to Postgres if no Redis \
handle is wired at startup; live reachability is in backend health probes)",
);
}
}
if authn.session_enabled && authn.session_hash_secret.trim().is_empty() {
return AuthReadinessCheck::fail(
"session_revocation_store",
"sessions enabled (UDB_SESSION_ENABLED) but no UDB_SESSION_HASH_SECRET is set — \
sessions cannot be persisted (raw session ids must never be stored)",
);
}
AuthReadinessCheck::pass(
"session_revocation_store",
"Postgres session/token-revocation store (durable cluster-wide revocation)",
)
}
fn check_outbox_table(hardened: bool) -> AuthReadinessCheck {
let durable_export = env_on("UDB_AUDIT_EXPORT_POSTGRES");
let dsn_env = auth_postgres_dsn_env_present();
if durable_export && !dsn_env {
return AuthReadinessCheck::fail(
"outbox_table",
"UDB_AUDIT_EXPORT_POSTGRES is set but no Postgres DSN env (UDB_PG_DSN / \
DATABASE_URL) is configured to hold the outbox / durable audit table",
);
}
if dsn_env {
AuthReadinessCheck::pass(
"outbox_table",
"Postgres DSN present; auth events publish via the outbox→CDC relay",
)
} else if hardened {
AuthReadinessCheck::pass(
"outbox_table",
"no Postgres DSN env in a hardened posture — if Postgres is configured via \
services.yaml the auth-event outbox is fine; otherwise events cannot be \
persisted. Confirm via backend health probes.",
)
} else {
AuthReadinessCheck::pass(
"outbox_table",
"no Postgres DSN env; auth-event outbox may be unavailable — valid for dev",
)
}
}
async fn check_policy_snapshot() -> AuthReadinessCheck {
if let Err(e) = crate::runtime::authz::validate_casbin_model().await {
return AuthReadinessCheck::fail(
"policy_snapshot",
format!("authz policy snapshot cannot load (model parse failed): {e}"),
);
}
AuthReadinessCheck::pass(
"policy_snapshot",
"authz policy snapshot loads off a valid Casbin model; deny-by-default posture \
(snapshot default_allow is false)",
)
}
fn check_signing_key_registry() -> AuthReadinessCheck {
match crate::runtime::security::active_signing_key() {
Some((kid, _pem)) if !kid.trim().is_empty() => AuthReadinessCheck::pass(
"signing_key_registry",
"signing-key registry has an ACTIVE key (rotation-capable)",
),
Some(_) => AuthReadinessCheck::fail(
"signing_key_registry",
"signing-key registry snapshot has an ACTIVE entry with an empty kid",
),
None => AuthReadinessCheck::pass(
"signing_key_registry",
"no DB signing-key registry seeded; signing uses the env key + static kid \
(single-key fallback) — valid when key rotation is not in use",
),
}
}
fn check_revocation_subscription(hardened: bool) -> AuthReadinessCheck {
if hardened {
AuthReadinessCheck::pass(
"revocation_subscription",
"hardened posture: revocation lookups fail closed (a revocation-store error denies)",
)
} else {
AuthReadinessCheck::pass(
"revocation_subscription",
"dev posture: revocation lookups may fail open on store error — set UDB_FAIL_CLOSED \
or run in production posture to require fail-closed revocation",
)
}
}
fn check_idp_provider_support() -> AuthReadinessCheck {
AuthReadinessCheck::pass(
"idp_provider_support",
"IdP kinds supported: NATIVE, OIDC, SAML (+ SCIM provisioning). \
Proto-enumerated but UNSUPPORTED: LDAP, CUSTOM_JWT, EXTERNAL_SESSION — \
do not configure a provider with these kinds (they fail at login).",
)
}
pub async fn auth_readiness_triples(security: &SecurityConfig) -> Vec<(String, bool, String)> {
check_auth_readiness(security)
.await
.checks
.into_iter()
.map(|c| (c.name, c.ok, c.detail))
.collect()
}
fn check_rsa_pem(
name: &str,
key_src: &str,
read_err: &str,
parse_err: &str,
parse: impl Fn(&[u8]) -> Result<(), jsonwebtoken::errors::Error>,
) -> AuthReadinessCheck {
let inline = key_src.contains("-----BEGIN");
let bytes = if inline {
key_src.as_bytes().to_vec()
} else {
match std::fs::read(key_src) {
Ok(bytes) => bytes,
Err(e) => return AuthReadinessCheck::fail(name, format!("{read_err}: {e}")),
}
};
let source = if inline { "inline PEM" } else { "file path" };
match parse(&bytes) {
Ok(()) => AuthReadinessCheck::pass(name, format!("RSA PEM ({source}) parsed")),
Err(e) => AuthReadinessCheck::fail(name, format!("{parse_err} ({source}): {e}")),
}
}
async fn check_casbin_model() -> AuthReadinessCheck {
match crate::runtime::authz::validate_casbin_model().await {
Ok(()) => AuthReadinessCheck::pass("casbin_model", "authz Casbin model parsed"),
Err(e) => AuthReadinessCheck::fail("casbin_model", e),
}
}
async fn check_jwks_reachable(url: &str) -> AuthReadinessCheck {
#[cfg(feature = "http-client")]
{
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
{
Ok(client) => client,
Err(e) => {
return AuthReadinessCheck::fail(
"jwks_reachability",
format!("could not build HTTP client: {e}"),
);
}
};
match client.get(url).send().await {
Ok(resp) if resp.status().is_success() => match resp.text().await {
Ok(body) => {
let is_jwks = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|v| v.get("keys").map(|k| k.is_array()))
.unwrap_or(false);
if is_jwks {
AuthReadinessCheck::pass(
"jwks_reachability",
"configured JWKS URL is reachable and returns a JWK set",
)
} else {
AuthReadinessCheck::fail(
"jwks_reachability",
"JWKS URL reachable but the response is not a JWK set",
)
}
}
Err(e) => AuthReadinessCheck::fail(
"jwks_reachability",
format!("JWKS body read failed: {e}"),
),
},
Ok(resp) => AuthReadinessCheck::fail(
"jwks_reachability",
format!("JWKS URL returned HTTP {}", resp.status()),
),
Err(e) => {
AuthReadinessCheck::fail("jwks_reachability", format!("JWKS URL unreachable: {e}"))
}
}
}
#[cfg(not(feature = "http-client"))]
{
let _ = url;
AuthReadinessCheck::pass(
"jwks_reachability",
"JWKS URL configured but not probed (http-client feature disabled)",
)
}
}
#[cfg(test)]
mod tests {
use super::*;
const RS256_PRIVATE_PEM: &str = include_str!("../../testdata/jwt_rs256_private.pem");
fn config_with_private_key(key: Option<&str>) -> SecurityConfig {
SecurityConfig {
jwt_private_key: key.map(ToString::to_string),
..SecurityConfig::default()
}
}
#[tokio::test]
async fn good_rsa_private_key_passes_signing_check() {
let security = config_with_private_key(Some(RS256_PRIVATE_PEM));
let report = check_auth_readiness(&security).await;
let check = report
.checks
.iter()
.find(|c| c.name == "jwt_signing_key")
.expect("signing-key check should run when a private key is configured");
assert!(check.ok, "good RSA key should pass: {}", check.detail);
assert!(!check.detail.contains("BEGIN"));
}
#[tokio::test]
async fn garbage_private_key_fails_signing_check() {
let security = config_with_private_key(Some("-----BEGIN PRIVATE KEY-----\nnot-a-key\n"));
let report = check_auth_readiness(&security).await;
let check = report
.checks
.iter()
.find(|c| c.name == "jwt_signing_key")
.expect("signing-key check should run when a private key is configured");
assert!(!check.ok, "garbage key must fail");
assert!(!report.ok, "report should not be ok when a check fails");
}
#[tokio::test]
async fn default_casbin_model_validates() {
let report = check_auth_readiness(&SecurityConfig::default()).await;
let casbin = report
.checks
.iter()
.find(|c| c.name == "casbin_model")
.expect("casbin check always runs");
assert!(
casbin.ok,
"default Casbin model should validate: {}",
casbin.detail
);
let issuance = report
.checks
.iter()
.find(|c| c.name == "token_issuance")
.expect("token-issuance check runs when no key/JWKS is set");
assert!(issuance.ok);
let audit = report
.checks
.iter()
.find(|c| c.name == "audit_sink")
.expect("audit_sink check always runs");
assert!(audit.ok);
assert!(report.ok, "default dev config should be ready: {report:?}");
}
#[tokio::test]
async fn non_http_audit_sink_fails_readiness() {
let security = SecurityConfig {
audit_sink_url: "ftp://bad-sink/ingest".to_string(),
..SecurityConfig::default()
};
let report = check_auth_readiness(&security).await;
let check = report
.checks
.iter()
.find(|c| c.name == "audit_sink")
.expect("audit_sink check runs");
assert!(!check.ok, "a non-http(s) audit sink URL must fail");
assert!(!report.ok, "report must not be ok when a check fails");
}
#[tokio::test]
async fn https_audit_sink_passes_readiness() {
let security = SecurityConfig {
audit_sink_url: "https://audit.example.com/ingest".to_string(),
..SecurityConfig::default()
};
let report = check_auth_readiness(&security).await;
let check = report
.checks
.iter()
.find(|c| c.name == "audit_sink")
.expect("audit_sink check runs");
assert!(check.ok, "https audit sink should pass: {}", check.detail);
}
#[test]
fn noop_audit_sink_is_dev_only() {
let dev = check_audit_sink("noop://audit", false);
assert!(dev.ok, "noop sink should be accepted in dev posture");
let hardened = check_audit_sink("noop://audit", true);
assert!(
!hardened.ok,
"noop sink must fail under production/fail-closed posture"
);
}
#[tokio::test]
async fn dependency_posture_probes_all_run() {
let report = check_auth_readiness(&SecurityConfig::default()).await;
for name in [
"auth_schema_migration",
"session_revocation_store",
"outbox_table",
"policy_snapshot",
"signing_key_registry",
"revocation_subscription",
"idp_provider_support",
] {
assert!(
report.checks.iter().any(|c| c.name == name),
"posture probe {name} must run; report={report:?}"
);
}
}
#[test]
fn idp_support_matrix_names_unsupported_kinds() {
let check = check_idp_provider_support();
assert!(check.ok, "support-matrix probe is informational (passes)");
for kind in ["OIDC", "SAML", "LDAP", "CUSTOM_JWT", "EXTERNAL_SESSION"] {
assert!(
check.detail.contains(kind),
"support matrix must mention {kind}: {}",
check.detail
);
}
}
#[test]
fn signing_key_registry_reports_env_fallback_when_empty() {
let check = check_signing_key_registry();
assert!(check.ok, "registry posture should pass: {}", check.detail);
}
}