PXE boot menu polish (iVentoy-inspired):
- render_menu now opens with a best-effort `console --picture
<base>/branding/pxe-logo || console` line so iPXE builds with PNG
support paint the operator's uploaded raster logo as the background.
- ASCII OpenPXE wordmark banner sits at the top of the menu in
`item --gap` lines — always visible on every iPXE build, including
the snponly/undionly variants without graphics console.
- New footer line above `choose`: "OpenPXE v0.4.6 - <arch label>",
where <arch label> is mapped from iPXE's ${buildarch}/${platform}
to "x86 BIOS", "x86_64 UEFI", or "arm64 UEFI". No URL, per brief.
- New GET /branding/pxe-logo route serves the operator's PNG / JPEG /
WebP / GIF as-is for iPXE to consume. SVG uploads 404 here (iPXE
can't rasterize SVG) — the always-visible ASCII wordmark stands in.
Route stays public after admin setup so iPXE clients (no cookies)
can fetch it.
UI:
- Removed the bottom-left "signed in as / Sign out" row.
- Added a person-icon button next to the theme toggle in the topbar.
Click opens a small popover with: Name (display only), Edit account
(jumps to Settings), Sign out. Esc + click-outside close it.
- Settings → Account card form chrome made consistent. The previous
`label.field` selector only styled type=text/number, leaving
password inputs with default browser chrome. Switched to a
negation-list selector that covers every typed input we use, plus
-webkit-appearance:none + a 1px focus ring. Light + dark mode both
show the same border/padding/focus state across all four account
fields.
- Settings → SSO card now renders display name, IdP logo URL (new),
and metadata source on one 3-column row. The metadata <select>
inherits the same chrome as the text inputs so it baseline-aligns
with them. SsoConfig grew an idp_logo_url field, persisted to
sso.json, length-capped and validated to http(s) only.
Quality:
- 138 tests passing (was 132 in v0.4.5). +1 IdP-logo-URL validation,
+1 PXE menu polish regression guard, +4 /branding/pxe-logo
integration tests covering missing-config / SVG-fallback / raster-
serve / post-auth public-allowlist cases.
- cargo clippy --workspace --all-targets clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
319 lines
12 KiB
Rust
319 lines
12 KiB
Rust
//! SAML SSO configuration — FleetDM-shaped, storage-only for v0.4.5.
|
|
//!
|
|
//! The operator pastes their IdP's metadata XML (or its URL) and a
|
|
//! human-readable label; v0.4.5 just persists it. The actual SAML
|
|
//! response-validation / JIT-provisioning flow lands in a later release
|
|
//! — for now we cover the "configurable" half so an operator can teach
|
|
//! OpenPXE about their IdP today and flip the switch on next upgrade.
|
|
//!
|
|
//! Shape borrowed from <https://github.com/fleetdm/fleet>'s app-config
|
|
//! SSO block, minus the user-RBAC fields (OpenPXE is single-tier: you
|
|
//! have access or you don't). Entity ID is omitted from the operator
|
|
//! UI per the v0.4.5 brief — it defaults to the advertised public base
|
|
//! URL when SAML wiring lands, 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,
|
|
}
|
|
|
|
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())
|
|
}
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
|
|
/// 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();
|
|
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(),
|
|
})
|
|
.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(),
|
|
})
|
|
.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(),
|
|
});
|
|
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(),
|
|
});
|
|
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(),
|
|
});
|
|
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(),
|
|
})
|
|
.unwrap();
|
|
assert_eq!(s.snapshot().idp_logo_url, "https://idp.example.com/logo.png");
|
|
}
|
|
|
|
#[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(),
|
|
});
|
|
assert!(matches!(r, Err(Error::Invalid(_))));
|
|
}
|
|
}
|