Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
278 changes: 272 additions & 6 deletions Cargo.lock

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions common/src/api/external/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,8 @@ pub enum ResourceType {
Fleet,
Silo,
SiloUser,
SiloIdentityProvider,
SiloSamlIdentityProvider,
ConsoleSession,
Organization,
Project,
Expand Down Expand Up @@ -1680,6 +1682,36 @@ pub struct NetworkInterface {
// V6 address, at least one of which must be specified.
}

/// A SAML configuration specifies both IDP and SP details
#[derive(Clone, Debug, Serialize, JsonSchema, Deserialize)]
pub struct SiloSamlIdentityProvider {
#[serde(flatten)]
pub identity: IdentityMetadata,

/// url where identity provider metadata descriptor is
pub idp_metadata_url: String,

/// idp's entity id
pub idp_entity_id: String,

/// sp's client id
pub sp_client_id: String,

/// service provider endpoint where the response will be sent
pub acs_url: String,

/// service provider endpoint where the idp should send log out requests
pub slo_url: String,

/// customer's technical contact for saml configuration
pub technical_contact_email: String,

/// optional request signing key pair (base64 encoded der files)
pub public_cert: Option<String>,
#[serde(skip_serializing)]
pub private_key: Option<String>,
}

#[cfg(test)]
mod test {
use super::RouteDestination;
Expand Down
41 changes: 39 additions & 2 deletions common/src/sql/dbinit.sql
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,6 @@ CREATE TABLE omicron.public.volume (
CREATE TABLE omicron.public.silo (
/* Identity metadata */
id UUID PRIMARY KEY,

name STRING(128) NOT NULL,
description STRING(512) NOT NULL,

Expand All @@ -202,7 +201,6 @@ CREATE UNIQUE INDEX ON omicron.public.silo (
* Silo users
*/
CREATE TABLE omicron.public.silo_user (
/* silo user id */
id UUID PRIMARY KEY,

silo_id UUID NOT NULL,
Expand All @@ -212,6 +210,45 @@ CREATE TABLE omicron.public.silo_user (
time_deleted TIMESTAMPTZ
);

/*
* Silo identity provider list
*/
CREATE TABLE omicron.public.silo_identity_provider (
silo_id UUID NOT NULL,
provider_type TEXT NOT NULL,
provider_id UUID NOT NULL,

PRIMARY KEY (silo_id, provider_id)
);

/*
* Silo SAML identity provider
*/
CREATE TABLE omicron.public.silo_saml_identity_provider (
/* Identity metadata */
id UUID PRIMARY KEY,
name STRING(128) NOT NULL,
description STRING(512) NOT NULL,

silo_id UUID NOT NULL,

idp_metadata_url TEXT NOT NULL,
idp_metadata_document_string TEXT NOT NULL,

idp_entity_id TEXT NOT NULL,
sp_client_id TEXT NOT NULL,
acs_url TEXT NOT NULL,
slo_url TEXT NOT NULL,
technical_contact_email TEXT NOT NULL,

public_cert TEXT,
private_key TEXT,

time_created TIMESTAMPTZ NOT NULL,
time_modified TIMESTAMPTZ NOT NULL,
time_deleted TIMESTAMPTZ
);

/*
* Organizations
*/
Expand Down
6 changes: 6 additions & 0 deletions nexus/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ cookie = "0.16"
crucible-agent-client = { git = "https://github.com/oxidecomputer/crucible", rev = "945daedb88cefa790f1d994b3a038b8fa9ac514a" }
# Tracking pending 2.0 version.
diesel = { git = "https://github.com/diesel-rs/diesel", rev = "ce77c382", features = ["postgres", "r2d2", "chrono", "serde_json", "network-address", "uuid"] }
flate2 = "1.0.22"
futures = "0.3.21"
headers = "0.3.7"
hex = "0.4.3"
Expand All @@ -29,6 +30,9 @@ libc = "0.2.119"
macaddr = { version = "1.0.1", features = [ "serde_std" ]}
mime_guess = "2.0.4"
newtype_derive = "0.1.6"
openssl = "0.10"
openssl-sys = "0.9"
openssl-probe = "0.1.2"
oso = "0.26"
oximeter-client = { path = "../oximeter-client" }
oximeter-db = { path = "../oximeter/db/" }
Expand All @@ -39,6 +43,7 @@ rand = "0.8.5"
ref-cast = "1.0"
reqwest = { version = "0.11.8", features = [ "json" ] }
ring = "0.16"
samael = { git = "https://github.com/njaremko/samael", features = ["xmlsec"], rev = "441a244120eeb5995b2e47a52dc1beafa890d2b2" }
serde_json = "1.0"
serde_urlencoded = "0.7.1"
serde_with = "1.12.0"
Expand Down Expand Up @@ -124,6 +129,7 @@ openapiv3 = "1.0"
regex = "1.5.5"
subprocess = "0.2.8"
term = "0.7"
httptest = "0.15.4"

[dev-dependencies.openapi-lint]
git = "https://github.com/oxidecomputer/openapi-lint"
Expand Down
1 change: 1 addition & 0 deletions nexus/src/authn/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@

pub mod external;
pub mod saga;
pub mod silos;

pub use crate::db::fixed_data::user_builtin::USER_DB_INIT;
pub use crate::db::fixed_data::user_builtin::USER_INTERNAL_API;
Expand Down
152 changes: 152 additions & 0 deletions nexus/src/authn/silos.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

//! Silo related authentication types and functions

use crate::db::model::SiloSamlIdentityProvider;

use anyhow::{anyhow, bail, Result};
use samael::metadata::ContactPerson;
use samael::metadata::ContactType;
use samael::metadata::EntityDescriptor;
use samael::metadata::NameIdFormat;
use samael::metadata::HTTP_REDIRECT_BINDING;
use samael::service_provider::ServiceProvider;
use samael::service_provider::ServiceProviderBuilder;

pub enum SiloIdentityProviderType {
Local,
Ldap,
Saml(Box<SiloSamlIdentityProvider>),
}

impl SiloSamlIdentityProvider {
/// return an error if this SiloSamlIdentityProvider is invalid
pub fn validate(&self) -> Result<()> {
// check that the idp metadata document string parses into an EntityDescriptor
let _idp_metadata: EntityDescriptor =
self.idp_metadata_document_string.parse()?;

// check that there is a valid sign in url
let _sign_in_url = self.sign_in_url()?;

// if keys were supplied, check that both public and private are here
if self.get_public_cert_bytes()?.is_some()
&& self.get_private_key_bytes()?.is_none()
{
bail!("public and private key must be supplied together");
}
if self.get_public_cert_bytes()?.is_none()
&& self.get_private_key_bytes()?.is_some()
{
bail!("public and private key must be supplied together");
}

// XXX validate DER keys

Ok(())
}

pub fn sign_in_url(&self) -> Result<String> {
let idp_metadata: EntityDescriptor =
self.idp_metadata_document_string.parse()?;

// return the *first* SSO HTTP-Redirect binding URL in the IDP metadata:
//
// <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://..."/>
let sso_descriptors = idp_metadata
.idp_sso_descriptors
.as_ref()
.ok_or_else(|| anyhow!("no IDPSSODescriptor"))?;

if sso_descriptors.is_empty() {
return Err(anyhow!("zero SSO descriptors"));
}

// Currently, we only support redirect binding
let redirect_binding_locations = sso_descriptors[0]
.single_sign_on_services
.iter()
.filter(|x| x.binding == HTTP_REDIRECT_BINDING)
.map(|x| x.location.clone())
.collect::<Vec<String>>();

if redirect_binding_locations.is_empty() {
return Err(anyhow!("zero redirect binding locations"));
}

let redirect_url = redirect_binding_locations[0].clone();

// Create the authn request
let provider = self.make_service_provider(idp_metadata)?;
let authn_request = provider
.make_authentication_request(&redirect_url)
.map_err(|e| anyhow!(e.to_string()))?;

// XXX relay state
let encoded_relay_state = "";

let authn_request_url =
if let Some(key) = self.get_private_key_bytes()? {
// sign authn request if keys were supplied
authn_request.signed_redirect(&encoded_relay_state, &key)
} else {
authn_request.redirect(&encoded_relay_state)
}
.map_err(|e| anyhow!(e.to_string()))?
.ok_or_else(|| anyhow!("request url was none!".to_string()))?;

Ok(authn_request_url.to_string())
}

fn make_service_provider(
&self,
idp_metadata: EntityDescriptor,
) -> Result<ServiceProvider> {
let mut sp_builder = ServiceProviderBuilder::default();
sp_builder.entity_id(self.sp_client_id.clone());
sp_builder.allow_idp_initiated(true);
sp_builder.contact_person(ContactPerson {
email_addresses: Some(vec![self.technical_contact_email.clone()]),
contact_type: Some(ContactType::Technical.value().to_string()),
..ContactPerson::default()
});
sp_builder.idp_metadata(idp_metadata);

sp_builder.authn_name_id_format(
// Note: requesting persistent format relies on working user import!
// XXX make this customizable!
Some(NameIdFormat::EmailAddressNameIDFormat.value().into()),
);

sp_builder.acs_url(self.acs_url.clone());
sp_builder.slo_url(self.slo_url.clone());

if let Some(cert) = &self.public_cert {
if let Ok(decoded) = base64::decode(cert.as_bytes()) {
if let Ok(parsed) = openssl::x509::X509::from_der(&decoded) {
sp_builder.certificate(Some(parsed));
}
}
}

Ok(sp_builder.build()?)
}

fn get_public_cert_bytes(&self) -> Result<Option<Vec<u8>>> {
if let Some(cert) = &self.public_cert {
Ok(Some(base64::decode(cert.as_bytes())?))
} else {
Ok(None)
}
}

fn get_private_key_bytes(&self) -> Result<Option<Vec<u8>>> {
if let Some(key) = &self.private_key {
Ok(Some(base64::decode(key.as_bytes())?))
} else {
Ok(None)
}
}
}
Loading