From 03acc0039bb506673a5e29a9aa4adb526df0cb45 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Wed, 30 Mar 2022 17:14:35 -0400 Subject: [PATCH 1/3] Support for SAML as a Silo IdP, part 1 Add the db schemas, models, and some endpoints to support configuring a SAML IdP for a Silo. Enough functionality is here to support the first step of SP-initiated SAML login flow. More work is required to support receiving the SAML IdP's response and actually logging in the user. Two tables were added here: one that relates a silo to a list of typed identity providers, and one for saml configuration. Future work will add "local" and "ldap" identity provider support. XXX CRUD for silo saml identity providers XXX hash of metadata document, or return directly XXX name or ID for identity providers - add name XXX delete of silo deletes providers --- Cargo.lock | 278 +++++++++++- common/src/api/external/mod.rs | 32 ++ common/src/sql/dbinit.sql | 41 +- nexus/Cargo.toml | 6 + nexus/src/authn/mod.rs | 1 + nexus/src/authn/silos.rs | 152 +++++++ nexus/src/db/datastore.rs | 85 ++++ nexus/src/db/model.rs | 110 ++++- nexus/src/db/schema.rs | 35 ++ nexus/src/external_api/console_api.rs | 92 +++- nexus/src/external_api/http_entrypoints.rs | 67 ++- nexus/src/external_api/params.rs | 33 ++ nexus/src/external_api/tag-config.json | 6 + nexus/src/external_api/views.rs | 17 + nexus/src/nexus.rs | 84 ++++ nexus/tests/integration_tests/silos.rs | 419 +++++++++++++++++- nexus/tests/output/nexus_tags.txt | 7 + .../output/uncovered-authz-endpoints.txt | 4 + openapi/nexus.json | 306 +++++++++++++ 19 files changed, 1759 insertions(+), 16 deletions(-) create mode 100644 nexus/src/authn/silos.rs diff --git a/Cargo.lock b/Cargo.lock index e3e7d4d556d..ba480d1d101 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -212,6 +212,29 @@ dependencies = [ "serde", ] +[[package]] +name = "bindgen" +version = "0.59.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bd2a9a458e8f4304c52c43ebb0cfbd520289f8379a52e329a38afda99bf8eb8" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "clap 2.34.0", + "env_logger", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "which", +] + [[package]] name = "bit-set" version = "0.5.2" @@ -338,6 +361,15 @@ version = "1.0.73" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fff2a6927b3bb87f9595d67196a70493f627687a71d87a0d692242c33f58c11" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-if" version = "1.0.0" @@ -367,6 +399,17 @@ dependencies = [ "generic-array 0.14.5", ] +[[package]] +name = "clang-sys" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00842eed744b858222c4c9faf7243aafc6d33f92f96935263ef4d8a41ce21" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "2.34.0" @@ -757,14 +800,38 @@ dependencies = [ "cipher", ] +[[package]] +name = "darling" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2c43f534ea4b0b049015d00269734195e6d3f0f6635cb692251aca6f9f8b3c" +dependencies = [ + "darling_core 0.12.4", + "darling_macro 0.12.4", +] + [[package]] name = "darling" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0d720b8683f8dd83c65155f0530560cba68cd2bf395f6513a483caee57ff7f4" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.13.1", + "darling_macro 0.13.1", +] + +[[package]] +name = "darling_core" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e91455b86830a1c21799d94524df0845183fa55bafd9aa137b01c7d1065fa36" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn", ] [[package]] @@ -781,13 +848,24 @@ dependencies = [ "syn", ] +[[package]] +name = "darling_macro" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29b5acf0dea37a7f66f7b25d2c5e93fd46f8f6968b1a5d7a3e02e97768afc95a" +dependencies = [ + "darling_core 0.12.4", + "quote", + "syn", +] + [[package]] name = "darling_macro" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72c41b3b7352feb3211a0d743dc5700a4e3b60f51bd2b368892d1e0f9a95f44b" dependencies = [ - "darling_core", + "darling_core 0.13.1", "quote", "syn", ] @@ -819,6 +897,37 @@ dependencies = [ "const-oid", ] +[[package]] +name = "derive_builder" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d13202debe11181040ae9063d739fa32cfcaaebe2275fe387703460ae2365b30" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e616858f6187ed828df7c64a6d71720d83767a7f19740b2d1b6fe6327b36e5" +dependencies = [ + "darling 0.12.4", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58a94ace95092c5acb1e97a7e846b310cfbd499652f72297da7493f618a98d73" +dependencies = [ + "derive_builder_core", + "syn", +] + [[package]] name = "diesel" version = "2.0.0" @@ -1687,6 +1796,28 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" +[[package]] +name = "httptest" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f25cfb6def593d43fae1ead24861f217e93bc70768a45cc149a69b5f049df4" +dependencies = [ + "bstr", + "bytes", + "crossbeam-channel", + "form_urlencoded", + "futures", + "http", + "hyper", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "serde_urlencoded", + "tokio", +] + [[package]] name = "hubpack" version = "0.1.0" @@ -1973,12 +2104,28 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "libc" version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efaa7b300f3b5fe8eb6bf21ce3895e1751d9665086af2d64b42f19701015ff4f" +[[package]] +name = "libloading" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbc0f03f9a775e9f6aed295c6a1ba2253c5757a9e03d55c6caa46a681abcddd" +dependencies = [ + "cfg-if", + "winapi", +] + [[package]] name = "libsqlite3-sys" version = "0.24.1" @@ -1989,6 +2136,17 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libxml" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687f5a78939052c5d02865c0fe3ea2ce2acdca875f7f81db82f7aef256dd97ac" +dependencies = [ + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linked-hash-map" version = "0.5.4" @@ -2089,6 +2247,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.4.4" @@ -2246,6 +2410,16 @@ dependencies = [ "smallvec", ] +[[package]] +name = "nom" +version = "7.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8903e5a29a317527874d0402f867152a3d21c908bb0b933e416c65e301d4c36" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -2416,10 +2590,12 @@ dependencies = [ "diesel-dtrace", "dropshot 0.6.1-dev (git+https://github.com/oxidecomputer/dropshot?branch=main)", "expectorate", + "flate2", "futures", "headers", "hex", "http", + "httptest", "hyper", "ipnetwork", "lazy_static", @@ -2435,6 +2611,9 @@ dependencies = [ "omicron-test-utils", "openapi-lint", "openapiv3", + "openssl", + "openssl-probe", + "openssl-sys", "oso", "oximeter", "oximeter-client", @@ -2448,6 +2627,7 @@ dependencies = [ "regex", "reqwest", "ring", + "samael", "schemars", "serde", "serde_json", @@ -2937,6 +3117,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + [[package]] name = "pem" version = "1.0.2" @@ -3336,6 +3522,16 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quick-xml" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8533f14c8382aaad0d592c812ac3b826162128b65662331e1127b45c3d18536b" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quote" version = "1.0.17" @@ -3627,6 +3823,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc_version" version = "0.1.7" @@ -3689,6 +3891,32 @@ version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73b4b750c782965c211b42f022f59af1fbceabdd026623714f104152f1ec149f" +[[package]] +name = "samael" +version = "0.0.8" +source = "git+https://github.com/njaremko/samael?rev=441a244120eeb5995b2e47a52dc1beafa890d2b2#441a244120eeb5995b2e47a52dc1beafa890d2b2" +dependencies = [ + "base64", + "bindgen", + "chrono", + "data-encoding", + "derive_builder", + "flate2", + "lazy_static", + "libc", + "libxml", + "openssl", + "openssl-probe", + "openssl-sys", + "pkg-config", + "quick-xml", + "rand 0.8.5", + "serde", + "snafu 0.6.10", + "url", + "uuid", +] + [[package]] name = "same-file" version = "1.0.6" @@ -3934,7 +4162,7 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12e47be9471c72889ebafb5e14d5ff930d89ae7a67bbdb5f8abb564f845a927e" dependencies = [ - "darling", + "darling 0.13.1", "proc-macro2", "quote", "syn", @@ -4020,6 +4248,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b2853a4d09f215c24cc5489c992ce46052d359b5109343cbafbf26bc62f8a3" + [[package]] name = "signal-hook" version = "0.3.13" @@ -4223,6 +4457,16 @@ dependencies = [ "thiserror", ] +[[package]] +name = "snafu" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eab12d3c261b2308b0d80c26fffb58d17eba81a4be97890101f416b478c79ca7" +dependencies = [ + "doc-comment", + "snafu-derive 0.6.10", +] + [[package]] name = "snafu" version = "0.7.0" @@ -4230,7 +4474,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2eba135d2c579aa65364522eb78590cdf703176ef71ad4c32b00f58f7afb2df5" dependencies = [ "doc-comment", - "snafu-derive", + "snafu-derive 0.7.0", +] + +[[package]] +name = "snafu-derive" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1508efa03c362e23817f96cde18abed596a25219a8b2c66e8db33c03543d315b" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -4806,7 +5061,7 @@ dependencies = [ "serde", "serde_json", "serde_plain", - "snafu", + "snafu 0.7.0", "tempfile", "untrusted", "url", @@ -5414,6 +5669,17 @@ dependencies = [ "webpki", ] +[[package]] +name = "which" +version = "4.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c4fb54e6113b6a8772ee41c3404fb0301ac79604489467e0a9ce1f3e97c24ae" +dependencies = [ + "either", + "lazy_static", + "libc", +] + [[package]] name = "widestring" version = "0.5.1" diff --git a/common/src/api/external/mod.rs b/common/src/api/external/mod.rs index 527327450aa..5203677d425 100644 --- a/common/src/api/external/mod.rs +++ b/common/src/api/external/mod.rs @@ -516,6 +516,8 @@ pub enum ResourceType { Fleet, Silo, SiloUser, + SiloIdentityProvider, + SiloSamlIdentityProvider, ConsoleSession, Organization, Project, @@ -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, + #[serde(skip_serializing)] + pub private_key: Option, +} + #[cfg(test)] mod test { use super::RouteDestination; diff --git a/common/src/sql/dbinit.sql b/common/src/sql/dbinit.sql index b67212eb2d8..63854014052 100644 --- a/common/src/sql/dbinit.sql +++ b/common/src/sql/dbinit.sql @@ -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, @@ -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, @@ -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 */ diff --git a/nexus/Cargo.toml b/nexus/Cargo.toml index ed70e6225d8..78dae145bb3 100644 --- a/nexus/Cargo.toml +++ b/nexus/Cargo.toml @@ -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" @@ -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/" } @@ -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" @@ -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" diff --git a/nexus/src/authn/mod.rs b/nexus/src/authn/mod.rs index b85a4b02638..5dab83755c3 100644 --- a/nexus/src/authn/mod.rs +++ b/nexus/src/authn/mod.rs @@ -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; diff --git a/nexus/src/authn/silos.rs b/nexus/src/authn/silos.rs new file mode 100644 index 00000000000..0d8592fc94b --- /dev/null +++ b/nexus/src/authn/silos.rs @@ -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), +} + +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 { + let idp_metadata: EntityDescriptor = + self.idp_metadata_document_string.parse()?; + + // return the *first* SSO HTTP-Redirect binding URL in the IDP metadata: + // + // + 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::>(); + + 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 { + 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>> { + if let Some(cert) = &self.public_cert { + Ok(Some(base64::decode(cert.as_bytes())?)) + } else { + Ok(None) + } + } + + fn get_private_key_bytes(&self) -> Result>> { + if let Some(key) = &self.private_key { + Ok(Some(base64::decode(key.as_bytes())?)) + } else { + Ok(None) + } + } +} diff --git a/nexus/src/db/datastore.rs b/nexus/src/db/datastore.rs index fcbc4560127..6fcf2aac3ec 100644 --- a/nexus/src/db/datastore.rs +++ b/nexus/src/db/datastore.rs @@ -2833,6 +2833,91 @@ impl DataStore { Ok(()) } + + pub async fn silo_identity_provider_type( + &self, + provider_id: Uuid, + ) -> LookupResult { + use db::schema::silo_identity_provider::dsl; + + let result: String = dsl::silo_identity_provider + .filter(dsl::provider_id.eq(provider_id)) + .select(dsl::provider_type) + .get_result_async(self.pool()) + .await + .map_err(|e| { + public_error_from_diesel_pool(e, ErrorHandler::Server) + })?; + + Ok(result.as_str().parse().map_err(|e: anyhow::Error| { + Error::internal_error(&e.to_string()) + })?) + } + + pub async fn silo_saml_identity_provider_create( + &self, + provider: db::model::SiloSamlIdentityProvider, + ) -> CreateResult { + self.pool() + .transaction(move |conn| { + use db::schema::silo_identity_provider::dsl as idp_dsl; + diesel::insert_into(idp_dsl::silo_identity_provider) + .values(db::model::SiloIdentityProvider { + silo_id: provider.silo_id, + provider_type: + db::model::SiloIdentityProviderTypeEnum::Saml, + provider_id: provider.id(), + }) + .execute(conn)?; + + use db::schema::silo_saml_identity_provider::dsl; + let result = + diesel::insert_into(dsl::silo_saml_identity_provider) + .values(provider) + .returning( + db::model::SiloSamlIdentityProvider::as_returning(), + ) + .get_result(conn)?; + + Ok(result) + }) + .await + .map_err(|e: TransactionError| { + Error::internal_error(&format!("Transaction error: {}", e)) + }) + } + + pub async fn silo_saml_identity_provider_fetch( + &self, + provider_id: Uuid, + ) -> LookupResult { + use db::schema::silo_saml_identity_provider::dsl; + dsl::silo_saml_identity_provider + .filter(dsl::id.eq(provider_id)) + .filter(dsl::time_deleted.is_null()) + .select(db::model::SiloSamlIdentityProvider::as_select()) + .get_result_async(self.pool()) + .await + .map_err(|e| public_error_from_diesel_pool(e, ErrorHandler::Server)) + } + + pub async fn get_discoverable_identity_providers( + &self, + ) -> LookupResult> { + use db::schema::silo::dsl as silo_dsl; + use db::schema::silo_identity_provider::dsl as idp_dsl; + + silo_dsl::silo + .filter(silo_dsl::discoverable.eq(true)) + .inner_join( + idp_dsl::silo_identity_provider + .on(idp_dsl::silo_id.eq(silo_dsl::id)), + ) + .select(db::model::SiloIdentityProvider::as_select()) + .get_results_async(self.pool()) + .await + .map_err(|e| public_error_from_diesel_pool(e, ErrorHandler::Server)) + } } /// Constructs a DataStore for use in test suites that has preloaded the diff --git a/nexus/src/db/model.rs b/nexus/src/db/model.rs index 4d65040ad05..17789a6ea44 100644 --- a/nexus/src/db/model.rs +++ b/nexus/src/db/model.rs @@ -9,7 +9,8 @@ use crate::db::identity::{Asset, Resource}; use crate::db::schema::{ console_session, dataset, disk, image, instance, metric_producer, network_interface, organization, oximeter, project, rack, region, - role_assignment_builtin, role_builtin, router_route, silo, silo_user, sled, + role_assignment_builtin, role_builtin, router_route, silo, + silo_identity_provider, silo_saml_identity_provider, silo_user, sled, snapshot, update_available_artifact, user_builtin, volume, vpc, vpc_firewall_rule, vpc_router, vpc_subnet, zpool, }; @@ -17,6 +18,7 @@ use crate::defaults; use crate::external_api::params; use crate::external_api::views; use crate::internal_api; +use anyhow::bail; use chrono::{DateTime, Utc}; use db_macros::{Asset, Resource}; use diesel::backend::{Backend, BinaryRawValue, RawValue}; @@ -37,6 +39,8 @@ use std::net::IpAddr; use std::net::Ipv4Addr; use std::net::Ipv6Addr; use std::net::SocketAddr; +use std::str::FromStr; +use std::string::ToString; use uuid::Uuid; // TODO: Break up types into multiple files @@ -980,6 +984,110 @@ impl SiloUser { } } +#[derive(Debug, Copy, Clone, AsExpression, FromSqlRow)] +#[sql_type = "sql_types::Text"] +pub enum SiloIdentityProviderTypeEnum { + Local, + Saml, + Ldap, +} + +impl ToString for SiloIdentityProviderTypeEnum { + fn to_string(&self) -> String { + match self { + SiloIdentityProviderTypeEnum::Local => "local".to_string(), + SiloIdentityProviderTypeEnum::Saml => "saml".to_string(), + SiloIdentityProviderTypeEnum::Ldap => "ldap".to_string(), + } + } +} + +impl ToSql for SiloIdentityProviderTypeEnum +where + DB: Backend, + str: ToSql, +{ + fn to_sql( + &self, + out: &mut serialize::Output, + ) -> serialize::Result { + out.write_all(self.to_string().as_bytes())?; + Ok(serialize::IsNull::No) + } +} + +impl FromStr for SiloIdentityProviderTypeEnum { + type Err = anyhow::Error; + fn from_str(value: &str) -> Result { + Ok(match value { + "local" => SiloIdentityProviderTypeEnum::Local, + "saml" => SiloIdentityProviderTypeEnum::Saml, + "ldap" => SiloIdentityProviderTypeEnum::Ldap, + &_ => { + bail!( + "unrecognized value for SiloIdentityProviderTypeEnum: {}", + &value + ); + } + }) + } +} + +impl FromSql for SiloIdentityProviderTypeEnum +where + DB: Backend, + String: FromSql, +{ + fn from_sql(bytes: RawValue) -> deserialize::Result { + let bytes_string = String::from_sql(bytes)?; + Ok(bytes_string.parse()?) + } +} + +#[derive(Queryable, Insertable, Clone, Debug, Selectable)] +#[table_name = "silo_identity_provider"] +pub struct SiloIdentityProvider { + pub silo_id: Uuid, + pub provider_type: SiloIdentityProviderTypeEnum, + pub provider_id: Uuid, +} + +#[derive(Queryable, Insertable, Clone, Debug, Selectable, Resource)] +#[table_name = "silo_saml_identity_provider"] +pub struct SiloSamlIdentityProvider { + #[diesel(embed)] + pub identity: SiloSamlIdentityProviderIdentity, + + pub silo_id: Uuid, + + pub idp_metadata_url: String, + pub idp_metadata_document_string: String, + + pub idp_entity_id: String, + pub sp_client_id: String, + pub acs_url: String, + pub slo_url: String, + pub technical_contact_email: String, + pub public_cert: Option, + pub private_key: Option, +} + +impl Into for SiloSamlIdentityProvider { + fn into(self) -> external::SiloSamlIdentityProvider { + external::SiloSamlIdentityProvider { + identity: self.identity(), + idp_metadata_url: self.idp_metadata_url.clone(), + idp_entity_id: self.idp_entity_id.clone(), + sp_client_id: self.sp_client_id.clone(), + acs_url: self.acs_url.clone(), + slo_url: self.slo_url.clone(), + technical_contact_email: self.technical_contact_email.clone(), + public_cert: self.public_cert, + private_key: self.private_key, + } + } +} + /// Describes an organization within the database. #[derive(Queryable, Insertable, Debug, Resource, Selectable)] #[table_name = "organization"] diff --git a/nexus/src/db/schema.rs b/nexus/src/db/schema.rs index a102de7c298..a8de44033ec 100644 --- a/nexus/src/db/schema.rs +++ b/nexus/src/db/schema.rs @@ -134,6 +134,39 @@ table! { } } +table! { + silo_identity_provider (silo_id, provider_id) { + silo_id -> Uuid, + provider_type -> Text, + provider_id -> Uuid, + } +} + +table! { + silo_saml_identity_provider (id) { + id -> Uuid, + name -> Text, + description -> Text, + + silo_id -> Uuid, + + idp_metadata_url -> Text, + idp_metadata_document_string -> Text, + + idp_entity_id -> Text, + sp_client_id -> Text, + acs_url -> Text, + slo_url -> Text, + technical_contact_email -> Text, + public_cert -> Nullable, + private_key -> Nullable, + + time_created -> Timestamptz, + time_modified -> Timestamptz, + time_deleted -> Nullable, + } +} + table! { organization (id) { id -> Uuid, @@ -426,6 +459,8 @@ allow_tables_to_appear_in_same_query!( region, saga, saga_node_event, + silo, + silo_identity_provider, console_session, sled, router_route, diff --git a/nexus/src/external_api/console_api.rs b/nexus/src/external_api/console_api.rs index 24b53515263..b889e8188ef 100644 --- a/nexus/src/external_api/console_api.rs +++ b/nexus/src/external_api/console_api.rs @@ -15,7 +15,10 @@ use crate::authn::external::{ SessionStore, SESSION_COOKIE_COOKIE_NAME, }, }; -use crate::authn::{USER_TEST_PRIVILEGED, USER_TEST_UNPRIVILEGED}; +use crate::authn::{ + silos::SiloIdentityProviderType, USER_TEST_PRIVILEGED, + USER_TEST_UNPRIVILEGED, +}; use crate::context::OpContext; use crate::ServerContext; use dropshot::{ @@ -81,6 +84,93 @@ pub async fn spoof_login( .body("ok".into())?) // TODO: what do we return from login? } +#[derive(Deserialize, JsonSchema)] +pub struct LoginToProviderPathParam { + pub provider_id: Uuid, +} + +/// Ask the user to login to their identity provider +/// +/// Either display a page asking a user for their credentials, or redirect them +/// to their identity provider. +#[endpoint { + method = GET, + path = "/login/{provider_id}", + tags = ["login"], +}] +pub async fn ask_user_to_login_to_provider( + rqctx: Arc>>, + path_params: Path, +) -> Result, HttpError> { + let apictx = rqctx.context(); + let handler = async { + let nexus = &apictx.nexus; + let path_params = path_params.into_inner(); + + let identity_provider = + nexus.get_silo_identity_provider(path_params.provider_id).await?; + match identity_provider { + SiloIdentityProviderType::Local => { + todo!() + } + SiloIdentityProviderType::Ldap => { + todo!() + } + SiloIdentityProviderType::Saml(silo_saml_identity_provider) => { + let sign_in_url = + silo_saml_identity_provider.sign_in_url().map_err(|e| { + HttpError::for_internal_error(e.to_string()) + })?; + + Ok(Response::builder() + .status(StatusCode::FOUND) + .header(http::header::LOCATION, sign_in_url) + .body("".into())?) + } + } + }; + // TODO instrument_dropshot_handler doesn't support Response + //apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await + handler.await +} + +/// Consume some sort of credentials, and authenticate a user. +/// +/// Either receive a username and password, or some sort of identity provider +/// data (like a SAMLResponse). Use these to set the user's session cookie. +#[endpoint { + method = POST, + path = "/login/{provider_id}", + tags = ["login"], +}] +pub async fn consume_credentials_and_authn_user( + rqctx: Arc>>, + path_params: Path, +) -> Result, HttpError> { + let apictx = rqctx.context(); + let handler = async { + let nexus = &apictx.nexus; + let path_params = path_params.into_inner(); + + let identity_provider = + nexus.get_silo_identity_provider(path_params.provider_id).await?; + match identity_provider { + SiloIdentityProviderType::Local => { + todo!() + } + SiloIdentityProviderType::Ldap => { + todo!() + } + SiloIdentityProviderType::Saml(_silo_saml_identity_provider) => { + todo!() + } + } + }; + // TODO instrument_dropshot_handler doesn't support Response + //apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await + handler.await +} + // Log user out of web console by deleting session in both server and browser #[endpoint { // important for security that this be a POST despite the empty req body diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 6a7f5be5ab1..b2f0c685422 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -11,8 +11,8 @@ use crate::ServerContext; use super::{ console_api, params, views::{ - Image, Organization, Project, Rack, Role, Silo, Sled, Snapshot, User, - Vpc, VpcRouter, VpcSubnet, + Image, Organization, Project, Rack, Role, Silo, SiloIdentityProvider, + Sled, Snapshot, User, Vpc, VpcRouter, VpcSubnet, }, }; use crate::context::OpContext; @@ -56,6 +56,7 @@ use omicron_common::api::external::RouterRouteCreateParams; use omicron_common::api::external::RouterRouteKind; use omicron_common::api::external::RouterRouteUpdateParams; use omicron_common::api::external::Saga; +use omicron_common::api::external::SiloSamlIdentityProvider; use omicron_common::api::external::VpcFirewallRuleUpdateParams; use omicron_common::api::external::VpcFirewallRules; use ref_cast::RefCast; @@ -75,6 +76,9 @@ pub fn external_api() -> NexusApiDescription { api.register(silos_get_silo)?; api.register(silos_delete_silo)?; + api.register(identity_provider_list)?; + api.register(silo_saml_idp_create)?; + api.register(organizations_get)?; api.register(organizations_post)?; api.register(organizations_get_organization)?; @@ -181,6 +185,8 @@ pub fn external_api() -> NexusApiDescription { api.register(console_api::logout)?; api.register(console_api::console_page)?; api.register(console_api::asset)?; + api.register(console_api::ask_user_to_login_to_provider)?; + api.register(console_api::consume_credentials_and_authn_user)?; Ok(()) } @@ -230,7 +236,7 @@ pub fn external_api() -> NexusApiDescription { // clients. Client generators use operationId to name API methods, so changing // a function name is a breaking change from a client perspective. -// TODO authz for silo endpoints +// TODO-security: authz for silo endpoints // List all silos (that are discoverable). #[endpoint { @@ -343,6 +349,61 @@ async fn silos_delete_silo( apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await } +// Silo identity providers + +/// Return a list of all identity providers for all discoverable silos +#[endpoint { + method = GET, + path = "/identity_provider", + tags = ["silos"], +}] +async fn identity_provider_list( + rqctx: Arc>>, +) -> Result>, HttpError> { + let apictx = rqctx.context(); + let handler = async { + let nexus = &apictx.nexus; + let identity_providers = nexus + .get_discoverable_identity_providers() + .await? + .iter() + .map(|x| x.clone().into()) + .collect(); + Ok(HttpResponseOk(identity_providers)) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + +// Silo SAML identity providers + +/// Create a new SAML identity provider for a silo. +#[endpoint { + method = POST, + path = "/silos/{silo_name}/saml_identity_provider", + tags = ["silos"], +}] +async fn silo_saml_idp_create( + rqctx: Arc>>, + path_params: Path, + new_provider: TypedBody, +) -> Result, HttpError> { + let apictx = rqctx.context(); + let nexus = &apictx.nexus; + + let handler = async { + let opctx = OpContext::for_external_api(&rqctx).await?; + let provider = nexus + .silo_saml_identity_provider_create( + &opctx, + &path_params.into_inner().silo_name, + new_provider.into_inner(), + ) + .await?; + Ok(HttpResponseCreated(provider.into())) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + /// List all organizations. #[endpoint { method = GET, diff --git a/nexus/src/external_api/params.rs b/nexus/src/external_api/params.rs index 6b2bc08b7c2..53f554f98b8 100644 --- a/nexus/src/external_api/params.rs +++ b/nexus/src/external_api/params.rs @@ -24,6 +24,39 @@ pub struct SiloCreate { pub discoverable: bool, } +// Silo identity providers + +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct SiloSamlIdentityProviderCreate { + #[serde(flatten)] + pub identity: IdentityMetadataCreateParams, + + pub silo_id: Uuid, + + /// 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, + #[serde(skip_serializing)] + pub private_key: Option, +} + // ORGANIZATIONS /// Create-time parameters for an [`Organization`](crate::external_api::views::Organization) diff --git a/nexus/src/external_api/tag-config.json b/nexus/src/external_api/tag-config.json index bfd8b24ede4..bd22a2e49ef 100644 --- a/nexus/src/external_api/tag-config.json +++ b/nexus/src/external_api/tag-config.json @@ -32,6 +32,12 @@ "url": "http://oxide.computer/docs/#xxx" } }, + "login": { + "description": "Authentication endpoints", + "external_docs": { + "url": "http://oxide.computer/docs/#xxx" + } + }, "metrics": { "description": "Metrics provide insight into the operation of the Oxide deployment. These include telemetry on hardware and software components that can be used to understand the current state as well as to diagnose issues.", "external_docs": { diff --git a/nexus/src/external_api/views.rs b/nexus/src/external_api/views.rs index fdceddacc1a..515b1a0056a 100644 --- a/nexus/src/external_api/views.rs +++ b/nexus/src/external_api/views.rs @@ -36,6 +36,23 @@ impl Into for model::Silo { } } +// SILO IDENTITY PROVIDER + +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct SiloIdentityProvider { + pub provider_id: Uuid, + pub provider_type: String, +} + +impl Into for model::SiloIdentityProvider { + fn into(self) -> SiloIdentityProvider { + SiloIdentityProvider { + provider_id: self.provider_id, + provider_type: self.provider_type.to_string(), + } + } +} + // ORGANIZATIONS /// Client view of an [`Organization`] diff --git a/nexus/src/nexus.rs b/nexus/src/nexus.rs index b96c0072d63..778afd58a86 100644 --- a/nexus/src/nexus.rs +++ b/nexus/src/nexus.rs @@ -3335,6 +3335,90 @@ impl Nexus { ) -> LookupResult { self.db_datastore.silo_user_fetch(silo_user_id).await } + + pub async fn silo_saml_identity_provider_create( + &self, + opctx: &OpContext, + silo_name: &Name, + params: params::SiloSamlIdentityProviderCreate, + ) -> CreateResult { + let db_silo = self.silo_fetch(&opctx, silo_name).await?; + + // Download the SAML IdP descriptor, and write it into the DB. This is + // so that it can be deserialized later. + // + // Importantly, do this only once and store it. It would introduce + // attack surface to download it each time it was required. + let idp_metadata_document_string = + reqwest::get(¶ms.idp_metadata_url) + .await + .map_err(|e| Error::invalid_request(&e.to_string()))? + .text() + .await + .map_err(|e| Error::invalid_request(&e.to_string()))?; + + let provider = db::model::SiloSamlIdentityProvider { + identity: db::model::SiloSamlIdentityProviderIdentity::new( + Uuid::new_v4(), + params.identity, + ), + silo_id: db_silo.id(), + + idp_metadata_url: params.idp_metadata_url, + idp_metadata_document_string, + + idp_entity_id: params.idp_entity_id, + sp_client_id: params.sp_client_id, + acs_url: params.acs_url, + slo_url: params.slo_url, + technical_contact_email: params.technical_contact_email, + public_cert: params.public_cert, + private_key: params.private_key, + }; + + provider + .validate() + .map_err(|e| Error::invalid_request(&e.to_string()))?; + + self.db_datastore.silo_saml_identity_provider_create(provider).await + } + + pub async fn silo_saml_identity_provider_fetch( + &self, + provider_id: Uuid, + ) -> LookupResult { + self.db_datastore.silo_saml_identity_provider_fetch(provider_id).await + } + + pub async fn get_silo_identity_provider( + &self, + provider_id: Uuid, + ) -> LookupResult { + let silo_provider_type = + self.db_datastore.silo_identity_provider_type(provider_id).await?; + + match silo_provider_type { + db::model::SiloIdentityProviderTypeEnum::Local => { + todo!() + } + db::model::SiloIdentityProviderTypeEnum::Ldap => { + todo!() + } + db::model::SiloIdentityProviderTypeEnum::Saml => { + let silo_saml_identity_provider = + self.silo_saml_identity_provider_fetch(provider_id).await?; + Ok(authn::silos::SiloIdentityProviderType::Saml(Box::new( + silo_saml_identity_provider, + ))) + } + } + } + + pub async fn get_discoverable_identity_providers( + &self, + ) -> LookupResult> { + self.db_datastore.get_discoverable_identity_providers().await + } } fn generate_session_token() -> String { diff --git a/nexus/tests/integration_tests/silos.rs b/nexus/tests/integration_tests/silos.rs index 9dba23461de..ae5be43a5a8 100644 --- a/nexus/tests/integration_tests/silos.rs +++ b/nexus/tests/integration_tests/silos.rs @@ -4,19 +4,26 @@ use uuid::Uuid; -use nexus_test_utils::http_testing::{AuthnMode, NexusRequest}; -use omicron_nexus::external_api::views::{Organization, Silo}; +use nexus_test_utils::http_testing::{AuthnMode, NexusRequest, RequestBuilder}; +use omicron_common::api::external::IdentityMetadataCreateParams; +use omicron_common::api::external::SiloSamlIdentityProvider; +use omicron_nexus::external_api::params; +use omicron_nexus::external_api::views::{ + Organization, Silo, SiloIdentityProvider, +}; use omicron_nexus::TestInterfaces as _; use http::method::Method; use http::StatusCode; use nexus_test_utils::resource_helpers::{ - create_organization, create_silo, objects_list_page_authz, + create_organization, create_silo, object_create, objects_list_page_authz, }; use nexus_test_utils::ControlPlaneTestContext; use nexus_test_utils_macros::nexus_test; +use httptest::{matchers::*, responders::*, Expectation, Server}; + #[nexus_test] async fn test_silos(cptestctx: &ControlPlaneTestContext) { let client = &cptestctx.external_client; @@ -140,3 +147,409 @@ async fn test_silos(cptestctx: &ControlPlaneTestContext) { .await .expect_err("unexpected success"); } + +// Valid SAML IdP entity descriptor from https://en.wikipedia.org/wiki/SAML_metadata#Identity_provider_metadata +// note: no signing keys +const SAML_IDP_DESCRIPTOR: &str = r#" + + + + + + + https://registrar.example.net/category/self-certified + + + + + + + Example.org + The identity provider at Example.org + https://idp.example.org/myicon.png + + + + + + + Example.org Non-Profit Org + Example.org + https://www.example.org/ + + + SAML Technical Support + mailto:technical-support@example.org + + "#; + +// Create a SAML IdP +#[nexus_test] +async fn test_create_a_saml_idp(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + + let silo: Silo = create_silo(&client, "discoverable", true).await; + + let saml_idp_descriptor = SAML_IDP_DESCRIPTOR; + + let server = Server::run(); + server.expect( + Expectation::matching(request::method_path("GET", "/descriptor")) + .respond_with(status_code(200).body(saml_idp_descriptor)), + ); + + let silo_saml_idp: SiloSamlIdentityProvider = object_create( + client, + "/silos/discoverable/saml_identity_provider", + ¶ms::SiloSamlIdentityProviderCreate { + identity: IdentityMetadataCreateParams { + name: "some-totally-real-saml-provider" + .to_string() + .parse() + .unwrap(), + description: "an org".to_string(), + }, + + silo_id: silo.identity.id, + idp_metadata_url: server.url("/descriptor").to_string(), + + idp_entity_id: "entity_id".to_string(), + sp_client_id: "client_id".to_string(), + acs_url: "http://acs".to_string(), + slo_url: "http://slo".to_string(), + technical_contact_email: "technical@fake".to_string(), + + public_cert: None, + private_key: None, + }, + ) + .await; + + // Expect the SSO redirect when trying to log in + let result = NexusRequest::new( + RequestBuilder::new( + client, + Method::GET, + &format!("/login/{}", silo_saml_idp.identity.id), + ) + .expect_status(Some(StatusCode::FOUND)), + ) + .execute() + .await + .expect("expected success"); + + assert!(result.headers["Location"] + .to_str() + .unwrap() + .to_string() + .starts_with( + "https://idp.example.org/SAML2/SSO/Redirect?SAMLRequest=", + )); + + // Expect that the list of providers contains this + let identity_providers: Vec = + NexusRequest::object_get(client, "/identity_provider") + .execute() + .await + .expect("success") + .parsed_body() + .unwrap(); + + assert_eq!(identity_providers.len(), 1); + assert_eq!(identity_providers[0].provider_id, silo_saml_idp.identity.id); +} + +// Fail to create a SAML IdP out of an invalid descriptor +#[nexus_test] +async fn test_create_a_saml_idp_invalid_descriptor_truncated( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + let silo: Silo = create_silo(&client, "discoverable", true).await; + + let saml_idp_descriptor = { + let mut saml_idp_descriptor = SAML_IDP_DESCRIPTOR.to_string(); + saml_idp_descriptor.truncate(100); + saml_idp_descriptor + }; + + let server = Server::run(); + server.expect( + Expectation::matching(request::method_path("GET", "/descriptor")) + .respond_with(status_code(200).body(saml_idp_descriptor)), + ); + + NexusRequest::new( + RequestBuilder::new( + client, + Method::POST, + "/silos/discoverable/saml_identity_provider", + ) + .body(Some(¶ms::SiloSamlIdentityProviderCreate { + identity: IdentityMetadataCreateParams { + name: "some-totally-real-saml-provider" + .to_string() + .parse() + .unwrap(), + description: "an org".to_string(), + }, + + silo_id: silo.identity.id, + idp_metadata_url: server.url("/descriptor").to_string(), + + idp_entity_id: "entity_id".to_string(), + sp_client_id: "client_id".to_string(), + acs_url: "http://acs".to_string(), + slo_url: "http://slo".to_string(), + technical_contact_email: "technical@fake".to_string(), + + public_cert: None, + private_key: None, + })) + .expect_status(Some(StatusCode::BAD_REQUEST)), + ) + .execute() + .await + .expect("unexpected success"); +} + +// Fail to create a SAML IdP out of a descriptor with no SSO redirect binding url +#[nexus_test] +async fn test_create_a_saml_idp_invalid_descriptor_no_redirect_binding( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + let silo: Silo = create_silo(&client, "discoverable", true).await; + + let saml_idp_descriptor = { + let mut saml_idp_descriptor = SAML_IDP_DESCRIPTOR.to_string(); + saml_idp_descriptor + .lines() + .filter(|x| { + !x.contains( + "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", + ) + }) + .map(|x| x.to_string()) + .collect::>() + .join("\n") + }; + + assert!(!saml_idp_descriptor + .contains("urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect")); + + let server = Server::run(); + server.expect( + Expectation::matching(request::method_path("GET", "/descriptor")) + .respond_with(status_code(200).body(saml_idp_descriptor)), + ); + + NexusRequest::new( + RequestBuilder::new( + client, + Method::POST, + "/silos/discoverable/saml_identity_provider", + ) + .body(Some(¶ms::SiloSamlIdentityProviderCreate { + identity: IdentityMetadataCreateParams { + name: "some-totally-real-saml-provider" + .to_string() + .parse() + .unwrap(), + description: "an org".to_string(), + }, + + silo_id: silo.identity.id, + idp_metadata_url: server.url("/descriptor").to_string(), + + idp_entity_id: "entity_id".to_string(), + sp_client_id: "client_id".to_string(), + acs_url: "http://acs".to_string(), + slo_url: "http://slo".to_string(), + technical_contact_email: "technical@fake".to_string(), + + public_cert: None, + private_key: None, + })) + .expect_status(Some(StatusCode::BAD_REQUEST)), + ) + .execute() + .await + .expect("unexpected success"); +} + +// Create a hidden Silo with a SAML IdP +#[nexus_test] +async fn test_create_a_hidden_silo_saml_idp( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + let silo: Silo = create_silo(&client, "hidden", false).await; + + // Valid IdP descriptor + let saml_idp_descriptor = SAML_IDP_DESCRIPTOR.to_string(); + + let server = Server::run(); + server.expect( + Expectation::matching(request::method_path("GET", "/descriptor")) + .respond_with(status_code(200).body(saml_idp_descriptor)), + ); + + let silo_saml_idp: SiloSamlIdentityProvider = object_create( + client, + "/silos/hidden/saml_identity_provider", + ¶ms::SiloSamlIdentityProviderCreate { + identity: IdentityMetadataCreateParams { + name: "some-totally-real-saml-provider" + .to_string() + .parse() + .unwrap(), + description: "an org".to_string(), + }, + + silo_id: silo.identity.id, + idp_metadata_url: server.url("/descriptor").to_string(), + + idp_entity_id: "entity_id".to_string(), + sp_client_id: "client_id".to_string(), + acs_url: "http://acs".to_string(), + slo_url: "http://slo".to_string(), + technical_contact_email: "technical@fake".to_string(), + + public_cert: None, + private_key: None, + }, + ) + .await; + + // Expect the SSO redirect when trying to log in + let result = NexusRequest::new( + RequestBuilder::new( + client, + Method::GET, + &format!("/login/{}", silo_saml_idp.identity.id), + ) + .expect_status(Some(StatusCode::FOUND)), + ) + .execute() + .await + .expect("expected success"); + + assert!(result.headers["Location"] + .to_str() + .unwrap() + .to_string() + .starts_with( + "https://idp.example.org/SAML2/SSO/Redirect?SAMLRequest=", + )); + + // Expect that the list of providers is empty + let identity_providers: Vec = + NexusRequest::object_get(client, "/identity_provider") + .execute() + .await + .expect("success") + .parsed_body() + .unwrap(); + + assert_eq!(identity_providers.len(), 0); +} + +// Can't create a SAML IdP if the metadata URL returns something that's not 200 +#[nexus_test] +async fn test_saml_idp_metadata_url_404(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + + let silo: Silo = create_silo(&client, "discoverable", true).await; + + let server = Server::run(); + server.expect( + Expectation::matching(request::method_path("GET", "/descriptor")) + .respond_with(status_code(404).body("no descriptor found")), + ); + + NexusRequest::new( + RequestBuilder::new( + client, + Method::POST, + "/silos/discoverable/saml_identity_provider", + ) + .body(Some(¶ms::SiloSamlIdentityProviderCreate { + identity: IdentityMetadataCreateParams { + name: "some-totally-real-saml-provider" + .to_string() + .parse() + .unwrap(), + description: "an org".to_string(), + }, + + silo_id: silo.identity.id, + idp_metadata_url: server.url("/descriptor").to_string(), + + idp_entity_id: "entity_id".to_string(), + sp_client_id: "client_id".to_string(), + acs_url: "http://acs".to_string(), + slo_url: "http://slo".to_string(), + technical_contact_email: "technical@fake".to_string(), + + public_cert: None, + private_key: None, + })) + .expect_status(Some(StatusCode::BAD_REQUEST)), + ) + .execute() + .await + .expect("unexpected success"); +} + +// Can't create a SAML IdP if the metadata URL isn't a URL +#[nexus_test] +async fn test_saml_idp_metadata_url_invalid( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + let silo: Silo = create_silo(&client, "discoverable", true).await; + + NexusRequest::new( + RequestBuilder::new( + client, + Method::POST, + "/silos/discoverable/saml_identity_provider", + ) + .body(Some(¶ms::SiloSamlIdentityProviderCreate { + identity: IdentityMetadataCreateParams { + name: "some-totally-real-saml-provider" + .to_string() + .parse() + .unwrap(), + description: "an org".to_string(), + }, + + silo_id: silo.identity.id, + idp_metadata_url: "htttps://fake.url".to_string(), + + idp_entity_id: "entity_id".to_string(), + sp_client_id: "client_id".to_string(), + acs_url: "http://acs".to_string(), + slo_url: "http://slo".to_string(), + technical_contact_email: "technical@fake".to_string(), + + public_cert: None, + private_key: None, + })) + .expect_status(Some(StatusCode::BAD_REQUEST)), + ) + .execute() + .await + .expect("unexpected success"); +} diff --git a/nexus/tests/output/nexus_tags.txt b/nexus/tests/output/nexus_tags.txt index c3816b9c581..7c074fe185f 100644 --- a/nexus/tests/output/nexus_tags.txt +++ b/nexus/tests/output/nexus_tags.txt @@ -45,6 +45,11 @@ project_instances_instance_stop /organizations/{organization_name}/proj project_instances_migrate_instance /organizations/{organization_name}/projects/{project_name}/instances/{instance_name}/migrate project_instances_post /organizations/{organization_name}/projects/{project_name}/instances +API operations found with tag "login" +OPERATION ID URL PATH +ask_user_to_login_to_provider /login/{provider_id} +consume_credentials_and_authn_user /login/{provider_id} + API operations found with tag "metrics" OPERATION ID URL PATH timeseries_schema_get /timeseries/schema @@ -98,6 +103,8 @@ sagas_get_saga /sagas/{saga_id} API operations found with tag "silos" OPERATION ID URL PATH +identity_provider_list /identity_provider +silo_saml_idp_create /silos/{silo_name}/saml_identity_provider silos_delete_silo /silos/{silo_name} silos_get /silos silos_get_silo /silos/{silo_name} diff --git a/nexus/tests/output/uncovered-authz-endpoints.txt b/nexus/tests/output/uncovered-authz-endpoints.txt index aebceb8c5d3..aafc6520496 100644 --- a/nexus/tests/output/uncovered-authz-endpoints.txt +++ b/nexus/tests/output/uncovered-authz-endpoints.txt @@ -4,8 +4,10 @@ project_images_delete_image (delete "/organizations/{organization_n instance_network_interfaces_delete_interface (delete "/organizations/{organization_name}/projects/{project_name}/instances/{instance_name}/network-interfaces/{interface_name}") project_snapshots_delete_snapshot (delete "/organizations/{organization_name}/projects/{project_name}/snapshots/{snapshot_name}") silos_delete_silo (delete "/silos/{silo_name}") +identity_provider_list (get "/identity_provider") images_get (get "/images") images_get_image (get "/images/{image_name}") +ask_user_to_login_to_provider (get "/login/{provider_id}") project_images_get (get "/organizations/{organization_name}/projects/{project_name}/images") project_images_get_image (get "/organizations/{organization_name}/projects/{project_name}/images/{image_name}") instance_disks_get (get "/organizations/{organization_name}/projects/{project_name}/instances/{instance_name}/disks") @@ -19,8 +21,10 @@ silos_get (get "/silos") silos_get_silo (get "/silos/{silo_name}") images_post (post "/images") spoof_login (post "/login") +consume_credentials_and_authn_user (post "/login/{provider_id}") logout (post "/logout") project_images_post (post "/organizations/{organization_name}/projects/{project_name}/images") instance_network_interfaces_post (post "/organizations/{organization_name}/projects/{project_name}/instances/{instance_name}/network-interfaces") project_snapshots_post (post "/organizations/{organization_name}/projects/{project_name}/snapshots") silos_post (post "/silos") +silo_saml_idp_create (post "/silos/{silo_name}/saml_identity_provider") diff --git a/openapi/nexus.json b/openapi/nexus.json index ff75aab5120..90957551fac 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -210,6 +210,37 @@ } } }, + "/identity_provider": { + "get": { + "tags": [ + "silos" + ], + "summary": "Return a list of all identity providers for all discoverable silos", + "operationId": "identity_provider_list", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Array_of_SiloIdentityProvider", + "type": "array", + "items": { + "$ref": "#/components/schemas/SiloIdentityProvider" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/images": { "get": { "tags": [ @@ -404,6 +435,68 @@ } } }, + "/login/{provider_id}": { + "get": { + "tags": [ + "login" + ], + "summary": "Ask the user to login to their identity provider", + "description": "Either display a page asking a user for their credentials, or redirect them to their identity provider.", + "operationId": "ask_user_to_login_to_provider", + "parameters": [ + { + "in": "path", + "name": "provider_id", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "simple" + } + ], + "responses": { + "default": { + "description": "", + "content": { + "*/*": { + "schema": {} + } + } + } + } + }, + "post": { + "tags": [ + "login" + ], + "summary": "Consume some sort of credentials, and authenticate a user.", + "description": "Either receive a username and password, or some sort of identity provider data (like a SAMLResponse). Use these to set the user's session cookie.", + "operationId": "consume_credentials_and_authn_user", + "parameters": [ + { + "in": "path", + "name": "provider_id", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "simple" + } + ], + "responses": { + "default": { + "description": "", + "content": { + "*/*": { + "schema": {} + } + } + } + } + } + }, "/logout": { "post": { "tags": [ @@ -4563,6 +4656,55 @@ } } }, + "/silos/{silo_name}/saml_identity_provider": { + "post": { + "tags": [ + "silos" + ], + "summary": "Create a new SAML identity provider for a silo.", + "operationId": "silo_saml_idp_create", + "parameters": [ + { + "in": "path", + "name": "silo_name", + "description": "The silo's unique name.", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SiloSamlIdentityProviderCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "successful creation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SiloSamlIdentityProvider" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/timeseries/schema": { "get": { "tags": [ @@ -6617,6 +6759,22 @@ "name" ] }, + "SiloIdentityProvider": { + "type": "object", + "properties": { + "provider_id": { + "type": "string", + "format": "uuid" + }, + "provider_type": { + "type": "string" + } + }, + "required": [ + "provider_id", + "provider_type" + ] + }, "SiloResultsPage": { "description": "A single page of results", "type": "object", @@ -6638,6 +6796,147 @@ "items" ] }, + "SiloSamlIdentityProvider": { + "description": "A SAML configuration specifies both IDP and SP details", + "type": "object", + "properties": { + "acs_url": { + "description": "service provider endpoint where the response will be sent", + "type": "string" + }, + "description": { + "description": "human-readable free-form text about a resource", + "type": "string" + }, + "id": { + "description": "unique, immutable, system-controlled identifier for each resource", + "type": "string", + "format": "uuid" + }, + "idp_entity_id": { + "description": "idp's entity id", + "type": "string" + }, + "idp_metadata_url": { + "description": "url where identity provider metadata descriptor is", + "type": "string" + }, + "name": { + "description": "unique, mutable, user-controlled identifier for each resource", + "allOf": [ + { + "$ref": "#/components/schemas/Name" + } + ] + }, + "private_key": { + "nullable": true, + "writeOnly": true, + "type": "string" + }, + "public_cert": { + "nullable": true, + "description": "optional request signing key pair (base64 encoded der files)", + "type": "string" + }, + "slo_url": { + "description": "service provider endpoint where the idp should send log out requests", + "type": "string" + }, + "sp_client_id": { + "description": "sp's client id", + "type": "string" + }, + "technical_contact_email": { + "description": "customer's technical contact for saml configuration", + "type": "string" + }, + "time_created": { + "description": "timestamp when this resource was created", + "type": "string", + "format": "date-time" + }, + "time_modified": { + "description": "timestamp when this resource was last modified", + "type": "string", + "format": "date-time" + } + }, + "required": [ + "acs_url", + "description", + "id", + "idp_entity_id", + "idp_metadata_url", + "name", + "slo_url", + "sp_client_id", + "technical_contact_email", + "time_created", + "time_modified" + ] + }, + "SiloSamlIdentityProviderCreate": { + "description": "Create-time identity-related parameters", + "type": "object", + "properties": { + "acs_url": { + "description": "service provider endpoint where the response will be sent", + "type": "string" + }, + "description": { + "type": "string" + }, + "idp_entity_id": { + "description": "idp's entity id", + "type": "string" + }, + "idp_metadata_url": { + "description": "url where identity provider metadata descriptor is", + "type": "string" + }, + "name": { + "$ref": "#/components/schemas/Name" + }, + "private_key": { + "nullable": true, + "writeOnly": true, + "type": "string" + }, + "public_cert": { + "nullable": true, + "description": "optional request signing key pair (base64 encoded der files)", + "type": "string" + }, + "silo_id": { + "type": "string", + "format": "uuid" + }, + "slo_url": { + "description": "service provider endpoint where the idp should send log out requests", + "type": "string" + }, + "sp_client_id": { + "description": "sp's client id", + "type": "string" + }, + "technical_contact_email": { + "description": "customer's technical contact for saml configuration", + "type": "string" + } + }, + "required": [ + "acs_url", + "description", + "idp_entity_id", + "idp_metadata_url", + "name", + "silo_id", + "slo_url", + "sp_client_id", + "technical_contact_email" + ] + }, "Sled": { "description": "Client view of an [`Sled`]", "type": "object", @@ -7853,6 +8152,13 @@ "url": "http://oxide.computer/docs/#xxx" } }, + { + "name": "login", + "description": "Authentication endpoints", + "externalDocs": { + "url": "http://oxide.computer/docs/#xxx" + } + }, { "name": "metrics", "description": "Metrics provide insight into the operation of the Oxide deployment. These include telemetry on hardware and software components that can be used to understand the current state as well as to diagnose issues.", From 9349eced4b6e9b9b784683a76f0157d12b631690 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Mon, 4 Apr 2022 15:22:27 -0400 Subject: [PATCH 2/3] delete of silo deletes providers --- nexus/src/db/datastore.rs | 43 ++++++++++++++- nexus/tests/integration_tests/silos.rs | 75 ++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/nexus/src/db/datastore.rs b/nexus/src/db/datastore.rs index 6fcf2aac3ec..6d063bd1a73 100644 --- a/nexus/src/db/datastore.rs +++ b/nexus/src/db/datastore.rs @@ -2829,7 +2829,48 @@ impl DataStore { ) })?; - info!(opctx.log, "deleted {} silo users for silo {}", updated_rows, id,); + info!(opctx.log, "deleted {} silo users for silo {}", updated_rows, id); + + // delete all silo identity providers + use db::schema::silo_identity_provider::dsl as idp_dsl; + + let updated_rows = diesel::delete(idp_dsl::silo_identity_provider) + .filter(idp_dsl::silo_id.eq(id)) + .execute_async(self.pool()) + .await + .map_err(|e| { + public_error_from_diesel_pool( + e, + ErrorHandler::NotFoundByLookup( + ResourceType::Silo, + LookupType::ById(id), + ), + ) + })?; + + info!(opctx.log, "deleted {} silo IdPs for silo {}", updated_rows, id); + + use db::schema::silo_saml_identity_provider::dsl as saml_idp_dsl; + + let updated_rows = + diesel::delete(saml_idp_dsl::silo_saml_identity_provider) + .filter(saml_idp_dsl::silo_id.eq(id)) + .execute_async(self.pool()) + .await + .map_err(|e| { + public_error_from_diesel_pool( + e, + ErrorHandler::NotFoundByLookup( + ResourceType::Silo, + LookupType::ById(id), + ), + ) + })?; + + info!( + opctx.log, + "deleted {} silo saml IdPs for silo {}", updated_rows, id + ); Ok(()) } diff --git a/nexus/tests/integration_tests/silos.rs b/nexus/tests/integration_tests/silos.rs index ae5be43a5a8..cd911a03b50 100644 --- a/nexus/tests/integration_tests/silos.rs +++ b/nexus/tests/integration_tests/silos.rs @@ -265,6 +265,81 @@ async fn test_create_a_saml_idp(cptestctx: &ControlPlaneTestContext) { assert_eq!(identity_providers[0].provider_id, silo_saml_idp.identity.id); } +// Test that deleting the silo deletes the idp +#[nexus_test] +async fn test_deleting_a_silo_deletes_the_idp( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + let silo: Silo = create_silo(&client, "discoverable", true).await; + + let saml_idp_descriptor = SAML_IDP_DESCRIPTOR; + + let server = Server::run(); + server.expect( + Expectation::matching(request::method_path("GET", "/descriptor")) + .respond_with(status_code(200).body(saml_idp_descriptor)), + ); + + let silo_saml_idp: SiloSamlIdentityProvider = object_create( + client, + "/silos/discoverable/saml_identity_provider", + ¶ms::SiloSamlIdentityProviderCreate { + identity: IdentityMetadataCreateParams { + name: "some-totally-real-saml-provider" + .to_string() + .parse() + .unwrap(), + description: "an org".to_string(), + }, + + silo_id: silo.identity.id, + idp_metadata_url: server.url("/descriptor").to_string(), + + idp_entity_id: "entity_id".to_string(), + sp_client_id: "client_id".to_string(), + acs_url: "http://acs".to_string(), + slo_url: "http://slo".to_string(), + technical_contact_email: "technical@fake".to_string(), + + public_cert: None, + private_key: None, + }, + ) + .await; + + // Expect that the list of providers contains this + let identity_providers: Vec = + NexusRequest::object_get(client, "/identity_provider") + .execute() + .await + .expect("success") + .parsed_body() + .unwrap(); + + assert_eq!(identity_providers.len(), 1); + assert_eq!(identity_providers[0].provider_id, silo_saml_idp.identity.id); + + // Delete the silo + NexusRequest::object_delete(&client, &"/silos/discoverable") + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make request"); + + // Expect that the provider is gone + let identity_providers: Vec = + NexusRequest::object_get(client, "/identity_provider") + .execute() + .await + .expect("success") + .parsed_body() + .unwrap(); + + assert_eq!(identity_providers.len(), 0); +} + // Fail to create a SAML IdP out of an invalid descriptor #[nexus_test] async fn test_create_a_saml_idp_invalid_descriptor_truncated( From e030e3e5ecee4ca2d7ddd8bf9f03d6c1129edb0d Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Mon, 4 Apr 2022 15:28:34 -0400 Subject: [PATCH 3/3] comment -> documentation means it should be per field --- nexus/src/external_api/params.rs | 4 +++- openapi/nexus.json | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/nexus/src/external_api/params.rs b/nexus/src/external_api/params.rs index 53f554f98b8..5975f6453ed 100644 --- a/nexus/src/external_api/params.rs +++ b/nexus/src/external_api/params.rs @@ -51,8 +51,10 @@ pub struct SiloSamlIdentityProviderCreate { /// customer's technical contact for saml configuration pub technical_contact_email: String, - /// optional request signing key pair (base64 encoded der files) + /// optional request signing public certificate (base64 encoded der file) pub public_cert: Option, + + /// optional request signing private key (base64 encoded der file) #[serde(skip_serializing)] pub private_key: Option, } diff --git a/openapi/nexus.json b/openapi/nexus.json index 90957551fac..e8249b84fc7 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -6901,11 +6901,12 @@ "private_key": { "nullable": true, "writeOnly": true, + "description": "optional request signing private key (base64 encoded der file)", "type": "string" }, "public_cert": { "nullable": true, - "description": "optional request signing key pair (base64 encoded der files)", + "description": "optional request signing public certificate (base64 encoded der file)", "type": "string" }, "silo_id": {