Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for SAML as a Silo IdP, part 1 #994

Closed
wants to merge 58 commits into from
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
365050a
Support for SAML as a Silo IdP, part 1
jmpesp Apr 29, 2022
81dd77f
use impl_enum_type for SiloIdentityProviderType
jmpesp Apr 29, 2022
13b091d
install libxmlsec1-openssl in Dockerfile
jmpesp Apr 29, 2022
1e87918
impl ToString in the impl_enum_type macro
jmpesp Apr 29, 2022
6be4f9f
xmlsec1 too
jmpesp Apr 29, 2022
ed33499
try installing xmlsec1 in check-omicron-deployment
jmpesp Apr 29, 2022
f55fada
put package installs into install_prerequisites.sh
jmpesp Apr 29, 2022
ffc9f96
Merge remote-tracking branch 'upstream/main' into silo_authn_providers
jmpesp May 2, 2022
7595933
no more full table scans
jmpesp May 3, 2022
2566741
Merge remote-tracking branch 'upstream/main' into silo_authn_providers
jmpesp May 4, 2022
b578a99
install library/libxmlsec1 on helios
jmpesp May 4, 2022
463b1b6
update silo identity provider diesel code
jmpesp May 4, 2022
99bcf08
./tools/install_prerequisites.sh for clippy-lint
jmpesp May 4, 2022
a12939b
update PATH
jmpesp May 4, 2022
3ec5827
install libxmlsec1-dev for pkg-config files
jmpesp May 5, 2022
3eb6e98
Merge remote-tracking branch 'upstream/main' into silo_authn_providers
jmpesp May 5, 2022
115ffdd
fmt
jmpesp May 5, 2022
3f8727a
more prereqs for actions
jmpesp May 5, 2022
6fbfec7
apparently helios needs clang?
jmpesp May 5, 2022
c2a8633
Merge remote-tracking branch 'upstream/main' into silo_authn_providers
jmpesp May 5, 2022
531c39f
bindgen uses libclang
jmpesp May 5, 2022
f92964f
add libxmlsec1-dev, put on separate lines
jmpesp May 5, 2022
a93ed10
expand SAML related acronyms
jmpesp May 5, 2022
eb0ed4b
unpin samael crate
jmpesp May 5, 2022
4cd4c55
cargo.lock update
jmpesp May 5, 2022
2ef7da6
remove redundant index on silo_identity_provider, make delete soft
jmpesp May 6, 2022
e77353c
reorder external-authenticator permission grants together
jmpesp May 6, 2022
b304994
correct primary key columns
jmpesp May 6, 2022
0afecd6
add test_impl_enum_type_to_string
jmpesp May 6, 2022
0bf0847
revert to apt-get
jmpesp May 6, 2022
2e35e06
remove local, ldap provider types
jmpesp May 6, 2022
7c1fbe4
fmt
jmpesp May 6, 2022
044c82d
further remove local and ldap
jmpesp May 6, 2022
d835c45
pool_authorized, plus opctx check for silo create child perm
jmpesp May 6, 2022
8a6f954
ErrorHandler::NotFoundByResource
jmpesp May 6, 2022
b083fe5
change to String::from_utf8_lossy
jmpesp May 6, 2022
8571681
remove local and ldap from dbinit
jmpesp May 6, 2022
1ed412e
fmt
jmpesp May 6, 2022
74521e8
turns out I can add id without test failure...?
jmpesp May 9, 2022
813876a
use ErrorHandler::Conflict
jmpesp May 9, 2022
56320f3
better comment
jmpesp May 9, 2022
6597d67
use lazy_static http server, restore unauthorized_coverage
jmpesp May 9, 2022
c9d369c
properly timeout, and return better error messages for idp urls
jmpesp May 9, 2022
36d3588
saml_identity_provider -> saml_identity_providers
jmpesp May 9, 2022
5f352d0
add view for SiloSamlIdentityProvider
jmpesp May 9, 2022
fa10a10
deserialize_with public cert and private keys
jmpesp May 11, 2022
9593b8f
clippy and fmt
jmpesp May 11, 2022
378b2c1
Add authn::SiloSamlIdentityProvider
jmpesp May 11, 2022
dab03ea
Drop Silo prefix on types
jmpesp May 11, 2022
46de770
add error context with try_into fails
jmpesp May 13, 2022
6c42a7c
typo
jmpesp May 16, 2022
3393799
add regular identity to identity provider
jmpesp May 18, 2022
2f45e1a
add support for listing identity providers
jmpesp May 18, 2022
fc0522d
larger request_body_max_bytes for SAML IdP payload
jmpesp May 19, 2022
302f878
get by with samael patch and "cargo update -p clang-sys" for now
jmpesp May 19, 2022
140f697
uncomment test_listing_identity_providers
jmpesp May 24, 2022
f9e2011
add list_identity_providers permission
jmpesp May 25, 2022
609a7e1
use lazy_static for saml identity provider stuff
jmpesp May 25, 2022
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
254 changes: 248 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,
SshKey,
ConsoleSession,
GlobalImage,
Expand Down Expand Up @@ -1743,6 +1745,36 @@ impl std::fmt::Display for Digest {
}
}

