Storage / boot detection - Add El Torito boot-catalog detection to ISO introspection. This is the authoritative "can this boot at all?" signal: any ISO with a boot catalog (BSDs, ESXi, firmware tools, custom spins) is bootable via iPXE sanboot; a data/appliance ISO (e.g. a VMware vCenter bundle) has none and is honestly flagged. Replaces the crude ">1.5 GB ⇒ unbootable" size guess. - Re-introspect stale LOCAL ISOs on startup via an introspection-revision gate (INTROSPECT_REV). ISOs uploaded by an older binary carried a frozen family/boot profile — most visibly a Windows 11 ISO tagged Unknown before the UDF/UTF-16 detection landed, which then showed "won't boot" forever. An upgrade now re-probes and fixes them in place; no delete-and-re-upload. - WebUI bootability() keys off family / kernel / el_torito / remote-source instead of the size heuristic; dashboard family counts now bucket Windows / Linux / other honestly instead of lumping everything non-Windows under "Linux". SSO login button - The "Sign in with …" button keyed off the auth-gated /api/sso, which 401s pre-auth — so the button only survived on a stale in-memory config and vanished instance-wide on any fresh login-page load. Ship a minimal, non-sensitive SSO descriptor (enabled + idp_name + idp_logo_url, no metadata/entity-ID) on the public /api/me; the login card reads that. The button is now static whenever SSO is usable. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
421 lines
16 KiB
Rust
421 lines
16 KiB
Rust
//! SAML SSO configuration — FleetDM-shaped.
|
|
//!
|
|
//! The operator pastes their IdP's metadata XML (or its URL) and a
|
|
//! human-readable label. As of v0.5.1 the SAML login flow is wired
|
|
//! end-to-end (see [`crate::saml`]): SP-initiated AuthnRequest, the ACS
|
|
//! endpoint, pure-Rust signature verification, and operator-session
|
|
//! minting. This module owns only the persisted *configuration*.
|
|
//!
|
|
//! Shape borrowed from <https://github.com/fleetdm/fleet>'s app-config
|
|
//! SSO block, minus the user-RBAC fields (OpenPXE is single-tier: any
|
|
//! IdP-authenticated user the SP cryptographically verifies gets an
|
|
//! operator session; there is no per-user role table). Entity ID is
|
|
//! exposed (FleetDM-style) but defaults to the advertised public base
|
|
//! URL when blank, which is what most IdPs expect anyway.
|
|
|
|
use parking_lot::RwLock;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
|
|
use crate::{Error, Result};
|
|
|
|
/// The configurable surface. `metadata` and `metadata_url` are mutually
|
|
/// exclusive at apply time (one or the other identifies the IdP); the
|
|
/// store keeps both fields so an operator can switch between them
|
|
/// without losing the inactive one.
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
pub struct SsoConfig {
|
|
/// Master switch — when false, all SSO machinery (planned for a
|
|
/// later release) is skipped regardless of the rest of the fields.
|
|
#[serde(default)]
|
|
pub enabled: bool,
|
|
/// Display name shown on the WebUI's login screen as the "Sign in
|
|
/// with X" button label. Empty/whitespace falls back to "SSO".
|
|
#[serde(default)]
|
|
pub idp_name: String,
|
|
/// Optional HTTPS URL pointing at the IdP's brand logo. Rendered
|
|
/// next to `idp_name` on the WebUI's login screen (FleetDM-style).
|
|
/// Length-capped at [`MAX_URL_LEN`]; empty is fine.
|
|
#[serde(default)]
|
|
pub idp_logo_url: String,
|
|
/// Raw SAML metadata XML pasted by the operator. Mutually exclusive
|
|
/// with `metadata_url`; if both are set, the URL wins at apply time
|
|
/// (operators typically forget about a stale XML paste).
|
|
#[serde(default)]
|
|
pub metadata: String,
|
|
/// HTTPS URL where the IdP serves its metadata. Loaded lazily by the
|
|
/// future SAML flow; not validated here beyond a basic length cap.
|
|
#[serde(default)]
|
|
pub metadata_url: String,
|
|
/// SP Entity ID advertised to the IdP — mirrors FleetDM's "Entity ID".
|
|
/// Must exactly match the SP/Relying-Party entry configured on the IdP.
|
|
/// Empty falls back to the advertised public base URL at runtime, which
|
|
/// is what most IdPs expect. Length-capped at [`MAX_URL_LEN`].
|
|
#[serde(default)]
|
|
pub entity_id: String,
|
|
/// Allow IdP-initiated login — an unsolicited `<Response>` POSTed to the
|
|
/// ACS with no `InResponseTo`. Mirrors FleetDM's "Allow SSO login
|
|
/// initiated by identity provider". Default off; SP-initiated (the
|
|
/// "Sign in with X" button) is always allowed regardless.
|
|
#[serde(default)]
|
|
pub allow_idp_initiated: bool,
|
|
}
|
|
|
|
impl SsoConfig {
|
|
/// Returns `true` only when the config is *usable* — enabled, and
|
|
/// at least one of metadata/metadata_url is present. The future
|
|
/// login flow will key off this; for v0.4.5 the WebUI uses it to
|
|
/// surface a yellow "configured but not live yet" hint.
|
|
#[must_use]
|
|
pub fn is_usable(&self) -> bool {
|
|
self.enabled && (!self.metadata.trim().is_empty() || !self.metadata_url.trim().is_empty())
|
|
}
|
|
}
|
|
|
|
/// The minimal, non-sensitive slice of the SSO config that the **pre-auth**
|
|
/// login screen needs to render the "Sign in with …" button. Carries only
|
|
/// the display affordances — never the metadata XML/URL or entity ID, which
|
|
/// stay behind the auth-gated `/api/sso`. Served as part of the public
|
|
/// `/api/me` so the button renders reliably whether or not anyone is signed
|
|
/// in (v0.5.9: fixes the button vanishing because `/api/sso` 401s pre-auth).
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
pub struct SsoLoginInfo {
|
|
/// True only when SSO is *usable* (enabled AND a metadata source is
|
|
/// present) — i.e. clicking the button will actually reach an IdP.
|
|
pub enabled: bool,
|
|
/// Button label, e.g. "STC AD". Empty falls back to "SSO" in the UI.
|
|
pub idp_name: String,
|
|
/// Optional IdP logo rendered on the button. Empty = no image.
|
|
pub idp_logo_url: String,
|
|
}
|
|
|
|
/// In-memory + on-disk SSO settings registry.
|
|
#[derive(Debug, Clone)]
|
|
pub struct SsoStore {
|
|
path: Arc<PathBuf>,
|
|
inner: Arc<RwLock<SsoConfig>>,
|
|
}
|
|
|
|
impl SsoStore {
|
|
/// Load from `<work_dir>/sso.json`, or start with the default empty
|
|
/// (`enabled = false`) config. A corrupt file falls back to default
|
|
/// rather than blocking startup.
|
|
#[must_use]
|
|
pub fn load_or_default(work_dir: &std::path::Path) -> Self {
|
|
let path = work_dir.join("sso.json");
|
|
let cfg = match std::fs::read_to_string(&path) {
|
|
Ok(text) => match serde_json::from_str::<SsoConfig>(&text) {
|
|
Ok(parsed) => parsed,
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
target: "openpxe::sso",
|
|
"sso.json present but unreadable ({e}); starting with default config"
|
|
);
|
|
SsoConfig::default()
|
|
}
|
|
},
|
|
Err(_) => SsoConfig::default(),
|
|
};
|
|
Self {
|
|
path: Arc::new(path),
|
|
inner: Arc::new(RwLock::new(cfg)),
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn snapshot(&self) -> SsoConfig {
|
|
self.inner.read().clone()
|
|
}
|
|
|
|
/// Public, non-sensitive descriptor for the login screen. Safe to
|
|
/// expose pre-auth — it's exactly what the "Sign in with …" button
|
|
/// keys off, with no metadata/entity-ID leakage. v0.5.9.
|
|
#[must_use]
|
|
pub fn login_info(&self) -> SsoLoginInfo {
|
|
let cfg = self.inner.read();
|
|
SsoLoginInfo {
|
|
enabled: cfg.is_usable(),
|
|
idp_name: cfg.idp_name.clone(),
|
|
idp_logo_url: cfg.idp_logo_url.clone(),
|
|
}
|
|
}
|
|
|
|
/// Replace the whole config in one shot. Light validation: metadata
|
|
/// XML and URL are length-capped so an operator can't OOM us by
|
|
/// pasting a 10 GiB blob; the IdP UI tab clamps the input visually,
|
|
/// but the server enforces a hard ceiling regardless.
|
|
pub fn replace(&self, mut cfg: SsoConfig) -> Result<SsoConfig> {
|
|
cfg.idp_name = cfg.idp_name.trim().to_string();
|
|
cfg.idp_logo_url = cfg.idp_logo_url.trim().to_string();
|
|
cfg.metadata = cfg.metadata.trim().to_string();
|
|
cfg.metadata_url = cfg.metadata_url.trim().to_string();
|
|
cfg.entity_id = cfg.entity_id.trim().to_string();
|
|
if cfg.entity_id.len() > MAX_URL_LEN {
|
|
return Err(Error::Invalid(format!(
|
|
"entity_id exceeds {MAX_URL_LEN}-char cap"
|
|
)));
|
|
}
|
|
if cfg.metadata.len() > MAX_METADATA_BYTES {
|
|
return Err(Error::Invalid(format!(
|
|
"metadata XML exceeds {MAX_METADATA_BYTES}-byte cap"
|
|
)));
|
|
}
|
|
if cfg.metadata_url.len() > MAX_URL_LEN {
|
|
return Err(Error::Invalid(format!(
|
|
"metadata_url exceeds {MAX_URL_LEN}-char cap"
|
|
)));
|
|
}
|
|
if cfg.idp_logo_url.len() > MAX_URL_LEN {
|
|
return Err(Error::Invalid(format!(
|
|
"idp_logo_url exceeds {MAX_URL_LEN}-char cap"
|
|
)));
|
|
}
|
|
if !cfg.metadata_url.is_empty()
|
|
&& !cfg.metadata_url.starts_with("http://")
|
|
&& !cfg.metadata_url.starts_with("https://")
|
|
{
|
|
return Err(Error::Invalid(
|
|
"metadata_url must start with http:// or https://".into(),
|
|
));
|
|
}
|
|
if !cfg.idp_logo_url.is_empty()
|
|
&& !cfg.idp_logo_url.starts_with("http://")
|
|
&& !cfg.idp_logo_url.starts_with("https://")
|
|
{
|
|
return Err(Error::Invalid(
|
|
"idp_logo_url must start with http:// or https://".into(),
|
|
));
|
|
}
|
|
// If they're trying to *enable* the integration but haven't
|
|
// supplied either source, reject — saves a "configured but
|
|
// unusable" surprise later.
|
|
if cfg.enabled && cfg.metadata.is_empty() && cfg.metadata_url.is_empty() {
|
|
return Err(Error::Invalid(
|
|
"enable SSO requires either metadata XML or a metadata URL".into(),
|
|
));
|
|
}
|
|
{
|
|
let mut g = self.inner.write();
|
|
*g = cfg.clone();
|
|
}
|
|
self.persist();
|
|
tracing::info!(
|
|
target: "openpxe::sso",
|
|
enabled = cfg.enabled,
|
|
idp = %cfg.idp_name,
|
|
has_xml = !cfg.metadata.is_empty(),
|
|
has_url = !cfg.metadata_url.is_empty(),
|
|
"sso configuration updated"
|
|
);
|
|
Ok(cfg)
|
|
}
|
|
|
|
fn persist(&self) {
|
|
let snap = self.inner.read().clone();
|
|
let body = match serde_json::to_vec_pretty(&snap) {
|
|
Ok(b) => b,
|
|
Err(e) => {
|
|
tracing::warn!(target: "openpxe::sso", "serialize sso.json: {e}");
|
|
return;
|
|
}
|
|
};
|
|
if let Some(parent) = self.path.parent() {
|
|
let _ = std::fs::create_dir_all(parent);
|
|
}
|
|
let tmp = self.path.with_extension("json.tmp");
|
|
if let Err(e) = std::fs::write(&tmp, body) {
|
|
tracing::warn!(target: "openpxe::sso", "write sso.json tmp: {e}");
|
|
return;
|
|
}
|
|
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
|
|
tracing::warn!(target: "openpxe::sso", "rename sso.json: {e}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Saturation caps. The numbers are generous for any real IdP metadata
|
|
/// document — Okta's largest is ~50 KB, Azure AD's ~30 KB.
|
|
const MAX_METADATA_BYTES: usize = 1024 * 1024;
|
|
const MAX_URL_LEN: usize = 2048;
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::tempdir;
|
|
|
|
#[test]
|
|
fn default_is_disabled_and_empty() {
|
|
let dir = tempdir().unwrap();
|
|
let s = SsoStore::load_or_default(dir.path());
|
|
let cfg = s.snapshot();
|
|
assert!(!cfg.enabled);
|
|
assert!(cfg.metadata.is_empty());
|
|
assert!(cfg.metadata_url.is_empty());
|
|
assert!(!cfg.is_usable());
|
|
}
|
|
|
|
#[test]
|
|
fn replace_metadata_url_round_trip_via_disk() {
|
|
let dir = tempdir().unwrap();
|
|
let s = SsoStore::load_or_default(dir.path());
|
|
s.replace(SsoConfig {
|
|
enabled: true,
|
|
idp_name: "Okta".into(),
|
|
metadata: String::new(),
|
|
metadata_url: "https://idp.example.com/metadata".into(),
|
|
idp_logo_url: String::new(),
|
|
entity_id: String::new(),
|
|
allow_idp_initiated: false,
|
|
})
|
|
.unwrap();
|
|
drop(s);
|
|
let s2 = SsoStore::load_or_default(dir.path());
|
|
let cfg = s2.snapshot();
|
|
assert!(cfg.enabled);
|
|
assert!(cfg.is_usable());
|
|
assert_eq!(cfg.idp_name, "Okta");
|
|
assert_eq!(cfg.metadata_url, "https://idp.example.com/metadata");
|
|
}
|
|
|
|
#[test]
|
|
fn replace_xml_paste_is_accepted() {
|
|
let dir = tempdir().unwrap();
|
|
let s = SsoStore::load_or_default(dir.path());
|
|
let xml = r#"<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata">test</EntityDescriptor>"#;
|
|
s.replace(SsoConfig {
|
|
enabled: true,
|
|
idp_name: "Test IdP".into(),
|
|
metadata: xml.into(),
|
|
metadata_url: String::new(),
|
|
idp_logo_url: String::new(),
|
|
entity_id: String::new(),
|
|
allow_idp_initiated: false,
|
|
})
|
|
.unwrap();
|
|
assert!(s.snapshot().is_usable());
|
|
}
|
|
|
|
#[test]
|
|
fn enable_without_source_is_rejected() {
|
|
let dir = tempdir().unwrap();
|
|
let s = SsoStore::load_or_default(dir.path());
|
|
let r = s.replace(SsoConfig {
|
|
enabled: true,
|
|
idp_name: "Okta".into(),
|
|
metadata: String::new(),
|
|
metadata_url: String::new(),
|
|
idp_logo_url: String::new(),
|
|
entity_id: String::new(),
|
|
allow_idp_initiated: false,
|
|
});
|
|
assert!(matches!(r, Err(Error::Invalid(_))));
|
|
// …and a disabled blank config is fine.
|
|
s.replace(SsoConfig::default()).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn metadata_url_must_be_http_scheme() {
|
|
let dir = tempdir().unwrap();
|
|
let s = SsoStore::load_or_default(dir.path());
|
|
let r = s.replace(SsoConfig {
|
|
enabled: false,
|
|
idp_name: String::new(),
|
|
metadata: String::new(),
|
|
metadata_url: "ftp://idp.example.com/metadata".into(),
|
|
idp_logo_url: String::new(),
|
|
entity_id: String::new(),
|
|
allow_idp_initiated: false,
|
|
});
|
|
assert!(matches!(r, Err(Error::Invalid(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn idp_logo_url_must_be_http_scheme() {
|
|
// v0.4.6: SSO settings learned an idp_logo_url so the login
|
|
// screen can render the FleetDM-style "Sign in with <IdP-logo>"
|
|
// affordance. Same scheme rule as metadata_url.
|
|
let dir = tempdir().unwrap();
|
|
let s = SsoStore::load_or_default(dir.path());
|
|
let r = s.replace(SsoConfig {
|
|
enabled: false,
|
|
idp_name: "Okta".into(),
|
|
metadata: String::new(),
|
|
metadata_url: String::new(),
|
|
idp_logo_url: "data:image/png;base64,...".into(),
|
|
entity_id: String::new(),
|
|
allow_idp_initiated: false,
|
|
});
|
|
assert!(matches!(r, Err(Error::Invalid(_))));
|
|
// Real HTTPS URL is fine.
|
|
s.replace(SsoConfig {
|
|
enabled: false,
|
|
idp_name: "Okta".into(),
|
|
metadata: String::new(),
|
|
metadata_url: String::new(),
|
|
idp_logo_url: "https://idp.example.com/logo.png".into(),
|
|
entity_id: String::new(),
|
|
allow_idp_initiated: false,
|
|
})
|
|
.unwrap();
|
|
assert_eq!(
|
|
s.snapshot().idp_logo_url,
|
|
"https://idp.example.com/logo.png"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn entity_id_and_idp_initiated_round_trip() {
|
|
// v0.5.1: SP Entity ID + IdP-initiated toggle persist across reload.
|
|
let dir = tempdir().unwrap();
|
|
let s = SsoStore::load_or_default(dir.path());
|
|
s.replace(SsoConfig {
|
|
enabled: true,
|
|
idp_name: "Keycloak".into(),
|
|
metadata: String::new(),
|
|
metadata_url: "https://idp.example.com/metadata".into(),
|
|
idp_logo_url: String::new(),
|
|
entity_id: "https://pxe.example.com".into(),
|
|
allow_idp_initiated: true,
|
|
})
|
|
.unwrap();
|
|
drop(s);
|
|
let cfg = SsoStore::load_or_default(dir.path()).snapshot();
|
|
assert_eq!(cfg.entity_id, "https://pxe.example.com");
|
|
assert!(cfg.allow_idp_initiated);
|
|
}
|
|
|
|
#[test]
|
|
fn entity_id_cap_enforced() {
|
|
let dir = tempdir().unwrap();
|
|
let s = SsoStore::load_or_default(dir.path());
|
|
let r = s.replace(SsoConfig {
|
|
enabled: false,
|
|
idp_name: String::new(),
|
|
metadata: String::new(),
|
|
metadata_url: String::new(),
|
|
idp_logo_url: String::new(),
|
|
entity_id: "x".repeat(MAX_URL_LEN + 1),
|
|
allow_idp_initiated: false,
|
|
});
|
|
assert!(matches!(r, Err(Error::Invalid(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn metadata_size_cap_enforced() {
|
|
let dir = tempdir().unwrap();
|
|
let s = SsoStore::load_or_default(dir.path());
|
|
let oversize = "a".repeat(MAX_METADATA_BYTES + 1);
|
|
let r = s.replace(SsoConfig {
|
|
enabled: false,
|
|
idp_name: String::new(),
|
|
metadata: oversize,
|
|
metadata_url: String::new(),
|
|
idp_logo_url: String::new(),
|
|
entity_id: String::new(),
|
|
allow_idp_initiated: false,
|
|
});
|
|
assert!(matches!(r, Err(Error::Invalid(_))));
|
|
}
|
|
}
|