Pure-Rust SAML SP (bergshamra), Advanced tab folded into Settings, SMB+NFS merged into a Remote shares card with a protocol dropdown. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
200 lines
9.3 KiB
Markdown
200 lines
9.3 KiB
Markdown
# OpenPXE v0.5.1 — SAML SSO wiring + Settings/Storage UI consolidation
|
|
|
|
**Date:** 2026-05-31
|
|
**Author:** Miles Ward (with Claude)
|
|
**Status:** Approved design → implementation
|
|
|
|
## Summary
|
|
|
|
Three workstreams for v0.5.1:
|
|
|
|
1. **Wire SAML 2.0 SSO** end-to-end (currently config is persisted but no runtime
|
|
sign-in exists). Pure-Rust implementation that preserves the static-musl /
|
|
no-OpenSSL architecture, mirroring how FleetDM exposes and handles SAML.
|
|
2. **Fold the Advanced sidebar tab into Settings** as a collapsible section.
|
|
3. **Merge the Storage tab's SMB and NFS cards** into one "Remote shares" card
|
|
with a protocol dropdown.
|
|
|
|
Then bump `0.5.0 → 0.5.1`, build the static musl image, push `:0.5.1` + `:latest`
|
|
to Gitea, create the release, and scrub registry credentials.
|
|
|
|
## Decisions (locked with the user)
|
|
|
|
- **Crypto:** pure-Rust via `bergshamra` (XML-DSig + exclusive c14n, RustCrypto-based,
|
|
`#![forbid(unsafe_code)]`, ~99% xmlsec interop). `samael` is rejected — it
|
|
hard-requires OpenSSL/`xmlsec`/`libxml2` C deps, which would break the static
|
|
musl binary and the project's pure-Rust / no-OpenSSL architecture.
|
|
- **Access model:** any SAML assertion the IdP successfully authenticates and that
|
|
we cryptographically verify mints a full operator session. No user table, no
|
|
roles, no domain allowlist. The local admin account remains a guaranteed
|
|
fallback owner regardless of SSO state.
|
|
- **Flows:** SP-initiated (the "Sign in with <IdP>" button) is always on.
|
|
IdP-initiated is supported but gated behind an `allow_idp_initiated` toggle
|
|
(default off), mirroring FleetDM's "Allow SSO login initiated by identity
|
|
provider."
|
|
|
|
## Scope boundaries (v0.5.1)
|
|
|
|
In scope: SP-initiated + (gated) IdP-initiated login, signature verification on the
|
|
SAML Response/Assertion, full SP-side semantic validation, SP metadata endpoint,
|
|
login-page button wiring.
|
|
|
|
Out of scope (note for later releases): EncryptedAssertion (assertions must be
|
|
unencrypted), signed AuthnRequests (sent unsigned; Keycloak "client signature
|
|
required" must be off), Single Logout (SLO), multi-user accounts / RBAC / JIT role
|
|
mapping.
|
|
|
|
---
|
|
|
|
## Workstream 1 — SAML SP wiring (pure-Rust)
|
|
|
|
### New dependencies (workspace)
|
|
|
|
- `bergshamra` — XML-DSig verification + exclusive c14n (pure Rust).
|
|
- `roxmltree` (read/navigate) and/or `quick-xml` (build/serialize) — parse IdP
|
|
metadata + SAMLResponse, build AuthnRequest and SP metadata.
|
|
- `x509-parser` — extract the IdP signing certificate / public key from metadata.
|
|
- `flate2` — raw DEFLATE for the HTTP-Redirect binding.
|
|
- `base64` — encode/decode SAMLRequest/SAMLResponse.
|
|
|
|
All pure-Rust → the `x86_64-unknown-linux-musl` static build stays OpenSSL-free.
|
|
Exact `bergshamra` function signatures (`verify`, `DsigContext`, `KeysManager`,
|
|
`Key`, `VerifiedReference`, `VerifyResult`) will be pinned against the installed
|
|
crate source during implementation.
|
|
|
|
### Module boundaries
|
|
|
|
Pure protocol logic lives in `openpxe-core` (no axum dependency, unit-testable);
|
|
HTTP wiring lives in `openpxe-http-api`.
|
|
|
|
- `crates/core/src/saml/mod.rs` — public surface + shared types
|
|
(`VerifiedPrincipal { email, display_name, name_id, session_index }`, `SamlError`).
|
|
- `crates/core/src/saml/metadata.rs` — parse IdP `EntityDescriptor`: IdP EntityID,
|
|
`SingleSignOnService` locations + bindings, and one or more X.509 signing
|
|
certificates. Also build **our** SP metadata XML.
|
|
- `crates/core/src/saml/authn_request.rs` — build an AuthnRequest, return both the
|
|
request ID (to track) and the encoded HTTP-Redirect query value
|
|
(deflate → base64 → URL-encode).
|
|
- `crates/core/src/saml/response.rs` — decode `SAMLResponse` (base64 → XML),
|
|
**verify the signature via bergshamra** against the IdP cert, then enforce SP
|
|
semantics, returning `VerifiedPrincipal` or a typed `SamlError`.
|
|
|
|
### SP-side validation (response.rs)
|
|
|
|
After a cryptographically valid signature over the Response and/or the Assertion:
|
|
|
|
1. `Status` is `Success`.
|
|
2. `Destination` (if present) equals our ACS URL.
|
|
3. `Conditions/AudienceRestriction/Audience` equals our SP EntityID.
|
|
4. `NotBefore` / `NotOnOrAfter` within bounds (allow small clock skew, e.g. ±60s).
|
|
5. `InResponseTo` matches an outstanding request we issued (SP-initiated). Absent
|
|
for IdP-initiated, which is only accepted when `allow_idp_initiated` is true.
|
|
6. Assertion-ID replay guard: reject a previously consumed assertion ID.
|
|
7. NameID is the email (`nameid-format:emailAddress`). Display name read from
|
|
common attributes (`name`, `displayname`, `cn`, `urn:oid:2.5.4.3`).
|
|
|
|
XML Signature Wrapping (XSW) defenses come from bergshamra (duplicate-ID rejection,
|
|
strict positional verification); enable its strict verification options. We
|
|
additionally confirm the verified `Reference` covers the element we read claims from.
|
|
|
|
### State (in `openpxe-http-api`)
|
|
|
|
Two small TTL-pruned in-memory stores (parking_lot `Mutex<HashMap<...>>`):
|
|
|
|
- **Outstanding requests:** `request_id → issued_at`, TTL ≈ 5 min, for `InResponseTo`.
|
|
- **Consumed assertions:** `assertion_id → expires_at`, TTL = assertion validity,
|
|
for replay protection.
|
|
|
|
(In-memory is acceptable: a single-container app; a restart simply invalidates
|
|
in-flight logins.)
|
|
|
|
### Routes (all pre-auth; added to the public allowlist in the auth middleware)
|
|
|
|
- `GET /api/sso/login` → build AuthnRequest, record its ID, 302 to the IdP SSO URL
|
|
(HTTP-Redirect binding) with `SAMLRequest` + `RelayState`.
|
|
- `POST /api/sso/acs` → consume `SAMLResponse` (form-encoded). Verify + validate.
|
|
On success: `SessionStore::create(email)`, set the `openpxe_session` cookie
|
|
(same attributes as forms login), 302 to the dashboard. On failure: 302 back to
|
|
the login page with an error indicator. (Mirrors FleetDM's `/sso/callback`.)
|
|
- `GET /api/sso/metadata` → serve our SP `EntityDescriptor` XML for IdP import.
|
|
|
|
### Config changes (`crates/core/src/sso.rs`)
|
|
|
|
Add to `SsoConfig` (preserve existing fields + validation):
|
|
|
|
- `entity_id: String` — SP Entity ID (mirrors FleetDM's "Entity ID"); defaults to
|
|
the configured public base URL. The ACS URL is derived as
|
|
`<public_base_url>/api/sso/acs`.
|
|
- `allow_idp_initiated: bool` — default `false`.
|
|
|
|
`GET /api/sso` returns the new fields; `PUT /api/sso` validates and persists them.
|
|
|
|
### Login page (`crates/webui/src/app.js`)
|
|
|
|
Replace the "configured · runtime pending" message: the existing
|
|
"Sign in with <IdP>" button navigates to `GET /api/sso/login`. Render the IdP logo
|
|
(if `idp_logo_url` set) and use `idp_name` as the label. Keep the existing
|
|
FleetDM-style login layout.
|
|
|
|
### Testing
|
|
|
|
- `core/saml` unit tests using a self-signed test keypair we control:
|
|
- Parse representative Keycloak IdP metadata → correct SSO URL + cert.
|
|
- Build an AuthnRequest → well-formed, deflate/base64 round-trips, ID recorded.
|
|
- A correctly signed Response → `VerifiedPrincipal { email, .. }`.
|
|
- Reject: tampered signature, expired (`NotOnOrAfter`), wrong audience,
|
|
replayed assertion ID, unsigned response, `Status != Success`.
|
|
- `http-api` integration test: `GET /api/sso/login` returns a 302 with a
|
|
`SAMLRequest` query param; a crafted signed `SAMLResponse` POSTed to
|
|
`/api/sso/acs` (signed with the test key) sets an `openpxe_session` cookie.
|
|
|
|
---
|
|
|
|
## Workstream 2 — Advanced tab → Settings
|
|
|
|
- Remove the `Advanced` sidebar entry (`crates/webui/src/index.html`) and its
|
|
`advanced` view route in `app.js`.
|
|
- In the Settings view, append a **collapsible "Advanced" disclosure**
|
|
(default-collapsed) at the bottom containing the existing **Webhook
|
|
Notifications** card and the **API reference** block (moved out of the removed
|
|
Advanced view).
|
|
- No backend changes; `/api/notify*` and `/api/docs` endpoints are unchanged.
|
|
|
|
---
|
|
|
|
## Workstream 3 — Storage: merge SMB + NFS → "Remote shares"
|
|
|
|
- Replace the separate "SMB shares" and "NFS shares" cards with a single
|
|
**"Remote shares"** card:
|
|
- One add-form with a **protocol dropdown (SMB / NFS)**. Selecting the protocol
|
|
swaps the fields: SMB → server, share, guest checkbox, username, password;
|
|
NFS → server, export path.
|
|
- One unified table with a leading **Protocol** column (SMB/NFS badge), then
|
|
server/share-or-export, auth, ISO count, reachability, and Re-scan / Remove
|
|
actions.
|
|
- **No backend changes.** The form dispatches to the existing
|
|
`POST /api/smb-shares` or `POST /api/nfs-shares`; the table merges
|
|
`GET /api/smb-shares` + `GET /api/nfs-shares`, tagging each row with its
|
|
protocol. Re-scan/Remove call the existing per-protocol endpoints.
|
|
- Leaves the card pattern open for a future "Config files" card.
|
|
|
|
---
|
|
|
|
## Release
|
|
|
|
1. Bump workspace version `0.5.0 → 0.5.1` (`Cargo.toml`).
|
|
2. `cargo fmt`, `cargo clippy`, `cargo test` (all crates) green.
|
|
3. Build the static musl binary + Docker image; verify SAML deps compile clean
|
|
under musl (no OpenSSL/C linkage).
|
|
4. Push `openpxe:0.5.1` + `openpxe:latest` to Gitea via the established
|
|
temp-DOCKER_CONFIG pipeline; scrub credentials (logout + verify no token traces).
|
|
5. Create the Gitea release `v0.5.1` with notes.
|
|
|
|
## Risks
|
|
|
|
- `bergshamra` is pre-1.0 and unaudited. Mitigation: pin the version, enable strict
|
|
verification, keep the local-admin fallback, and own the SP-semantic checks
|
|
carefully (audience/Conditions/replay/InResponseTo — where SP vulns usually live).
|
|
- SAML is security-sensitive; negative tests (tamper/expiry/audience/replay/unsigned)
|
|
are part of the definition of done, not optional.
|