/// A SAML configuration specifies both IDP and SP details
davepacheco marked this conversation as resolved.
Show resolved Hide resolved
#[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
46 changes: 41 additions & 5 deletions common/src/sql/dbinit.sql
Original file line number Diff line number Diff line change
Expand Up @@ -183,16 +183,14 @@ 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,

discoverable BOOL NOT NULL,

time_created TIMESTAMPTZ NOT NULL,
time_modified TIMESTAMPTZ NOT NULL,
time_deleted TIMESTAMPTZ,

discoverable BOOL NOT NULL,

/* child resource generation number, per RFD 192 */
rcgen INT NOT NULL
);
Expand All @@ -206,7 +204,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 @@ -216,6 +213,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,
name 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,
time_created TIMESTAMPTZ NOT NULL,
time_modified TIMESTAMPTZ NOT NULL,
time_deleted TIMESTAMPTZ,

silo_id UUID NOT NULL,

idp_metadata_url TEXT NOT NULL,
davepacheco marked this conversation as resolved.
Show resolved Hide resolved
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
);

/*
* Users' public SSH keys, per RFD 44
*/
Expand Down
4 changes: 4 additions & 0 deletions nexus/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ macaddr = { version = "1.0.1", features = [ "serde_std" ]}
mime_guess = "2.0.4"
newtype_derive = "0.1.6"
num-integer = "0.1.44"
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 @@ -42,6 +45,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" }
davepacheco marked this conversation as resolved.
Show resolved Hide resolved
serde_json = "1.0"
serde_urlencoded = "0.7.1"
serde_with = "1.12.1"
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_EXTERNAL_AUTHN;
Expand Down
156 changes: 156 additions & 0 deletions nexus/src/authn/silos.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// 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>),
davepacheco marked this conversation as resolved.
Show resolved Hide resolved
}

impl SiloSamlIdentityProvider {
davepacheco marked this conversation as resolved.
Show resolved Hide resolved
/// return an error if this SiloSamlIdentityProvider is invalid
pub fn validate(&self) -> Result<()> {
davepacheco marked this conversation as resolved.
Show resolved Hide resolved
// 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(None)?;

// 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");
davepacheco marked this conversation as resolved.
Show resolved Hide resolved
}

// TODO validate DER keys
davepacheco marked this conversation as resolved.
Show resolved Hide resolved

Ok(())
}

pub fn sign_in_url(&self, relay_state: Option<String>) -> 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()))?;

let encoded_relay_state = if let Some(relay_state) = relay_state {
relay_state
} else {
"".to_string()
};

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);

// 3.4.1.1 Element <NameIDPolicy>: If the Format value is omitted or set
// to urn:oasis:names:tc:SAML:2.0:nameid-format:unspecified, then the
// identity provider is free to return any kind of identifier
sp_builder.authn_name_id_format(Some(
NameIdFormat::UnspecifiedNameIDFormat.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>>> {
davepacheco marked this conversation as resolved.
Show resolved Hide resolved
if let Some(cert) = &self.public_cert {
Ok(Some(base64::decode(cert.as_bytes())?))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I imagine we might want to add some context to this error (e.g., "failed to base64-decode public certificate"). As long as we're sticking with anyhow here, that's really easy with .context().

Copy link
Contributor Author

@jmpesp jmpesp May 11, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure how to proceed here, given fa10a10 adds "could not base64 decode" messages during deserialize. Given that the db model still uses String it's technically still possible that this decode will fail but it won't be seen by the user in the same way. I didn't try to shove openssl's types into the db, maybe I should.

} 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())?))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same about the error message.

} else {
Ok(None)
}
}
}
16 changes: 16 additions & 0 deletions nexus/src/authz/api_resources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,22 @@ authz_resource! {
polar_snippet = Custom,
}

authz_resource! {
name = "SiloIdentityProvider",
parent = "Silo",
primary_key = Uuid,
roles_allowed = false,
polar_snippet = Custom,
}

authz_resource! {
name = "SiloSamlIdentityProvider",
parent = "Silo",
primary_key = Uuid,
roles_allowed = false,
polar_snippet = Custom,
}

authz_resource! {
name = "SshKey",
parent = "SiloUser",
Expand Down
52 changes: 52 additions & 0 deletions nexus/src/authz/omicron.polar
Original file line number Diff line number Diff line change
Expand Up @@ -151,9 +151,13 @@ resource Silo {
}
has_relation(fleet: Fleet, "parent_fleet", silo: Silo)
if silo.fleet = fleet;
# Users can see their own silo! This includes USER_TEST_UNPRIVILEGED
has_role(actor: AuthenticatedActor, "viewer", silo: Silo)
if actor.silo = silo;

has_permission(actor: AuthenticatedActor, "read", silo: Silo)
davepacheco marked this conversation as resolved.
Show resolved Hide resolved
if has_role(actor, "external-authenticator", silo.fleet);

resource Organization {
permissions = [
"list_children",
Expand Down Expand Up @@ -279,3 +283,51 @@ resource SshKey {
}
has_relation(user: SiloUser, "silo_user", ssh_key: SshKey)
if ssh_key.silo_user = user;

resource SiloIdentityProvider {
permissions = [
"read",
"modify",
"create_child",
"list_children",
];
relations = { parent_silo: Silo };

"read" if "viewer" on "parent_silo";
"list_children" if "viewer" on "parent_silo";

# Only silo admins can create silo identity providers
"modify" if "admin" on "parent_silo";
"create_child" if "admin" on "parent_silo";
}
has_relation(silo: Silo, "parent_silo", silo_identity_provider: SiloIdentityProvider)
if silo_identity_provider.silo = silo;

has_permission(actor: AuthenticatedActor, "read", silo_identity_provider: SiloIdentityProvider)
if has_role(actor, "external-authenticator", silo_identity_provider.silo.fleet);
has_permission(actor: AuthenticatedActor, "list_children", silo_identity_provider: SiloIdentityProvider)
if has_role(actor, "external-authenticator", silo_identity_provider.silo.fleet);

resource SiloSamlIdentityProvider {
permissions = [
"read",
"modify",
"create_child",
"list_children",
];
relations = { parent_silo: Silo };

# Only silo admins have permissions for specific identity provider details
"read" if "admin" on "parent_silo";
"list_children" if "admin" on "parent_silo";

"modify" if "admin" on "parent_silo";
"create_child" if "admin" on "parent_silo";
}
has_relation(silo: Silo, "parent_silo", silo_saml_identity_provider: SiloSamlIdentityProvider)
if silo_saml_identity_provider.silo = silo;

has_permission(actor: AuthenticatedActor, "read", silo_saml_identity_provider: SiloSamlIdentityProvider)
davepacheco marked this conversation as resolved.
Show resolved Hide resolved
if has_role(actor, "external-authenticator", silo_saml_identity_provider.silo.fleet);
has_permission(actor: AuthenticatedActor, "list_children", silo_saml_identity_provider: SiloSamlIdentityProvider)
if has_role(actor, "external-authenticator", silo_saml_identity_provider.silo.fleet);
2 changes: 2 additions & 0 deletions nexus/src/authz/oso_generic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ pub fn make_omicron_oso(log: &slog::Logger) -> Result<Oso, anyhow::Error> {
SshKey::init(),
Silo::init(),
SiloUser::init(),
SiloIdentityProvider::init(),
SiloSamlIdentityProvider::init(),
Sled::init(),
UpdateAvailableArtifact::init(),
UserBuiltin::init(),
Expand Down
Loading