From 365050af9ec06fe23c2272d64fa818a0b58e5951 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Fri, 29 Apr 2022 09:41:34 -0400 Subject: [PATCH 01/54] 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 creating and 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. Table column order was corrected - I didn't see any bugs from this but am aware that the bug potential exists. Currently, the external-authenticator role only applies to the default silo, not any new ones. Making that opctx work with any silo will unlock some more testing. This commit also moves the unauthorized_coverage code into the unauthorized function, and sets up both hardcoded and new non-hardcoded data. When global images support was added, a hardcoded port was used for the httptest server and it was a problem to find an unused port, but allowing for non-hardcoded data means any port can be used. --- Cargo.lock | 254 +++++++- common/src/api/external/mod.rs | 32 + common/src/sql/dbinit.sql | 46 +- nexus/Cargo.toml | 4 + nexus/src/authn/mod.rs | 1 + nexus/src/authn/silos.rs | 156 +++++ nexus/src/authz/api_resources.rs | 16 + nexus/src/authz/omicron.polar | 52 ++ nexus/src/authz/oso_generic.rs | 2 + nexus/src/db/datastore.rs | 77 +++ nexus/src/db/fixed_data/user_builtin.rs | 1 - nexus/src/db/lookup.rs | 24 +- nexus/src/db/model.rs | 120 +++- nexus/src/db/schema.rs | 38 +- nexus/src/external_api/console_api.rs | 115 +++- nexus/src/external_api/http_entrypoints.rs | 77 +++ nexus/src/external_api/params.rs | 35 ++ nexus/src/external_api/tag-config.json | 6 + nexus/src/external_api/views.rs | 17 + nexus/src/nexus.rs | 110 ++++ nexus/tests/integration_tests/endpoints.rs | 57 +- nexus/tests/integration_tests/mod.rs | 1 - nexus/tests/integration_tests/silos.rs | 578 +++++++++++++++++- nexus/tests/integration_tests/unauthorized.rs | 272 ++++++++- .../unauthorized_coverage.rs | 147 ----- nexus/tests/output/nexus_tags.txt | 7 + .../output/uncovered-authz-endpoints.txt | 2 + openapi/nexus.json | 325 ++++++++++ 28 files changed, 2338 insertions(+), 234 deletions(-) create mode 100644 nexus/src/authn/silos.rs delete mode 100644 nexus/tests/integration_tests/unauthorized_coverage.rs diff --git a/Cargo.lock b/Cargo.lock index ce13533247d..3e2591eee3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -224,6 +224,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" @@ -350,6 +373,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" @@ -379,6 +411,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" @@ -781,14 +824,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]] @@ -805,13 +872,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", ] @@ -844,6 +922,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" @@ -1995,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.123" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb691a747a7ab48abc15c5b42066eaafde10dc427e3b6ee2a1cf43db04c763bd" +[[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" @@ -2011,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" @@ -2111,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" @@ -2268,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" @@ -2463,6 +2615,9 @@ dependencies = [ "omicron-test-utils", "openapi-lint", "openapiv3", + "openssl", + "openssl-probe", + "openssl-sys", "oso", "oximeter", "oximeter-client", @@ -2476,6 +2631,7 @@ dependencies = [ "regex", "reqwest", "ring", + "samael", "schemars", "serde", "serde_json", @@ -2964,6 +3120,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" @@ -3363,6 +3525,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.18" @@ -3653,6 +3825,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" @@ -3724,6 +3902,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" @@ -3969,7 +4173,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", @@ -4055,6 +4259,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" @@ -4265,6 +4475,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" @@ -4272,7 +4492,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]] @@ -4872,7 +5103,7 @@ dependencies = [ "serde", "serde_json", "serde_plain", - "snafu", + "snafu 0.7.0", "tempfile", "untrusted", "url", @@ -5445,6 +5676,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 29c9824cf55..4a43971e33a 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, SshKey, ConsoleSession, GlobalImage, @@ -1743,6 +1745,36 @@ impl std::fmt::Display for Digest { } } +/// 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 3c5cbc31c5f..84363611197 100644 --- a/common/src/sql/dbinit.sql +++ b/common/src/sql/dbinit.sql @@ -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 ); @@ -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, @@ -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, + 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 */ diff --git a/nexus/Cargo.toml b/nexus/Cargo.toml index 95957b98ab0..69b52e4f5ff 100644 --- a/nexus/Cargo.toml +++ b/nexus/Cargo.toml @@ -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/" } @@ -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" } serde_json = "1.0" serde_urlencoded = "0.7.1" serde_with = "1.12.1" diff --git a/nexus/src/authn/mod.rs b/nexus/src/authn/mod.rs index af083fe7b31..ca65c0d1909 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_EXTERNAL_AUTHN; diff --git a/nexus/src/authn/silos.rs b/nexus/src/authn/silos.rs new file mode 100644 index 00000000000..14c6689ce17 --- /dev/null +++ b/nexus/src/authn/silos.rs @@ -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), +} + +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(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"); + } + + // TODO validate DER keys + + Ok(()) + } + + pub fn sign_in_url(&self, relay_state: Option) -> 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()))?; + + 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 { + 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 : 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>> { + 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/authz/api_resources.rs b/nexus/src/authz/api_resources.rs index 41dad42d91c..db9e37f362f 100644 --- a/nexus/src/authz/api_resources.rs +++ b/nexus/src/authz/api_resources.rs @@ -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", diff --git a/nexus/src/authz/omicron.polar b/nexus/src/authz/omicron.polar index b5ee29a9cb3..04ce03c2b00 100644 --- a/nexus/src/authz/omicron.polar +++ b/nexus/src/authz/omicron.polar @@ -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) + if has_role(actor, "external-authenticator", silo.fleet); + resource Organization { permissions = [ "list_children", @@ -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) + 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); diff --git a/nexus/src/authz/oso_generic.rs b/nexus/src/authz/oso_generic.rs index 185cecd355f..3cbd0b48bb9 100644 --- a/nexus/src/authz/oso_generic.rs +++ b/nexus/src/authz/oso_generic.rs @@ -69,6 +69,8 @@ pub fn make_omicron_oso(log: &slog::Logger) -> Result { SshKey::init(), Silo::init(), SiloUser::init(), + SiloIdentityProvider::init(), + SiloSamlIdentityProvider::init(), Sled::init(), UpdateAvailableArtifact::init(), UserBuiltin::init(), diff --git a/nexus/src/db/datastore.rs b/nexus/src/db/datastore.rs index 83126f60e72..74e114bdb9b 100644 --- a/nexus/src/db/datastore.rs +++ b/nexus/src/db/datastore.rs @@ -2688,9 +2688,86 @@ impl DataStore { 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(()) } + pub async fn silo_saml_identity_provider_create( + &self, + provider: db::model::SiloSamlIdentityProvider, + ) -> CreateResult { + self.pool() + .transaction(move |conn| { + // insert silo identity provider record with type Saml + 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, + name: provider.name().clone(), + provider_type: + db::model::SiloIdentityProviderTypeEnum::Saml, + provider_id: provider.id(), + }) + .execute(conn)?; + + // insert silo saml identity provider record + 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)) + }) + } + /// Return the next available IPv6 address for an Oxide service running on /// the provided sled. pub async fn next_ipv6_address( diff --git a/nexus/src/db/fixed_data/user_builtin.rs b/nexus/src/db/fixed_data/user_builtin.rs index f878feb1874..03b646ee0ab 100644 --- a/nexus/src/db/fixed_data/user_builtin.rs +++ b/nexus/src/db/fixed_data/user_builtin.rs @@ -78,7 +78,6 @@ lazy_static! { /// Internal user used by Nexus when authenticating external requests pub static ref USER_EXTERNAL_AUTHN: UserBuiltinConfig = UserBuiltinConfig::new_static( - // "3a8a" looks a bit like "saga". "001de000-05e4-4000-8000-000000000003", &SILO_ID.to_string().as_str(), "external-authn", diff --git a/nexus/src/db/lookup.rs b/nexus/src/db/lookup.rs index 5bbe68b2cf9..d0444313e4f 100644 --- a/nexus/src/db/lookup.rs +++ b/nexus/src/db/lookup.rs @@ -397,7 +397,7 @@ impl<'a> Root<'a> { lookup_resource! { name = "Silo", ancestors = [], - children = [ "Organization" ], + children = [ "Organization", "SiloIdentityProvider", "SiloSamlIdentityProvider" ], lookup_by_name = true, soft_deletes = true, primary_key_columns = [ { column_name = "id", rust_type = Uuid } ] @@ -412,6 +412,28 @@ lookup_resource! { primary_key_columns = [ { column_name = "id", rust_type = Uuid } ] } +lookup_resource! { + name = "SiloIdentityProvider", + ancestors = [ "Silo" ], + children = [], + lookup_by_name = true, + soft_deletes = false, + primary_key_columns = [ + { column_name = "provider_id", rust_type = Uuid } + ] +} + +lookup_resource! { + name = "SiloSamlIdentityProvider", + ancestors = [ "Silo" ], + children = [], + lookup_by_name = true, + soft_deletes = true, + primary_key_columns = [ + { column_name = "id", rust_type = Uuid }, + ] +} + lookup_resource! { name = "SshKey", ancestors = [ "Silo", "SiloUser" ], diff --git a/nexus/src/db/model.rs b/nexus/src/db/model.rs index 9a8835c8e7b..fb893f02ea5 100644 --- a/nexus/src/db/model.rs +++ b/nexus/src/db/model.rs @@ -11,14 +11,15 @@ use crate::db::schema::{ console_session, dataset, disk, global_image, image, instance, metric_producer, network_interface, organization, oximeter, project, rack, region, role_assignment_builtin, role_builtin, router_route, silo, - silo_user, sled, snapshot, ssh_key, update_available_artifact, - user_builtin, volume, vpc, vpc_firewall_rule, vpc_router, vpc_subnet, - zpool, + silo_identity_provider, silo_saml_identity_provider, silo_user, sled, + snapshot, ssh_key, update_available_artifact, user_builtin, volume, vpc, + vpc_firewall_rule, vpc_router, vpc_subnet, zpool, }; 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}; @@ -40,6 +41,8 @@ use std::net::Ipv4Addr; use std::net::Ipv6Addr; use std::net::SocketAddr; use std::net::SocketAddrV6; +use std::str::FromStr; +use std::string::ToString; use uuid::Uuid; // TODO: Break up types into multiple files @@ -1004,6 +1007,117 @@ 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 name: Name, + pub provider_id: Uuid, +} + +impl SiloIdentityProvider { + pub fn id(&self) -> Uuid { + self.provider_id + } +} + +#[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 a user's public SSH key within the database. #[derive(Clone, Debug, Insertable, Queryable, Resource, Selectable)] #[table_name = "ssh_key"] diff --git a/nexus/src/db/schema.rs b/nexus/src/db/schema.rs index 21245dc5dad..09cc1472f6b 100644 --- a/nexus/src/db/schema.rs +++ b/nexus/src/db/schema.rs @@ -138,10 +138,11 @@ table! { id -> Uuid, name -> Text, description -> Text, - discoverable -> Bool, time_created -> Timestamptz, time_modified -> Timestamptz, time_deleted -> Nullable, + + discoverable -> Bool, rcgen -> Int8, } } @@ -157,6 +158,39 @@ table! { } } +table! { + silo_identity_provider (silo_id, provider_id) { + silo_id -> Uuid, + provider_type -> Text, + name -> Text, + provider_id -> Uuid, + } +} + +table! { + silo_saml_identity_provider (id) { + id -> Uuid, + name -> Text, + description -> Text, + time_created -> Timestamptz, + time_modified -> Timestamptz, + time_deleted -> Nullable, + + 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, + } +} + table! { ssh_key (id) { id -> Uuid, @@ -463,6 +497,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 c41a716bfcf..ff9289e8504 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::{ @@ -86,6 +89,116 @@ pub async fn spoof_login( .body("ok".into())?) // TODO: what do we return from login? } +#[derive(Deserialize, JsonSchema)] +pub struct LoginToProviderPathParam { + pub silo_name: crate::db::model::Name, + pub provider_name: crate::db::model::Name, +} + +/// 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/{silo_name}/{provider_name}", + 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(); + + // Use opctx_external_authn because this request will be + // unauthenticated. + let opctx = nexus.opctx_external_authn(); + + let identity_provider = nexus + .get_silo_identity_provider( + &opctx, + &path_params.silo_name, + &path_params.provider_name, + ) + .await?; + + match identity_provider { + SiloIdentityProviderType::Local => { + todo!() + } + SiloIdentityProviderType::Ldap => { + todo!() + } + SiloIdentityProviderType::Saml(silo_saml_identity_provider) => { + let relay_state = None; + let sign_in_url = silo_saml_identity_provider + .sign_in_url(relay_state) + .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 figure out why this fails + //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/{silo_name}/{provider_name}", + 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(); + + // Use opctx_external_authn because this request will be + // unauthenticated. + let opctx = nexus.opctx_external_authn(); + + let identity_provider = nexus + .get_silo_identity_provider( + &opctx, + &path_params.silo_name, + &path_params.provider_name, + ) + .await?; + + match identity_provider { + SiloIdentityProviderType::Local => { + todo!() + } + SiloIdentityProviderType::Ldap => { + todo!() + } + SiloIdentityProviderType::Saml(_silo_saml_identity_provider) => { + todo!() + } + } + }; + // TODO figure out why this fails + //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 92ee274ad54..c213a1eebe0 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -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(silo_saml_idp_create)?; + api.register(silo_saml_idp_fetch)?; + api.register(organizations_get)?; api.register(organizations_post)?; api.register(organizations_get_organization)?; @@ -186,6 +190,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(()) } @@ -346,6 +352,77 @@ async fn silos_delete_silo( apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await } +// Silo identity providers + +// 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 +} + +/// Path parameters for Silo SAML identity provider requests +#[derive(Deserialize, JsonSchema)] +struct SiloSamlPathParam { + /// The silo's unique name. + silo_name: Name, + /// The SAML identity provider's name + provider_name: Name, +} + +/// GET a silo's SAML identity provider +#[endpoint { + method = GET, + path = "/silos/{silo_name}/saml_identity_provider/{provider_name}", + tags = ["silos"], +}] +async fn silo_saml_idp_fetch( + rqctx: Arc>>, + path_params: Path, +) -> Result, HttpError> { + let apictx = rqctx.context(); + let nexus = &apictx.nexus; + + let path_params = path_params.into_inner(); + + let handler = async { + let opctx = OpContext::for_external_api(&rqctx).await?; + let provider = nexus + .silo_saml_identity_provider_fetch( + &opctx, + &path_params.silo_name, + &path_params.provider_name, + ) + .await?; + + Ok(HttpResponseOk(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 5df760fce91..641ac1bc605 100644 --- a/nexus/src/external_api/params.rs +++ b/nexus/src/external_api/params.rs @@ -25,6 +25,41 @@ 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 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, +} + // 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 5ba81fac1ce..76335136d9e 100644 --- a/nexus/src/external_api/tag-config.json +++ b/nexus/src/external_api/tag-config.json @@ -38,6 +38,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 0da41959b4c..7402029c6f8 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 b6c4dfb3e35..83e693e03e9 100644 --- a/nexus/src/nexus.rs +++ b/nexus/src/nexus.rs @@ -3827,6 +3827,116 @@ impl Nexus { Ok(db_silo_user) } + // Silo authn identity providers + + pub async fn silo_saml_identity_provider_create( + &self, + opctx: &OpContext, + silo_name: &Name, + params: params::SiloSamlIdentityProviderCreate, + ) -> CreateResult { + let (.., db_silo) = LookupPath::new(opctx, &self.db_datastore) + .silo_name(silo_name) + .fetch_for(authz::Action::CreateChild) + .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, + opctx: &OpContext, + silo_name: &Name, + provider_name: &Name, + ) -> LookupResult { + let (.., silo_saml_identity_provider) = + LookupPath::new(opctx, &self.datastore()) + .silo_name(silo_name) + .silo_saml_identity_provider_name(provider_name) + .fetch() + .await?; + Ok(silo_saml_identity_provider) + } + + /// First, look up the row in silo_identity_provider to get provider type, + /// then look in a specific table for the provider details. + pub async fn get_silo_identity_provider( + &self, + opctx: &OpContext, + silo_name: &Name, + provider_name: &Name, + ) -> LookupResult { + let (.., silo_identity_provider) = + LookupPath::new(opctx, &self.datastore()) + .silo_name(silo_name) + .silo_identity_provider_name(provider_name) + .fetch() + .await?; + + match silo_identity_provider.provider_type { + db::model::SiloIdentityProviderTypeEnum::Local => { + return Err(Error::unavail( + &"local silo authn provider not yet supported", + )); + } + + db::model::SiloIdentityProviderTypeEnum::Ldap => { + return Err(Error::unavail( + &"ldap silo authn provider not yet supported", + )); + } + + db::model::SiloIdentityProviderTypeEnum::Saml => { + let (.., silo_saml_identity_provider) = + LookupPath::new(opctx, &self.datastore()) + .silo_name(silo_name) + .silo_saml_identity_provider_name(provider_name) + .fetch() + .await?; + + Ok(authn::silos::SiloIdentityProviderType::Saml(Box::new( + silo_saml_identity_provider, + ))) + } + } + } + // SSH public keys pub async fn ssh_keys_list( diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index d79af1e519d..2ea768a6e19 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -217,15 +217,6 @@ lazy_static! { pub static ref DEMO_IMAGE_NAME: Name = "demo-image".parse().unwrap(); pub static ref DEMO_PROJECT_IMAGE_URL: String = format!("{}/{}", *DEMO_PROJECT_URL_IMAGES, *DEMO_IMAGE_NAME); - pub static ref DEMO_IMAGE_CREATE: params::ImageCreate = - params::ImageCreate { - identity: IdentityMetadataCreateParams { - name: DEMO_IMAGE_NAME.clone(), - description: String::from(""), - }, - source: params::ImageSource::Url(String::from("http://127.0.0.1:5555/image.raw")), - block_size: params::BlockSize::try_from(4096).unwrap(), - }; // Global Images pub static ref DEMO_GLOBAL_IMAGE_URL: String = @@ -249,6 +240,7 @@ lazy_static! { /// /// These structs are also used to check whether we're covering all endpoints in /// the public OpenAPI spec. +#[derive(Clone)] pub struct VerifyEndpoint { /// URL path for the HTTP resource to test /// @@ -281,6 +273,7 @@ pub struct VerifyEndpoint { } /// Describes the visibility of an HTTP resource +#[derive(Clone)] pub enum Visibility { /// All users can see the resource (including unauthenticated or /// unauthorized users) @@ -295,6 +288,7 @@ pub enum Visibility { } /// Describes an HTTP method supported by a particular API endpoint +#[derive(Clone)] pub enum AllowedMethod { /// HTTP "DELETE" method Delete, @@ -383,7 +377,6 @@ lazy_static! { ], }, - /* Organizations */ VerifyEndpoint { @@ -650,28 +643,6 @@ lazy_static! { ], }, - /* Project images */ - - VerifyEndpoint { - url: &*DEMO_PROJECT_URL_IMAGES, - visibility: Visibility::Protected, - allowed_methods: vec![ - AllowedMethod::GetUnimplemented, - AllowedMethod::Post( - serde_json::to_value(&*DEMO_IMAGE_CREATE).unwrap() - ), - ], - }, - - VerifyEndpoint { - url: &*DEMO_PROJECT_IMAGE_URL, - visibility: Visibility::Protected, - allowed_methods: vec![ - AllowedMethod::GetUnimplemented, - AllowedMethod::Delete, - ], - }, - /* Snapshots */ VerifyEndpoint { @@ -848,27 +819,5 @@ lazy_static! { serde_json::Value::Null )], }, - - /* Global Images */ - - VerifyEndpoint { - url: "/images", - visibility: Visibility::Public, - allowed_methods: vec![ - AllowedMethod::Get, - AllowedMethod::Post( - serde_json::to_value(&*DEMO_IMAGE_CREATE).unwrap() - ), - ], - }, - - VerifyEndpoint { - url: &*DEMO_GLOBAL_IMAGE_URL, - visibility: Visibility::Protected, - allowed_methods: vec![ - AllowedMethod::Get, - AllowedMethod::Delete, - ], - }, ]; } diff --git a/nexus/tests/integration_tests/mod.rs b/nexus/tests/integration_tests/mod.rs index 4a264e2b2e6..00c4bed67c8 100644 --- a/nexus/tests/integration_tests/mod.rs +++ b/nexus/tests/integration_tests/mod.rs @@ -21,7 +21,6 @@ mod ssh_keys; mod subnet_allocation; mod timeseries; mod unauthorized; -mod unauthorized_coverage; mod updates; mod users_builtin; mod vpc_firewall; diff --git a/nexus/tests/integration_tests/silos.rs b/nexus/tests/integration_tests/silos.rs index b704b6d0c68..102bcf3e5dc 100644 --- a/nexus/tests/integration_tests/silos.rs +++ b/nexus/tests/integration_tests/silos.rs @@ -4,20 +4,25 @@ use uuid::Uuid; -use nexus_test_utils::http_testing::{AuthnMode, NexusRequest}; +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}; 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 omicron_nexus::db::identity::Asset; +use httptest::{matchers::*, responders::*, Expectation, Server}; + #[nexus_test] async fn test_silos(cptestctx: &ControlPlaneTestContext) { let client = &cptestctx.external_client; @@ -138,3 +143,572 @@ 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 +pub 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; + + // TODO when different external-authenticator works with multiple silos + //const SILO_NAME: &str = "saml-silo"; + //let silo: Silo = create_silo(&client, SILO_NAME, true).await; + + const SILO_NAME: &str = "default-silo"; + let silo: Silo = + NexusRequest::object_get(&client, &format!("/silos/{}", SILO_NAME,)) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make request") + .parsed_body() + .unwrap(); + + 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, + &format!("/silos/{}/saml_identity_provider", SILO_NAME), + ¶ms::SiloSamlIdentityProviderCreate { + identity: IdentityMetadataCreateParams { + name: "some-totally-real-saml-provider" + .to_string() + .parse() + .unwrap(), + description: "a demo provider".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; + + // Assert external authenticator opctx can read it + let nexus = &cptestctx.server.apictx.nexus; + + let _retrieved_silo_nexus = nexus + .silo_fetch( + &nexus.opctx_external_authn(), + &omicron_common::api::external::Name::try_from( + SILO_NAME.to_string(), + ) + .unwrap() + .into(), + ) + .await + .unwrap(); + + let retrieved_silo_idp_from_nexus = nexus + .get_silo_identity_provider( + &nexus.opctx_external_authn(), + &omicron_common::api::external::Name::try_from( + SILO_NAME.to_string(), + ) + .unwrap() + .into(), + &omicron_common::api::external::Name::try_from( + "some-totally-real-saml-provider".to_string(), + ) + .unwrap() + .into(), + ) + .await + .unwrap(); + + match retrieved_silo_idp_from_nexus { + omicron_nexus::authn::silos::SiloIdentityProviderType::Saml(_) => { + // ok + } + + _ => { + assert!(false); + } + } + + // Check that created identity provider exists + let _retrieved_silo_saml_idp: SiloSamlIdentityProvider = + NexusRequest::object_get( + &client, + &format!( + "/silos/{}/saml_identity_provider/{}", + silo.identity.name, silo_saml_idp.identity.name, + ), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make request") + .parsed_body() + .unwrap(); + + // Expect the SSO redirect when trying to log in unauthenticated + let result = NexusRequest::new( + RequestBuilder::new( + client, + Method::GET, + &format!( + "/login/{}/{}", + silo.identity.name, silo_saml_idp.identity.name + ), + ) + .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=", + )); +} + +// Test that deleting the silo deletes the idp +// TODO when external-authenticator works with multiple silos +/* +#[nexus_test] +async fn test_deleting_a_silo_deletes_the_idp( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + const SILO_NAME: &str = "saml-silo"; + let silo: Silo = create_silo(&client, SILO_NAME, 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, + &format!("/silos/{}/saml_identity_provider", SILO_NAME), + ¶ms::SiloSamlIdentityProviderCreate { + identity: IdentityMetadataCreateParams { + name: "some-totally-real-saml-provider" + .to_string() + .parse() + .unwrap(), + description: "a demo provider".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; + + // Delete the silo + NexusRequest::object_delete(&client, &format!("/silos/{}", SILO_NAME)) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make request"); + + // Expect that the provider is gone +} +*/ + +// 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; + + // TODO when different external-authenticator works with multiple silos + //const SILO_NAME: &str = "saml-silo"; + //let silo: Silo = create_silo(&client, SILO_NAME, true).await; + + const SILO_NAME: &str = "default-silo"; + let silo: Silo = + NexusRequest::object_get(&client, &format!("/silos/{}", SILO_NAME,)) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make request") + .parsed_body() + .unwrap(); + + 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, + &format!("/silos/{}/saml_identity_provider", SILO_NAME), + ) + .body(Some(¶ms::SiloSamlIdentityProviderCreate { + identity: IdentityMetadataCreateParams { + name: "some-totally-real-saml-provider" + .to_string() + .parse() + .unwrap(), + description: "a demo provider".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)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .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; + + // TODO when different external-authenticator works with multiple silos + //const SILO_NAME: &str = "saml-silo"; + //let silo: Silo = create_silo(&client, SILO_NAME, true).await; + + const SILO_NAME: &str = "default-silo"; + let silo: Silo = + NexusRequest::object_get(&client, &format!("/silos/{}", SILO_NAME,)) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make request") + .parsed_body() + .unwrap(); + + let saml_idp_descriptor = { + let 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, + &format!("/silos/{}/saml_identity_provider", SILO_NAME), + ) + .body(Some(¶ms::SiloSamlIdentityProviderCreate { + identity: IdentityMetadataCreateParams { + name: "some-totally-real-saml-provider" + .to_string() + .parse() + .unwrap(), + description: "a demo provider".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)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("unexpected success"); +} + +// Create a hidden Silo with a SAML IdP +// TODO when different external-authenticator works with multiple silos +/* +#[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: "a demo provider".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=", + )); +} +*/ + +// 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; + + // TODO when different external-authenticator works with multiple silos + //const SILO_NAME: &str = "saml-silo"; + //let silo: Silo = create_silo(&client, SILO_NAME, true).await; + + const SILO_NAME: &str = "default-silo"; + let silo: Silo = + NexusRequest::object_get(&client, &format!("/silos/{}", SILO_NAME,)) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make request") + .parsed_body() + .unwrap(); + + 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, + &format!("/silos/{}/saml_identity_provider", SILO_NAME), + ) + .body(Some(¶ms::SiloSamlIdentityProviderCreate { + identity: IdentityMetadataCreateParams { + name: "some-totally-real-saml-provider" + .to_string() + .parse() + .unwrap(), + description: "a demo provider".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)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .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; + + // TODO when different external-authenticator works with multiple silos + //const SILO_NAME: &str = "saml-silo"; + //let silo: Silo = create_silo(&client, SILO_NAME, true).await; + + const SILO_NAME: &str = "default-silo"; + let silo: Silo = + NexusRequest::object_get(&client, &format!("/silos/{}", SILO_NAME,)) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make request") + .parsed_body() + .unwrap(); + + NexusRequest::new( + RequestBuilder::new( + client, + Method::POST, + &format!("/silos/{}/saml_identity_provider", SILO_NAME), + ) + .body(Some(¶ms::SiloSamlIdentityProviderCreate { + identity: IdentityMetadataCreateParams { + name: "some-totally-real-saml-provider" + .to_string() + .parse() + .unwrap(), + description: "a demo provider".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)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("unexpected success"); +} diff --git a/nexus/tests/integration_tests/unauthorized.rs b/nexus/tests/integration_tests/unauthorized.rs index b1eca1fa743..435e0539c3e 100644 --- a/nexus/tests/integration_tests/unauthorized.rs +++ b/nexus/tests/integration_tests/unauthorized.rs @@ -6,8 +6,10 @@ //! unauthorized users use super::endpoints::*; +use crate::integration_tests::silos::SAML_IDP_DESCRIPTOR; use dropshot::test_util::ClientTestContext; use dropshot::HttpErrorResponseBody; +use expectorate::assert_contents; use headers::authorization::Credentials; use http::method::Method; use http::StatusCode; @@ -20,7 +22,20 @@ use nexus_test_utils::http_testing::TestResponse; use nexus_test_utils::resource_helpers::DiskTest; use nexus_test_utils::ControlPlaneTestContext; use nexus_test_utils_macros::nexus_test; +use omicron_common::api::external::IdentityMetadataCreateParams; use omicron_nexus::authn::external::spoof; +use omicron_nexus::db::fixed_data::silo::DEFAULT_SILO; +use omicron_nexus::db::identity::Resource; +use omicron_nexus::external_api::params; +use openapiv3::OpenAPI; +use std::collections::BTreeMap; + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct Operation<'a> { + method: &'a str, + path: &'a str, + label: String, +} // This test hits a list Nexus API endpoints using both unauthenticated and // unauthorized requests to make sure we get the expected behavior (generally: @@ -56,10 +71,7 @@ async fn test_unauthorized(cptestctx: &ControlPlaneTestContext) { let log = &cptestctx.logctx.log; // Run a httptest server - let server = ServerBuilder::new() - .bind_addr("127.0.0.1:5555".parse().unwrap()) - .run() - .unwrap(); + let server = ServerBuilder::new().run().unwrap(); // Fake some data server.expect( @@ -73,7 +85,13 @@ async fn test_unauthorized(cptestctx: &ControlPlaneTestContext) { ), ); - // Create test data. + server.expect( + Expectation::matching(request::method_path("GET", "/descriptor")) + .times(1..) + .respond_with(status_code(200).body(SAML_IDP_DESCRIPTOR)), + ); + + // Create test data with hardcoded values. info!(log, "setting up resource hierarchy"); for request in &*SETUP_REQUESTS { NexusRequest::objects_post(client, request.url, &request.body) @@ -83,12 +101,121 @@ async fn test_unauthorized(cptestctx: &ControlPlaneTestContext) { .unwrap(); } - // Verify the hardcoded endpoints. + // Create test data with non-hardcoded values. + + // Create a GlobalImage + let demo_image_create: params::ImageCreate = params::ImageCreate { + identity: IdentityMetadataCreateParams { + name: DEMO_IMAGE_NAME.clone(), + description: String::from(""), + }, + source: params::ImageSource::Url(server.url("/image.raw").to_string()), + block_size: params::BlockSize::try_from(4096).unwrap(), + }; + + NexusRequest::objects_post(client, "/images", &demo_image_create) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap(); + + // Create a SAML identity provider + let demo_silo_saml_idp_create = params::SiloSamlIdentityProviderCreate { + identity: IdentityMetadataCreateParams { + name: "demo-saml-provider".to_string().parse().unwrap(), + description: "a demo provider".to_string(), + }, + + silo_id: DEFAULT_SILO.id().clone(), + 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, + }; + + NexusRequest::objects_post( + client, + "/silos/default-silo/saml_identity_provider", + &demo_silo_saml_idp_create, + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap(); + + let mut non_hardcoded_endpoints = vec![ + /* Project images */ + VerifyEndpoint { + url: &*DEMO_PROJECT_URL_IMAGES, + visibility: Visibility::Protected, + allowed_methods: vec![ + AllowedMethod::GetUnimplemented, + AllowedMethod::Post( + serde_json::to_value(&demo_image_create).unwrap(), + ), + ], + }, + VerifyEndpoint { + url: &*DEMO_PROJECT_IMAGE_URL, + visibility: Visibility::Protected, + allowed_methods: vec![ + AllowedMethod::GetUnimplemented, + AllowedMethod::Delete, + ], + }, + /* Global images */ + VerifyEndpoint { + url: "/images", + visibility: Visibility::Public, + allowed_methods: vec![ + AllowedMethod::Get, + AllowedMethod::Post( + serde_json::to_value(&demo_image_create).unwrap(), + ), + ], + }, + VerifyEndpoint { + url: &*DEMO_GLOBAL_IMAGE_URL, + visibility: Visibility::Protected, + allowed_methods: vec![AllowedMethod::Get, AllowedMethod::Delete], + }, + /* Silo identity providers */ + VerifyEndpoint { + url: "/silos/default-silo/saml_identity_provider", + visibility: Visibility::Public, // Users can see their own silo! This includes USER_TEST_UNPRIVILEGED + allowed_methods: vec![AllowedMethod::Post( + serde_json::to_value(&demo_silo_saml_idp_create).unwrap(), + )], + }, + VerifyEndpoint { + url: + "/silos/default-silo/saml_identity_provider/demo-saml-provider", + visibility: Visibility::Protected, + allowed_methods: vec![AllowedMethod::Get], + }, + ]; + + let mut endpoints: Vec = Vec::with_capacity( + VERIFY_ENDPOINTS.len() + non_hardcoded_endpoints.len(), + ); + endpoints.extend(VERIFY_ENDPOINTS.clone()); + endpoints.append(&mut non_hardcoded_endpoints); + + // Verify the endpoints. info!(log, "verifying endpoints"); print!("{}", VERIFY_HEADER); - for endpoint in &*VERIFY_ENDPOINTS { + for endpoint in &endpoints { verify_endpoint(&log, client, endpoint).await; } + + // Validate unauthorized coverage + unauthorized_coverage(&endpoints); } const VERIFY_HEADER: &str = r#" @@ -188,11 +315,6 @@ lazy_static! { url: &*DEMO_PROJECT_URL_INSTANCES, body: serde_json::to_value(&*DEMO_INSTANCE_CREATE).unwrap(), }, - // Create a GlobalImage - SetupReq { - url: "/images", - body: serde_json::to_value(&*DEMO_IMAGE_CREATE).unwrap(), - }, ]; } @@ -534,3 +656,129 @@ fn record_operation(whichtest: WhichTest<'_>) { print!("{}", c); } } + +fn unauthorized_coverage(endpoints: &Vec) { + // Load the OpenAPI schema for Nexus's public API. + let schema_path = "../openapi/nexus.json"; + let schema_contents = std::fs::read_to_string(&schema_path) + .expect("failed to read Nexus OpenAPI spec"); + let spec: OpenAPI = serde_json::from_str(&schema_contents) + .expect("Nexus OpenAPI spec was not valid OpenAPI"); + + // Take each operation that we find in the OpenAPI spec, make a regular + // expression that we can use to match against it (more on this below), and + // throw them into a BTreeMap. + let mut spec_operations: BTreeMap = spec + .operations() + .map(|(path, method, op)| { + // We're going to take URLs from our test cases and match them + // against operations in the API spec. The URLs from the API spec + // contain variables (e.g., "/instances/{instance_name}"). Our test + // cases have those variables filled in already (e.g., + // "/instances/my-instance"). + // + // To match a URL from the test case against one from the OpenAPI + // spec, we're going to: + // + // - use a regular expression to replace `{varname}` in the API + // spec's URL with `[^/]+` (one or more non-slash characters) + // + // - use that string as the basis for a second regex that we'll + // store with the Operation. We'll use this second regex to match + // URLs to their operation. + // + // This is slow (lookups will take time linear in the total number + // of API endpoints) and a little cheesy, but it's expedient and + // robust enough for our purposes. + // + // This will fail badly if it turns out that the URL contains any + // characters that would be interpreted specially by the regular + // expression engine. So let's check up front that those aren't + // present. + assert!( + path.chars().all(|c| c.is_ascii_alphanumeric() + || c == '_' + || c == '-' + || c == '{' + || c == '}' + || c == '/'), + "unexpected character in URL: {:?}", + path + ); + let re = regex::Regex::new("/\\{[^}]+\\}").unwrap(); + let regex_path = re.replace_all(path, "/[^/]+"); + let regex = regex::Regex::new(&format!("^{}$", regex_path)) + .expect("modified URL string was not a valid regex"); + let label = op + .operation_id + .clone() + .unwrap_or(String::from("unknown operation-id")); + (Operation { method, path, label }, regex) + }) + .collect(); + + // Go through each of the authz test cases and match each one against an + // OpenAPI operation. + let mut unexpected_endpoints = String::from( + "API endpoints tested by unauthorized.rs but not found \ + in the OpenAPI spec:\n", + ); + for v in endpoints { + for m in &v.allowed_methods { + let method_string = m.http_method().to_string().to_uppercase(); + let found = spec_operations.iter().find(|(op, regex)| { + op.method.to_uppercase() == method_string + && regex.is_match(v.url) + }); + if let Some((op, _)) = found { + println!( + "covered: {:40} ({:6?} {:?}) (by {:?})", + op.label, op.method, op.path, v.url + ); + let op = op.clone(); + spec_operations.remove(&op); + } else { + unexpected_endpoints + .push_str(&format!("{:6} {:?}", method_string, v.url)); + } + } + } + + println!("-----"); + + // If you're here because this assertion failed, we found an endpoint tested + // by "unauthorized.rs" that's not in the OpenAPI spec. This could happen + // if you're adding a test for an endpoint that's marked "unpublished". In + // that case, you might just allow expectorate to add this endpoint to the + // allowlist here. + assert_contents( + "tests/output/unexpected-authz-endpoints.txt", + &unexpected_endpoints, + ); + + // Check for uncovered endpoints (endpoints that are in the OpenAPI spec but + // not tested by the authz tests). + let mut uncovered_endpoints = + "API endpoints with no coverage in authz tests:\n".to_string(); + for op in spec_operations.keys() { + uncovered_endpoints.push_str(&format!( + "{:40} ({:6} {:?})\n", + op.label, op.method, op.path + )); + } + + // If you're here because this assertion failed, check that if you've added + // any API operations to Nexus, you've also added a corresponding test in + // "unauthorized.rs" so that it will automatically be checked for its + // behavior for unauthenticated and unauthorized users. DO NOT SKIP THIS. + // Even if you're just adding a stub, see [`Nexus::unimplemented_todo()`]. + // If you _added_ a test that covered an endpoint from the allowlist -- + // hooray! Just delete the corresponding line from this file. (Why is this + // not `expectorage::assert_contents`? Because we only expect this file to + // ever shrink, which is easy enough to fix by hand, and we don't want to + // make it easy to accidentally add things to the allowlist.) + let expected_uncovered_endpoints = + std::fs::read_to_string("tests/output/uncovered-authz-endpoints.txt") + .expect("failed to load file of allowed uncovered endpoints"); + assert_eq!(expected_uncovered_endpoints, uncovered_endpoints); +} diff --git a/nexus/tests/integration_tests/unauthorized_coverage.rs b/nexus/tests/integration_tests/unauthorized_coverage.rs deleted file mode 100644 index e92a5af98be..00000000000 --- a/nexus/tests/integration_tests/unauthorized_coverage.rs +++ /dev/null @@ -1,147 +0,0 @@ -// 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/. - -use super::endpoints::VERIFY_ENDPOINTS; -use expectorate::assert_contents; -use openapiv3::OpenAPI; -use std::collections::BTreeMap; - -/// Checks for uncovered public API endpoints -/// -/// This test compares the endpoints covered by the "unauthorized" test with -/// what's in the OpenAPI spec for the public API to make sure all endpoints are -/// accounted-for. -#[test] -fn test_unauthorized_coverage() { - // Load the OpenAPI schema for Nexus's public API. - let schema_path = "../openapi/nexus.json"; - let schema_contents = std::fs::read_to_string(&schema_path) - .expect("failed to read Nexus OpenAPI spec"); - let spec: OpenAPI = serde_json::from_str(&schema_contents) - .expect("Nexus OpenAPI spec was not valid OpenAPI"); - - // Take each operation that we find in the OpenAPI spec, make a regular - // expression that we can use to match against it (more on this below), and - // throw them into a BTreeMap. - let mut spec_operations: BTreeMap = spec - .operations() - .map(|(path, method, op)| { - // We're going to take URLs from our test cases and match them - // against operations in the API spec. The URLs from the API spec - // contain variables (e.g., "/instances/{instance_name}"). Our test - // cases have those variables filled in already (e.g., - // "/instances/my-instance"). - // - // To match a URL from the test case against one from the OpenAPI - // spec, we're going to: - // - // - use a regular expression to replace `{varname}` in the API - // spec's URL with `[^/]+` (one or more non-slash characters) - // - // - use that string as the basis for a second regex that we'll - // store with the Operation. We'll use this second regex to match - // URLs to their operation. - // - // This is slow (lookups will take time linear in the total number - // of API endpoints) and a little cheesy, but it's expedient and - // robust enough for our purposes. - // - // This will fail badly if it turns out that the URL contains any - // characters that would be interpreted specially by the regular - // expression engine. So let's check up front that those aren't - // present. - assert!( - path.chars().all(|c| c.is_ascii_alphanumeric() - || c == '_' - || c == '-' - || c == '{' - || c == '}' - || c == '/'), - "unexpected character in URL: {:?}", - path - ); - let re = regex::Regex::new("/\\{[^}]+\\}").unwrap(); - let regex_path = re.replace_all(path, "/[^/]+"); - let regex = regex::Regex::new(&format!("^{}$", regex_path)) - .expect("modified URL string was not a valid regex"); - let label = op - .operation_id - .clone() - .unwrap_or(String::from("unknown operation-id")); - (Operation { method, path, label }, regex) - }) - .collect(); - - // Go through each of the authz test cases and match each one against an - // OpenAPI operation. - let mut unexpected_endpoints = String::from( - "API endpoints tested by unauthorized.rs but not found \ - in the OpenAPI spec:\n", - ); - for v in &*VERIFY_ENDPOINTS { - for m in &v.allowed_methods { - let method_string = m.http_method().to_string().to_uppercase(); - let found = spec_operations.iter().find(|(op, regex)| { - op.method.to_uppercase() == method_string - && regex.is_match(v.url) - }); - if let Some((op, _)) = found { - println!( - "covered: {:40} ({:6?} {:?}) (by {:?})", - op.label, op.method, op.path, v.url - ); - let op = op.clone(); - spec_operations.remove(&op); - } else { - unexpected_endpoints - .push_str(&format!("{:6} {:?}", method_string, v.url)); - } - } - } - - println!("-----"); - - // If you're here because this assertion failed, we found an endpoint tested - // by "unauthorized.rs" that's not in the OpenAPI spec. This could happen - // if you're adding a test for an endpoint that's marked "unpublished". In - // that case, you might just allow expectorate to add this endpoint to the - // allowlist here. - assert_contents( - "tests/output/unexpected-authz-endpoints.txt", - &unexpected_endpoints, - ); - - // Check for uncovered endpoints (endpoints that are in the OpenAPI spec but - // not tested by the authz tests). - let mut uncovered_endpoints = - "API endpoints with no coverage in authz tests:\n".to_string(); - for op in spec_operations.keys() { - uncovered_endpoints.push_str(&format!( - "{:40} ({:6} {:?})\n", - op.label, op.method, op.path - )); - } - - // If you're here because this assertion failed, check that if you've added - // any API operations to Nexus, you've also added a corresponding test in - // "unauthorized.rs" so that it will automatically be checked for its - // behavior for unauthenticated and unauthorized users. DO NOT SKIP THIS. - // Even if you're just adding a stub, see [`Nexus::unimplemented_todo()`]. - // If you _added_ a test that covered an endpoint from the allowlist -- - // hooray! Just delete the corresponding line from this file. (Why is this - // not `expectorage::assert_contents`? Because we only expect this file to - // ever shrink, which is easy enough to fix by hand, and we don't want to - // make it easy to accidentally add things to the allowlist.) - let expected_uncovered_endpoints = - std::fs::read_to_string("tests/output/uncovered-authz-endpoints.txt") - .expect("failed to load file of allowed uncovered endpoints"); - assert_eq!(expected_uncovered_endpoints, uncovered_endpoints); -} - -#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] -struct Operation<'a> { - method: &'a str, - path: &'a str, - label: String, -} diff --git a/nexus/tests/output/nexus_tags.txt b/nexus/tests/output/nexus_tags.txt index f6fc57bdfbe..cbd254ecc54 100644 --- a/nexus/tests/output/nexus_tags.txt +++ b/nexus/tests/output/nexus_tags.txt @@ -48,6 +48,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/{silo_name}/{provider_name} +consume_credentials_and_authn_user /login/{silo_name}/{provider_name} + API operations found with tag "metrics" OPERATION ID URL PATH timeseries_schema_get /timeseries/schema @@ -101,6 +106,8 @@ sagas_get_saga /sagas/{saga_id} API operations found with tag "silos" OPERATION ID URL PATH +silo_saml_idp_create /silos/{silo_name}/saml_identity_provider +silo_saml_idp_fetch /silos/{silo_name}/saml_identity_provider/{provider_name} 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 89a0dcc71ed..8df845fd0b3 100644 --- a/nexus/tests/output/uncovered-authz-endpoints.txt +++ b/nexus/tests/output/uncovered-authz-endpoints.txt @@ -1,8 +1,10 @@ API endpoints with no coverage in authz tests: sshkeys_delete_key (delete "/session/me/sshkeys/{ssh_key_name}") +ask_user_to_login_to_provider (get "/login/{silo_name}/{provider_name}") session_me (get "/session/me") sshkeys_get (get "/session/me/sshkeys") sshkeys_get_key (get "/session/me/sshkeys/{ssh_key_name}") spoof_login (post "/login") +consume_credentials_and_authn_user (post "/login/{silo_name}/{provider_name}") logout (post "/logout") sshkeys_post (post "/session/me/sshkeys") diff --git a/openapi/nexus.json b/openapi/nexus.json index 409ebadc1a7..e8b262587d4 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -404,6 +404,84 @@ } } }, + "/login/{silo_name}/{provider_name}": { + "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_name", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + }, + { + "in": "path", + "name": "silo_name", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "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_name", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + }, + { + "in": "path", + "name": "silo_name", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + } + ], + "responses": { + "default": { + "description": "", + "content": { + "*/*": { + "schema": {} + } + } + } + } + } + }, "/logout": { "post": { "tags": [ @@ -4725,6 +4803,104 @@ } } }, + "/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" + } + } + } + }, + "/silos/{silo_name}/saml_identity_provider/{provider_name}": { + "get": { + "tags": [ + "silos" + ], + "summary": "GET a silo's SAML identity provider", + "operationId": "silo_saml_idp_fetch", + "parameters": [ + { + "in": "path", + "name": "provider_name", + "description": "The SAML identity provider's name", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + }, + { + "in": "path", + "name": "silo_name", + "description": "The silo's unique name.", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SiloSamlIdentityProvider" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/timeseries/schema": { "get": { "tags": [ @@ -7059,6 +7235,148 @@ "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, + "description": "optional request signing private key (base64 encoded der file)", + "type": "string" + }, + "public_cert": { + "nullable": true, + "description": "optional request signing public certificate (base64 encoded der file)", + "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", @@ -8374,6 +8692,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 81dd77fb6fb55e3fea56215b9cdacb2565d8e999 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Fri, 29 Apr 2022 11:22:03 -0400 Subject: [PATCH 02/54] use impl_enum_type for SiloIdentityProviderType --- common/src/sql/dbinit.sql | 8 ++++- nexus/src/db/datastore.rs | 2 +- nexus/src/db/model.rs | 75 ++++++++++----------------------------- nexus/src/db/schema.rs | 2 +- nexus/src/nexus.rs | 6 ++-- 5 files changed, 31 insertions(+), 62 deletions(-) diff --git a/common/src/sql/dbinit.sql b/common/src/sql/dbinit.sql index 84363611197..c1d00cb1aa6 100644 --- a/common/src/sql/dbinit.sql +++ b/common/src/sql/dbinit.sql @@ -213,12 +213,18 @@ CREATE TABLE omicron.public.silo_user ( time_deleted TIMESTAMPTZ ); +CREATE TYPE omicron.public.provider_type AS ENUM ( + 'local', + 'saml', + 'ldap' +); + /* * Silo identity provider list */ CREATE TABLE omicron.public.silo_identity_provider ( silo_id UUID NOT NULL, - provider_type TEXT NOT NULL, + provider_type omicron.public.provider_type NOT NULL, name TEXT NOT NULL, provider_id UUID NOT NULL, diff --git a/nexus/src/db/datastore.rs b/nexus/src/db/datastore.rs index 74e114bdb9b..a444d1aa939 100644 --- a/nexus/src/db/datastore.rs +++ b/nexus/src/db/datastore.rs @@ -2745,7 +2745,7 @@ impl DataStore { silo_id: provider.silo_id, name: provider.name().clone(), provider_type: - db::model::SiloIdentityProviderTypeEnum::Saml, + db::model::SiloIdentityProviderType::Saml, provider_id: provider.id(), }) .execute(conn)?; diff --git a/nexus/src/db/model.rs b/nexus/src/db/model.rs index fb893f02ea5..7aacdb3ca8b 100644 --- a/nexus/src/db/model.rs +++ b/nexus/src/db/model.rs @@ -19,7 +19,6 @@ 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}; @@ -41,7 +40,6 @@ use std::net::Ipv4Addr; use std::net::Ipv6Addr; use std::net::SocketAddr; use std::net::SocketAddrV6; -use std::str::FromStr; use std::string::ToString; use uuid::Uuid; @@ -1007,71 +1005,36 @@ impl SiloUser { } } -#[derive(Debug, Copy, Clone, AsExpression, FromSqlRow)] -#[sql_type = "sql_types::Text"] -pub enum SiloIdentityProviderTypeEnum { - Local, - Saml, - Ldap, -} +impl_enum_type!( + #[derive(SqlType, Debug, QueryId)] + #[postgres(type_name = "provider_type", type_schema = "public")] + pub struct SiloIdentityProviderTypeEnum; -impl ToString for SiloIdentityProviderTypeEnum { + #[derive(Copy, Clone, Debug, AsExpression, FromSqlRow, Serialize, Deserialize, PartialEq)] + #[sql_type = "SiloIdentityProviderTypeEnum"] + pub enum SiloIdentityProviderType; + + // Enum values + Local => b"local" + Saml => b"saml" + Ldap => b"ldap" +); + +impl ToString for SiloIdentityProviderType { fn to_string(&self) -> String { match self { - SiloIdentityProviderTypeEnum::Local => "local".to_string(), - SiloIdentityProviderTypeEnum::Saml => "saml".to_string(), - SiloIdentityProviderTypeEnum::Ldap => "ldap".to_string(), + SiloIdentityProviderType::Local => "local".to_string(), + SiloIdentityProviderType::Saml => "saml".to_string(), + SiloIdentityProviderType::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_type: SiloIdentityProviderType, pub name: Name, pub provider_id: Uuid, } diff --git a/nexus/src/db/schema.rs b/nexus/src/db/schema.rs index 09cc1472f6b..e576c6b8d4d 100644 --- a/nexus/src/db/schema.rs +++ b/nexus/src/db/schema.rs @@ -161,7 +161,7 @@ table! { table! { silo_identity_provider (silo_id, provider_id) { silo_id -> Uuid, - provider_type -> Text, + provider_type -> crate::db::model::SiloIdentityProviderTypeEnum, name -> Text, provider_id -> Uuid, } diff --git a/nexus/src/nexus.rs b/nexus/src/nexus.rs index 83e693e03e9..460c537b873 100644 --- a/nexus/src/nexus.rs +++ b/nexus/src/nexus.rs @@ -3910,19 +3910,19 @@ impl Nexus { .await?; match silo_identity_provider.provider_type { - db::model::SiloIdentityProviderTypeEnum::Local => { + db::model::SiloIdentityProviderType::Local => { return Err(Error::unavail( &"local silo authn provider not yet supported", )); } - db::model::SiloIdentityProviderTypeEnum::Ldap => { + db::model::SiloIdentityProviderType::Ldap => { return Err(Error::unavail( &"ldap silo authn provider not yet supported", )); } - db::model::SiloIdentityProviderTypeEnum::Saml => { + db::model::SiloIdentityProviderType::Saml => { let (.., silo_saml_identity_provider) = LookupPath::new(opctx, &self.datastore()) .silo_name(silo_name) From 13b091d4549d81cf505bf2f554a2d57c8272a673 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Fri, 29 Apr 2022 11:29:37 -0400 Subject: [PATCH 03/54] install libxmlsec1-openssl in Dockerfile --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index 4afcf1f7a99..e09e4c766a3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,6 +29,7 @@ RUN apt-get update && apt-get install -y \ libpq5 \ libssl1.1 \ libsqlite3-0 \ + libxmlsec1-openssl \ --no-install-recommends \ && rm -rf /var/lib/apt/lists/* From 1e879180398d5c0cec11dfd05beccda51100fae3 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Fri, 29 Apr 2022 12:23:17 -0400 Subject: [PATCH 04/54] impl ToString in the impl_enum_type macro --- nexus/src/db/model.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/nexus/src/db/model.rs b/nexus/src/db/model.rs index 7aacdb3ca8b..01bdc747adf 100644 --- a/nexus/src/db/model.rs +++ b/nexus/src/db/model.rs @@ -175,6 +175,16 @@ macro_rules! impl_enum_type { } } } + + impl ToString for $model_type { + fn to_string(&self) -> String { + match self { + $( + $model_type::$enum_item => String::from_utf8($sql_value.to_vec()).unwrap(), + )* + } + } + } } } @@ -1020,16 +1030,6 @@ impl_enum_type!( Ldap => b"ldap" ); -impl ToString for SiloIdentityProviderType { - fn to_string(&self) -> String { - match self { - SiloIdentityProviderType::Local => "local".to_string(), - SiloIdentityProviderType::Saml => "saml".to_string(), - SiloIdentityProviderType::Ldap => "ldap".to_string(), - } - } -} - #[derive(Queryable, Insertable, Clone, Debug, Selectable)] #[table_name = "silo_identity_provider"] pub struct SiloIdentityProvider { From 6be4f9fba14d6f09985f2f71e9eb96a598987a66 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Fri, 29 Apr 2022 12:24:30 -0400 Subject: [PATCH 05/54] xmlsec1 too --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index e09e4c766a3..949974c2977 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,7 @@ RUN apt-get update && apt-get install -y \ libpq5 \ libssl1.1 \ libsqlite3-0 \ - libxmlsec1-openssl \ + xmlsec1 libxmlsec1-openssl \ --no-install-recommends \ && rm -rf /var/lib/apt/lists/* From ed3349981e782d044b21db5e530511a4b7cf08cd Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Fri, 29 Apr 2022 14:00:08 -0400 Subject: [PATCH 06/54] try installing xmlsec1 in check-omicron-deployment --- .github/workflows/rust.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index ab301e3e129..20e252cbf39 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -31,6 +31,12 @@ jobs: if: ${{ github.ref != 'refs/heads/main' }} - name: Report cargo version run: cargo --version + - name: Install pre-reqs + run: apt install -y xmlsec1 libxmlsec1-openssl + if: ${{ runner.os == "ubuntu-18.04" }} + - name: Install pre-reqs + run: brew install libxmlsec1 + if: ${{ runner.os == "macos-11" }} - name: Check build of deployed Omicron packages run: cargo run --bin omicron-package -- check From f55fada9182813b997c9e7adc1c8c165465aa8fa Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Fri, 29 Apr 2022 14:35:16 -0400 Subject: [PATCH 07/54] put package installs into install_prerequisites.sh --- .github/workflows/rust.yml | 6 ------ tools/install_prerequisites.sh | 3 +++ 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 20e252cbf39..ab301e3e129 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -31,12 +31,6 @@ jobs: if: ${{ github.ref != 'refs/heads/main' }} - name: Report cargo version run: cargo --version - - name: Install pre-reqs - run: apt install -y xmlsec1 libxmlsec1-openssl - if: ${{ runner.os == "ubuntu-18.04" }} - - name: Install pre-reqs - run: brew install libxmlsec1 - if: ${{ runner.os == "macos-11" }} - name: Check build of deployed Omicron packages run: cargo run --bin omicron-package -- check diff --git a/tools/install_prerequisites.sh b/tools/install_prerequisites.sh index 939f87103b9..21afc057aa6 100755 --- a/tools/install_prerequisites.sh +++ b/tools/install_prerequisites.sh @@ -51,6 +51,8 @@ if [[ "${HOST_OS}" == "Linux" ]]; then packages=( 'libpq-dev' 'pkg-config' + 'xmlsec1' + 'libxmlsec1-openssl' ) confirm "Install (or update) [${packages[*]}]?" && sudo apt-get install ${packages[@]} elif [[ "${HOST_OS}" == "SunOS" ]]; then @@ -79,6 +81,7 @@ elif [[ "${HOST_OS}" == "Darwin" ]]; then packages=( 'postgresql' 'pkg-config' + 'libxmlsec1' ) confirm "Install (or update) [${packages[*]}]?" && brew install ${packages[@]} else From 75959330d6581442409cafceb29c9358042f8d5d Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Tue, 3 May 2022 12:45:19 -0400 Subject: [PATCH 08/54] no more full table scans --- common/src/sql/dbinit.sql | 9 +++++++++ nexus/tests/integration_tests/silos.rs | 2 -- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/common/src/sql/dbinit.sql b/common/src/sql/dbinit.sql index 9dc28b1232a..106d3c6ac7d 100644 --- a/common/src/sql/dbinit.sql +++ b/common/src/sql/dbinit.sql @@ -238,6 +238,11 @@ CREATE TABLE omicron.public.silo_identity_provider ( PRIMARY KEY (silo_id, provider_id) ); +CREATE INDEX ON omicron.public.silo_identity_provider ( + silo_id, + provider_id +); + /* * Silo SAML identity provider */ @@ -265,6 +270,10 @@ CREATE TABLE omicron.public.silo_saml_identity_provider ( private_key TEXT ); +CREATE INDEX ON omicron.public.silo_saml_identity_provider ( + silo_id +); + /* * Users' public SSH keys, per RFD 44 */ diff --git a/nexus/tests/integration_tests/silos.rs b/nexus/tests/integration_tests/silos.rs index 967e01bc164..ab229921664 100644 --- a/nexus/tests/integration_tests/silos.rs +++ b/nexus/tests/integration_tests/silos.rs @@ -2,8 +2,6 @@ // 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/. -use uuid::Uuid; - use nexus_test_utils::http_testing::{AuthnMode, NexusRequest, RequestBuilder}; use omicron_common::api::external::IdentityMetadataCreateParams; use omicron_common::api::external::SiloSamlIdentityProvider; From b578a991cd0967d75e9a9b7c285641f36589166e Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Wed, 4 May 2022 16:14:54 -0400 Subject: [PATCH 09/54] install library/libxmlsec1 on helios --- tools/install_prerequisites.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/install_prerequisites.sh b/tools/install_prerequisites.sh index 4ca3f6bb54f..ebfbbdbb0fe 100755 --- a/tools/install_prerequisites.sh +++ b/tools/install_prerequisites.sh @@ -85,6 +85,7 @@ elif [[ "${HOST_OS}" == "SunOS" ]]; then 'library/postgresql-13' 'pkg-config' 'brand/omicron1/tools' + 'library/libxmlsec1' ) # Install/update the set of packages. From 463b1b62c158c764b38cfc60b7abfcb073735de9 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Wed, 4 May 2022 16:33:37 -0400 Subject: [PATCH 10/54] update silo identity provider diesel code --- nexus/src/db/model.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nexus/src/db/model.rs b/nexus/src/db/model.rs index f1dc8bb3b9f..66f5c5fc20c 100644 --- a/nexus/src/db/model.rs +++ b/nexus/src/db/model.rs @@ -998,11 +998,11 @@ impl SiloUser { impl_enum_type!( #[derive(SqlType, Debug, QueryId)] - #[postgres(type_name = "provider_type", type_schema = "public")] + #[diesel(postgres_type(name = "provider_type"))] pub struct SiloIdentityProviderTypeEnum; #[derive(Copy, Clone, Debug, AsExpression, FromSqlRow, Serialize, Deserialize, PartialEq)] - #[sql_type = "SiloIdentityProviderTypeEnum"] + #[diesel(sql_type = SiloIdentityProviderTypeEnum)] pub enum SiloIdentityProviderType; // Enum values @@ -1012,7 +1012,7 @@ impl_enum_type!( ); #[derive(Queryable, Insertable, Clone, Debug, Selectable)] -#[table_name = "silo_identity_provider"] +#[diesel(table_name = silo_identity_provider)] pub struct SiloIdentityProvider { pub silo_id: Uuid, pub provider_type: SiloIdentityProviderType, @@ -1027,7 +1027,7 @@ impl SiloIdentityProvider { } #[derive(Queryable, Insertable, Clone, Debug, Selectable, Resource)] -#[table_name = "silo_saml_identity_provider"] +#[diesel(table_name = silo_saml_identity_provider)] pub struct SiloSamlIdentityProvider { #[diesel(embed)] pub identity: SiloSamlIdentityProviderIdentity, From 99bcf0891f02e0e0baf3fa59f4d4faab9426ec02 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Wed, 4 May 2022 16:40:22 -0400 Subject: [PATCH 11/54] ./tools/install_prerequisites.sh for clippy-lint --- .github/workflows/rust.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index d8af3b92e4d..ebb418ce966 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -45,6 +45,8 @@ jobs: run: cargo --version - name: Report Clippy version run: cargo clippy -- --version + - name: Install Pre-Requisites + run: ./tools/install_prerequisites.sh -y - name: Run Clippy Lints # # Clippy's style nits are useful, but not worth keeping in CI. This From a12939b8a920ca23ffacb0286dacc67a510ca70c Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Wed, 4 May 2022 16:42:42 -0400 Subject: [PATCH 12/54] update PATH --- .github/workflows/rust.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index ebb418ce966..da68f4a965e 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -45,6 +45,8 @@ jobs: run: cargo --version - name: Report Clippy version run: cargo clippy -- --version + - name: Update PATH + run: echo "$PWD/out/cockroachdb/bin:$PWD/out/clickhouse" >> "$GITHUB_PATH" - name: Install Pre-Requisites run: ./tools/install_prerequisites.sh -y - name: Run Clippy Lints From 3ec582706526023f62464260e6e78a0393795959 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Thu, 5 May 2022 09:57:39 -0400 Subject: [PATCH 13/54] install libxmlsec1-dev for pkg-config files --- tools/install_prerequisites.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/install_prerequisites.sh b/tools/install_prerequisites.sh index ebfbbdbb0fe..edfdd1f1007 100755 --- a/tools/install_prerequisites.sh +++ b/tools/install_prerequisites.sh @@ -75,9 +75,11 @@ if [[ "${HOST_OS}" == "Linux" ]]; then 'libpq-dev' 'pkg-config' 'xmlsec1' + 'libxmlsec1-dev' 'libxmlsec1-openssl' ) - confirm "Install (or update) [${packages[*]}]?" && sudo apt-get install ${packages[@]} + sudo apt update + confirm "Install (or update) [${packages[*]}]?" && sudo apt install ${packages[@]} elif [[ "${HOST_OS}" == "SunOS" ]]; then packages=( 'pkg:/package/pkg' From 115ffdd2e45c6dd7488fda5ec5a843a5eda9fad5 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Thu, 5 May 2022 10:08:40 -0400 Subject: [PATCH 14/54] fmt --- nexus/src/db/model.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nexus/src/db/model.rs b/nexus/src/db/model.rs index 7a3cf520b75..165baf8be3e 100644 --- a/nexus/src/db/model.rs +++ b/nexus/src/db/model.rs @@ -10,7 +10,8 @@ use crate::db::ipv6; use crate::db::schema::{ console_session, dataset, disk, global_image, image, instance, metric_producer, network_interface, organization, oximeter, project, rack, - region, role_assignment, role_builtin, router_route, silo, silo_identity_provider, silo_saml_identity_provider, silo_user, sled, + region, role_assignment, role_builtin, router_route, silo, + silo_identity_provider, silo_saml_identity_provider, silo_user, sled, snapshot, ssh_key, update_available_artifact, user_builtin, volume, vpc, vpc_firewall_rule, vpc_router, vpc_subnet, zpool, }; From 3f8727a30c9c27a105896a6ddd40a6e278639aa1 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Thu, 5 May 2022 11:33:05 -0400 Subject: [PATCH 15/54] more prereqs for actions --- .github/workflows/rust.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index da68f4a965e..970b86e92cd 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -31,6 +31,10 @@ jobs: if: ${{ github.ref != 'refs/heads/main' }} - name: Report cargo version run: cargo --version + - name: Update PATH + run: echo "$PWD/out/cockroachdb/bin:$PWD/out/clickhouse" >> "$GITHUB_PATH" + - name: Install Pre-Requisites + run: ./tools/install_prerequisites.sh -y - name: Check build of deployed Omicron packages run: cargo run --bin omicron-package -- check @@ -68,6 +72,10 @@ jobs: if: ${{ github.ref != 'refs/heads/main' }} - name: Report cargo version run: cargo --version + - name: Update PATH + run: echo "$PWD/out/cockroachdb/bin:$PWD/out/clickhouse" >> "$GITHUB_PATH" + - name: Install Pre-Requisites + run: ./tools/install_prerequisites.sh -y - name: Test build documentation run: cargo doc From 6fbfec74aaddcc14b9a254fbfc76b52fce0c8f6e Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Thu, 5 May 2022 11:33:17 -0400 Subject: [PATCH 16/54] apparently helios needs clang? --- tools/install_prerequisites.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/install_prerequisites.sh b/tools/install_prerequisites.sh index edfdd1f1007..c6e4d0cfedf 100755 --- a/tools/install_prerequisites.sh +++ b/tools/install_prerequisites.sh @@ -88,6 +88,7 @@ elif [[ "${HOST_OS}" == "SunOS" ]]; then 'pkg-config' 'brand/omicron1/tools' 'library/libxmlsec1' + 'pkg:/ooce/developer/clang-120' ) # Install/update the set of packages. From 531c39f74e3cc1f8d63333984e30be6666a33571 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Thu, 5 May 2022 15:01:12 -0400 Subject: [PATCH 17/54] bindgen uses libclang --- .github/buildomat/jobs/build-and-test.sh | 4 ++++ tools/install_prerequisites.sh | 1 + 2 files changed, 5 insertions(+) diff --git a/.github/buildomat/jobs/build-and-test.sh b/.github/buildomat/jobs/build-and-test.sh index 727914a3e2b..63c2bf413ea 100644 --- a/.github/buildomat/jobs/build-and-test.sh +++ b/.github/buildomat/jobs/build-and-test.sh @@ -49,6 +49,10 @@ banner build export RUSTFLAGS="-D warnings" export RUSTDOCFLAGS="-D warnings" export TMPDIR=$TEST_TMPDIR + +# "bindgen leverages libclang to preprocess, parse, and type check C and C++ header files." +export LIBCLANG_PATH=/opt/ooce/clang-12.0/lib/ + ptime -m cargo +'nightly-2022-04-27' build --locked --all-targets --verbose # diff --git a/tools/install_prerequisites.sh b/tools/install_prerequisites.sh index c6e4d0cfedf..c7cfd21f29c 100755 --- a/tools/install_prerequisites.sh +++ b/tools/install_prerequisites.sh @@ -88,6 +88,7 @@ elif [[ "${HOST_OS}" == "SunOS" ]]; then 'pkg-config' 'brand/omicron1/tools' 'library/libxmlsec1' + # "bindgen leverages libclang to preprocess, parse, and type check C and C++ header files." 'pkg:/ooce/developer/clang-120' ) From f92964fae644664ba3d74c8679aa11d5a752c3b4 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Thu, 5 May 2022 16:42:56 -0400 Subject: [PATCH 18/54] add libxmlsec1-dev, put on separate lines --- Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 949974c2977..f77c98e6630 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,9 @@ RUN apt-get update && apt-get install -y \ libpq5 \ libssl1.1 \ libsqlite3-0 \ - xmlsec1 libxmlsec1-openssl \ + xmlsec1 \ + libxmlsec1-dev \ + libxmlsec1-openssl \ --no-install-recommends \ && rm -rf /var/lib/apt/lists/* From a93ed10b36c547a789385f000d4367a6d3e61a08 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Thu, 5 May 2022 16:52:27 -0400 Subject: [PATCH 19/54] expand SAML related acronyms --- common/src/api/external/mod.rs | 11 ++++++----- openapi/nexus.json | 10 +++++----- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/common/src/api/external/mod.rs b/common/src/api/external/mod.rs index 4a43971e33a..d59f19b63ad 100644 --- a/common/src/api/external/mod.rs +++ b/common/src/api/external/mod.rs @@ -1745,7 +1745,8 @@ impl std::fmt::Display for Digest { } } -/// A SAML configuration specifies both IDP and SP details +/// A SAML configuration specifies both identity provider and service provider +/// details #[derive(Clone, Debug, Serialize, JsonSchema, Deserialize)] pub struct SiloSamlIdentityProvider { #[serde(flatten)] @@ -1754,19 +1755,19 @@ pub struct SiloSamlIdentityProvider { /// url where identity provider metadata descriptor is pub idp_metadata_url: String, - /// idp's entity id + /// identity provider's entity id pub idp_entity_id: String, - /// sp's client id + /// service provider'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 + /// service provider endpoint where the identity provider should send log out requests pub slo_url: String, - /// customer's technical contact for saml configuration + /// customer's technical contact for SAML configuration pub technical_contact_email: String, /// optional request signing key pair (base64 encoded der files) diff --git a/openapi/nexus.json b/openapi/nexus.json index 8b1b80bd9de..b41af4696d6 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -7378,7 +7378,7 @@ ] }, "SiloSamlIdentityProvider": { - "description": "A SAML configuration specifies both IDP and SP details", + "description": "A SAML configuration specifies both identity provider and service provider details", "type": "object", "properties": { "acs_url": { @@ -7395,7 +7395,7 @@ "format": "uuid" }, "idp_entity_id": { - "description": "idp's entity id", + "description": "identity provider's entity id", "type": "string" }, "idp_metadata_url": { @@ -7421,15 +7421,15 @@ "type": "string" }, "slo_url": { - "description": "service provider endpoint where the idp should send log out requests", + "description": "service provider endpoint where the identity provider should send log out requests", "type": "string" }, "sp_client_id": { - "description": "sp's client id", + "description": "service provider's client id", "type": "string" }, "technical_contact_email": { - "description": "customer's technical contact for saml configuration", + "description": "customer's technical contact for SAML configuration", "type": "string" }, "time_created": { From eb0ed4b2eb123315f3e66d43a8e6b9599c10eb40 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Thu, 5 May 2022 17:06:28 -0400 Subject: [PATCH 20/54] unpin samael crate --- nexus/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nexus/Cargo.toml b/nexus/Cargo.toml index 4dedf05f8d1..7212cc342cb 100644 --- a/nexus/Cargo.toml +++ b/nexus/Cargo.toml @@ -44,7 +44,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" } +samael = { git = "https://github.com/njaremko/samael", features = ["xmlsec"], branch = "master" } serde_json = "1.0" serde_urlencoded = "0.7.1" serde_with = "1.12.1" From 4cd4c55184c6d39d6f7a3e6bac0eeff2a1c89651 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Thu, 5 May 2022 17:07:12 -0400 Subject: [PATCH 21/54] cargo.lock update --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 0856fe86063..9e2f6c76f4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3909,7 +3909,7 @@ checksum = "73b4b750c782965c211b42f022f59af1fbceabdd026623714f104152f1ec149f" [[package]] name = "samael" version = "0.0.8" -source = "git+https://github.com/njaremko/samael?rev=441a244120eeb5995b2e47a52dc1beafa890d2b2#441a244120eeb5995b2e47a52dc1beafa890d2b2" +source = "git+https://github.com/njaremko/samael?branch=master#9fd1fcb99ab2e4c7f5037550680f311b48417d77" dependencies = [ "base64", "bindgen", From 2ef7da6ea9f18e3d2cfec6ab25aabcdceb111576 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Fri, 6 May 2022 10:22:47 -0400 Subject: [PATCH 22/54] remove redundant index on silo_identity_provider, make delete soft add back WHERE time_deleted IS NULL --- common/src/sql/dbinit.sql | 8 ++------ nexus/src/db/datastore.rs | 4 +++- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/common/src/sql/dbinit.sql b/common/src/sql/dbinit.sql index a246302a114..8f55bc61aa2 100644 --- a/common/src/sql/dbinit.sql +++ b/common/src/sql/dbinit.sql @@ -238,11 +238,6 @@ CREATE TABLE omicron.public.silo_identity_provider ( PRIMARY KEY (silo_id, provider_id) ); -CREATE INDEX ON omicron.public.silo_identity_provider ( - silo_id, - provider_id -); - /* * Silo SAML identity provider */ @@ -272,7 +267,8 @@ CREATE TABLE omicron.public.silo_saml_identity_provider ( CREATE INDEX ON omicron.public.silo_saml_identity_provider ( silo_id -); +) WHERE + time_deleted IS NULL; /* * Users' public SSH keys, per RFD 44 diff --git a/nexus/src/db/datastore.rs b/nexus/src/db/datastore.rs index 3cbab1e8d2c..634aed17a2e 100644 --- a/nexus/src/db/datastore.rs +++ b/nexus/src/db/datastore.rs @@ -2718,8 +2718,10 @@ impl DataStore { use db::schema::silo_saml_identity_provider::dsl as saml_idp_dsl; let updated_rows = - diesel::delete(saml_idp_dsl::silo_saml_identity_provider) + diesel::update(saml_idp_dsl::silo_saml_identity_provider) .filter(saml_idp_dsl::silo_id.eq(id)) + .filter(saml_idp_dsl::time_deleted.is_null()) + .set(saml_idp_dsl::time_deleted.eq(Utc::now())) .execute_async(self.pool()) .await .map_err(|e| { From e77353c9cef510d904a73d79cd3d6d6225e02a5a Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Fri, 6 May 2022 10:28:10 -0400 Subject: [PATCH 23/54] reorder external-authenticator permission grants together --- nexus/src/authz/omicron.polar | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/nexus/src/authz/omicron.polar b/nexus/src/authz/omicron.polar index 04ce03c2b00..bfc89d6b53f 100644 --- a/nexus/src/authz/omicron.polar +++ b/nexus/src/authz/omicron.polar @@ -155,9 +155,6 @@ has_relation(fleet: Fleet, "parent_fleet", silo: Silo) has_role(actor: AuthenticatedActor, "viewer", silo: Silo) if actor.silo = silo; -has_permission(actor: AuthenticatedActor, "read", silo: Silo) - if has_role(actor, "external-authenticator", silo.fleet); - resource Organization { permissions = [ "list_children", @@ -257,6 +254,17 @@ has_permission(actor: AuthenticatedActor, "read", session: ConsoleSession) has_permission(actor: AuthenticatedActor, "modify", session: ConsoleSession) if has_role(actor, "external-authenticator", session.fleet); +has_permission(actor: AuthenticatedActor, "read", silo: Silo) + if has_role(actor, "external-authenticator", silo.fleet); +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); +has_permission(actor: AuthenticatedActor, "read", silo_saml_identity_provider: SiloSamlIdentityProvider) + 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); + resource SiloUser { permissions = [ "list_children", @@ -303,11 +311,6 @@ resource SiloIdentityProvider { 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", @@ -327,7 +330,3 @@ resource SiloSamlIdentityProvider { 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) - 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); From b3049944a5508f645e6766f8a00a1570858bf407 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Fri, 6 May 2022 10:34:49 -0400 Subject: [PATCH 24/54] correct primary key columns --- nexus/src/db/lookup.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/nexus/src/db/lookup.rs b/nexus/src/db/lookup.rs index def3f4becb1..ac4b1243448 100644 --- a/nexus/src/db/lookup.rs +++ b/nexus/src/db/lookup.rs @@ -419,6 +419,7 @@ lookup_resource! { lookup_by_name = true, soft_deletes = false, primary_key_columns = [ + { column_name = "silo_id", rust_type = Uuid }, { column_name = "provider_id", rust_type = Uuid } ] } From 0afecd690a456643b822ed90878627170bd2c386 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Fri, 6 May 2022 11:33:42 -0400 Subject: [PATCH 25/54] add test_impl_enum_type_to_string --- nexus/src/db/model/mod.rs | 57 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/nexus/src/db/model/mod.rs b/nexus/src/db/model/mod.rs index e84a43e4fc8..bf51cd52262 100644 --- a/nexus/src/db/model/mod.rs +++ b/nexus/src/db/model/mod.rs @@ -351,4 +351,61 @@ mod tests { assert!(!subnet.check_requestable_addr("fd00::1".parse().unwrap())); assert!(subnet.check_requestable_addr("fd00::1:1".parse().unwrap())); } + + #[test] + fn test_impl_enum_type_to_string() { + // Assert here that the to_string does not panic on the unwrap + use std::io::Write; + + impl_enum_type!( + #[derive(SqlType, Debug, QueryId)] + #[diesel(postgres_type(name = "test_type"))] + pub struct TestTypeEnum; + + #[derive(Copy, Clone, Debug, AsExpression, FromSqlRow, PartialEq)] + #[diesel(sql_type = TestTypeEnum)] + pub enum TestType; + + // All possible macro_rules literals are listed below. Note that + // diesel::serialize::Output's write_all means that a &[u8] is + // expected for $sql_value, and impl_enum_type's ToString calls + // .to_vec() on the literal so that also must be implemented. + + // to_vec not found in `char` + // CharLiteral => 'a' + + // does not compile, says "consider adding a leading `b`" + //StringLiteral => "test post" + + // does not compile + //RawStringLiteral => r"test post" + + // does not compile: "expected `&[u8]`, found `u8`" + //ByteLiteral => b'a' + //HexByteLiteral => b'\x12' + + // ok + ByteStringLiteral => b"test post" + RawByteStringLiteral => br##"please " ignore"## + + // raw byte string literals can be any ASCII (i.e. 0x00 to 0x7F) + HexSixRawByteStringLiteral => br"\x06" + + // none of these compile + //IntegerLiteral1 => 123i32 + //IntegerLiteral2 => 123u32 + //IntegerLiteral3 => 123_u32 + //IntegerLiteral4 => 0xff_u8 + //IntegerLiteral5 => 0o70_i16 + //IntegerLiteral6 => 0b1111_1111_1001_0000_i64 + //IntegerLiteral7 => 0b________1_i32 + //IntegerLiteral8 => 0usize + //FloatLiteral => 123.0f64 + //BooleanLiteral => false + ); + + assert_eq!(TestType::ByteStringLiteral.to_string(), "test post".to_string()); + assert_eq!(TestType::RawByteStringLiteral.to_string(), "please \" ignore".to_string()); + assert_eq!(TestType::HexSixRawByteStringLiteral.to_string(), "\\x06".to_string()); + } } From 0bf084771bb136297253d961c9dbb4801d2aa6e8 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Fri, 6 May 2022 11:34:35 -0400 Subject: [PATCH 26/54] revert to apt-get --- tools/install_prerequisites.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/install_prerequisites.sh b/tools/install_prerequisites.sh index c7cfd21f29c..4ab8dce76ea 100755 --- a/tools/install_prerequisites.sh +++ b/tools/install_prerequisites.sh @@ -78,8 +78,8 @@ if [[ "${HOST_OS}" == "Linux" ]]; then 'libxmlsec1-dev' 'libxmlsec1-openssl' ) - sudo apt update - confirm "Install (or update) [${packages[*]}]?" && sudo apt install ${packages[@]} + sudo apt-get update + confirm "Install (or update) [${packages[*]}]?" && sudo apt-get install ${packages[@]} elif [[ "${HOST_OS}" == "SunOS" ]]; then packages=( 'pkg:/package/pkg' From 2e35e069876272a6281d0845dbdc15e808797a67 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Fri, 6 May 2022 11:45:14 -0400 Subject: [PATCH 27/54] remove local, ldap provider types use provider enum in external_api view --- nexus/src/db/model/silo_identity_provider.rs | 2 -- nexus/src/external_api/views.rs | 12 ++++++++++-- nexus/src/nexus.rs | 12 ------------ 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/nexus/src/db/model/silo_identity_provider.rs b/nexus/src/db/model/silo_identity_provider.rs index 541f8c2cd51..feeebdd6cef 100644 --- a/nexus/src/db/model/silo_identity_provider.rs +++ b/nexus/src/db/model/silo_identity_provider.rs @@ -22,9 +22,7 @@ impl_enum_type!( pub enum SiloIdentityProviderType; // Enum values - Local => b"local" Saml => b"saml" - Ldap => b"ldap" ); #[derive(Queryable, Insertable, Clone, Debug, Selectable)] diff --git a/nexus/src/external_api/views.rs b/nexus/src/external_api/views.rs index f84df838863..d750310beab 100644 --- a/nexus/src/external_api/views.rs +++ b/nexus/src/external_api/views.rs @@ -38,17 +38,25 @@ impl Into for model::Silo { // SILO IDENTITY PROVIDER +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SiloIdentityProviderType { + Saml, +} + #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] pub struct SiloIdentityProvider { pub provider_id: Uuid, - pub provider_type: String, + pub provider_type: SiloIdentityProviderType, } impl Into for model::SiloIdentityProvider { fn into(self) -> SiloIdentityProvider { SiloIdentityProvider { provider_id: self.provider_id, - provider_type: self.provider_type.to_string(), + provider_type: match self.provider_type { + model::SiloIdentityProviderType::Saml => SiloIdentityProviderType::Saml, + } } } } diff --git a/nexus/src/nexus.rs b/nexus/src/nexus.rs index b1c233a815c..7cbda138da3 100644 --- a/nexus/src/nexus.rs +++ b/nexus/src/nexus.rs @@ -3907,18 +3907,6 @@ impl Nexus { .await?; match silo_identity_provider.provider_type { - db::model::SiloIdentityProviderType::Local => { - return Err(Error::unavail( - &"local silo authn provider not yet supported", - )); - } - - db::model::SiloIdentityProviderType::Ldap => { - return Err(Error::unavail( - &"ldap silo authn provider not yet supported", - )); - } - db::model::SiloIdentityProviderType::Saml => { let (.., silo_saml_identity_provider) = LookupPath::new(opctx, &self.datastore()) From 7c1fbe40664a04ab8e816355d110e7a6127eb9f2 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Fri, 6 May 2022 11:57:00 -0400 Subject: [PATCH 28/54] fmt --- nexus/src/db/model/mod.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/nexus/src/db/model/mod.rs b/nexus/src/db/model/mod.rs index bf51cd52262..07769213084 100644 --- a/nexus/src/db/model/mod.rs +++ b/nexus/src/db/model/mod.rs @@ -404,8 +404,17 @@ mod tests { //BooleanLiteral => false ); - assert_eq!(TestType::ByteStringLiteral.to_string(), "test post".to_string()); - assert_eq!(TestType::RawByteStringLiteral.to_string(), "please \" ignore".to_string()); - assert_eq!(TestType::HexSixRawByteStringLiteral.to_string(), "\\x06".to_string()); + assert_eq!( + TestType::ByteStringLiteral.to_string(), + "test post".to_string() + ); + assert_eq!( + TestType::RawByteStringLiteral.to_string(), + "please \" ignore".to_string() + ); + assert_eq!( + TestType::HexSixRawByteStringLiteral.to_string(), + "\\x06".to_string() + ); } } From 044c82d1014cb364573e12c943e7110c9eabf9fc Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Fri, 6 May 2022 11:57:12 -0400 Subject: [PATCH 29/54] further remove local and ldap --- nexus/src/authn/silos.rs | 4 +--- nexus/src/external_api/console_api.rs | 12 ------------ nexus/src/external_api/views.rs | 25 ------------------------- nexus/src/nexus.rs | 4 ++-- 4 files changed, 3 insertions(+), 42 deletions(-) diff --git a/nexus/src/authn/silos.rs b/nexus/src/authn/silos.rs index 14c6689ce17..3b041dede16 100644 --- a/nexus/src/authn/silos.rs +++ b/nexus/src/authn/silos.rs @@ -16,9 +16,7 @@ use samael::service_provider::ServiceProvider; use samael::service_provider::ServiceProviderBuilder; pub enum SiloIdentityProviderType { - Local, - Ldap, - Saml(Box), + Saml(SiloSamlIdentityProvider), } impl SiloSamlIdentityProvider { diff --git a/nexus/src/external_api/console_api.rs b/nexus/src/external_api/console_api.rs index ff9289e8504..e9de40b1454 100644 --- a/nexus/src/external_api/console_api.rs +++ b/nexus/src/external_api/console_api.rs @@ -126,12 +126,6 @@ pub async fn ask_user_to_login_to_provider( .await?; match identity_provider { - SiloIdentityProviderType::Local => { - todo!() - } - SiloIdentityProviderType::Ldap => { - todo!() - } SiloIdentityProviderType::Saml(silo_saml_identity_provider) => { let relay_state = None; let sign_in_url = silo_saml_identity_provider @@ -183,12 +177,6 @@ pub async fn consume_credentials_and_authn_user( .await?; match identity_provider { - SiloIdentityProviderType::Local => { - todo!() - } - SiloIdentityProviderType::Ldap => { - todo!() - } SiloIdentityProviderType::Saml(_silo_saml_identity_provider) => { todo!() } diff --git a/nexus/src/external_api/views.rs b/nexus/src/external_api/views.rs index d750310beab..3c2e1914c16 100644 --- a/nexus/src/external_api/views.rs +++ b/nexus/src/external_api/views.rs @@ -36,31 +36,6 @@ impl Into for model::Silo { } } -// SILO IDENTITY PROVIDER - -#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum SiloIdentityProviderType { - Saml, -} - -#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] -pub struct SiloIdentityProvider { - pub provider_id: Uuid, - pub provider_type: SiloIdentityProviderType, -} - -impl Into for model::SiloIdentityProvider { - fn into(self) -> SiloIdentityProvider { - SiloIdentityProvider { - provider_id: self.provider_id, - provider_type: match self.provider_type { - model::SiloIdentityProviderType::Saml => SiloIdentityProviderType::Saml, - } - } - } -} - // ORGANIZATIONS /// Client view of an [`Organization`] diff --git a/nexus/src/nexus.rs b/nexus/src/nexus.rs index 7cbda138da3..d0f3772e5bd 100644 --- a/nexus/src/nexus.rs +++ b/nexus/src/nexus.rs @@ -3915,9 +3915,9 @@ impl Nexus { .fetch() .await?; - Ok(authn::silos::SiloIdentityProviderType::Saml(Box::new( + Ok(authn::silos::SiloIdentityProviderType::Saml( silo_saml_identity_provider, - ))) + )) } } } From d835c45b374428238f86edb55c4385fe72225d5d Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Fri, 6 May 2022 12:27:54 -0400 Subject: [PATCH 30/54] pool_authorized, plus opctx check for silo create child perm --- nexus/src/db/datastore.rs | 11 ++++++++--- nexus/src/nexus.rs | 6 ++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/nexus/src/db/datastore.rs b/nexus/src/db/datastore.rs index 634aed17a2e..78dd13b618d 100644 --- a/nexus/src/db/datastore.rs +++ b/nexus/src/db/datastore.rs @@ -2701,7 +2701,7 @@ impl DataStore { let updated_rows = diesel::delete(idp_dsl::silo_identity_provider) .filter(idp_dsl::silo_id.eq(id)) - .execute_async(self.pool()) + .execute_async(self.pool_authorized(opctx).await?) .await .map_err(|e| { public_error_from_diesel_pool( @@ -2722,7 +2722,7 @@ impl DataStore { .filter(saml_idp_dsl::silo_id.eq(id)) .filter(saml_idp_dsl::time_deleted.is_null()) .set(saml_idp_dsl::time_deleted.eq(Utc::now())) - .execute_async(self.pool()) + .execute_async(self.pool_authorized(opctx).await?) .await .map_err(|e| { public_error_from_diesel_pool( @@ -2744,9 +2744,14 @@ impl DataStore { pub async fn silo_saml_identity_provider_create( &self, + opctx: &OpContext, + authz_silo: &authz::Silo, provider: db::model::SiloSamlIdentityProvider, ) -> CreateResult { - self.pool() + opctx.authorize(authz::Action::CreateChild, authz_silo).await?; + + self.pool_authorized(opctx) + .await? .transaction(move |conn| { // insert silo identity provider record with type Saml use db::schema::silo_identity_provider::dsl as idp_dsl; diff --git a/nexus/src/nexus.rs b/nexus/src/nexus.rs index d0f3772e5bd..def20355555 100644 --- a/nexus/src/nexus.rs +++ b/nexus/src/nexus.rs @@ -3832,7 +3832,7 @@ impl Nexus { silo_name: &Name, params: params::SiloSamlIdentityProviderCreate, ) -> CreateResult { - let (.., db_silo) = LookupPath::new(opctx, &self.db_datastore) + let (authz_silo, db_silo) = LookupPath::new(opctx, &self.db_datastore) .silo_name(silo_name) .fetch_for(authz::Action::CreateChild) .await?; @@ -3873,7 +3873,9 @@ impl Nexus { .validate() .map_err(|e| Error::invalid_request(&e.to_string()))?; - self.db_datastore.silo_saml_identity_provider_create(provider).await + self.db_datastore + .silo_saml_identity_provider_create(opctx, &authz_silo, provider) + .await } pub async fn silo_saml_identity_provider_fetch( From 8a6f9548328abf56ea475884d40975e0cd27e4fe Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Fri, 6 May 2022 13:54:01 -0400 Subject: [PATCH 31/54] ErrorHandler::NotFoundByResource --- nexus/src/db/datastore.rs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/nexus/src/db/datastore.rs b/nexus/src/db/datastore.rs index 78dd13b618d..88db1dda178 100644 --- a/nexus/src/db/datastore.rs +++ b/nexus/src/db/datastore.rs @@ -2706,10 +2706,7 @@ impl DataStore { .map_err(|e| { public_error_from_diesel_pool( e, - ErrorHandler::NotFoundByLookup( - ResourceType::Silo, - LookupType::ById(id), - ), + ErrorHandler::NotFoundByResource(authz_silo), ) })?; @@ -2727,10 +2724,7 @@ impl DataStore { .map_err(|e| { public_error_from_diesel_pool( e, - ErrorHandler::NotFoundByLookup( - ResourceType::Silo, - LookupType::ById(id), - ), + ErrorHandler::NotFoundByResource(authz_silo), ) })?; From b083fe5266e4cc234935d4973e48f5102a88e137 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Fri, 6 May 2022 16:54:02 -0400 Subject: [PATCH 32/54] change to String::from_utf8_lossy --- nexus/src/db/model/mod.rs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/nexus/src/db/model/mod.rs b/nexus/src/db/model/mod.rs index 07769213084..0efea6a2044 100644 --- a/nexus/src/db/model/mod.rs +++ b/nexus/src/db/model/mod.rs @@ -218,7 +218,7 @@ macro_rules! impl_enum_type { fn to_string(&self) -> String { match self { $( - $model_type::$enum_item => String::from_utf8($sql_value.to_vec()).unwrap(), + $model_type::$enum_item => String::from_utf8_lossy($sql_value).to_string(), )* } } @@ -389,7 +389,13 @@ mod tests { RawByteStringLiteral => br##"please " ignore"## // raw byte string literals can be any ASCII (i.e. 0x00 to 0x7F) - HexSixRawByteStringLiteral => br"\x06" + HexSixRawByteStringLiteral => b"\x06" + + // make sure non-utf8 works + NonUtf8ByteStringLiteral => b"\xF6" + + // Sure why not + Rocketship => b"\xF0\x9F\x9A\x80" // 🚀 // none of these compile //IntegerLiteral1 => 123i32 @@ -414,7 +420,15 @@ mod tests { ); assert_eq!( TestType::HexSixRawByteStringLiteral.to_string(), - "\\x06".to_string() + "\u{6}".to_string() + ); + assert_eq!( + TestType::NonUtf8ByteStringLiteral.to_string(), + "�".to_string() + ); + assert_eq!( + TestType::Rocketship.to_string(), + "🚀".to_string(), ); } } From 857168188156f45ff1e27048394f9ebc8bc9cb36 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Fri, 6 May 2022 16:54:15 -0400 Subject: [PATCH 33/54] remove local and ldap from dbinit --- common/src/sql/dbinit.sql | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/common/src/sql/dbinit.sql b/common/src/sql/dbinit.sql index 8f55bc61aa2..43db577ac4c 100644 --- a/common/src/sql/dbinit.sql +++ b/common/src/sql/dbinit.sql @@ -221,9 +221,7 @@ CREATE INDEX ON omicron.public.silo_user ( time_deleted IS NULL; CREATE TYPE omicron.public.provider_type AS ENUM ( - 'local', - 'saml', - 'ldap' + 'saml' ); /* From 1ed412efc8aec7e072fd4d533f568840d39a6483 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Fri, 6 May 2022 17:02:07 -0400 Subject: [PATCH 34/54] fmt --- nexus/src/db/model/mod.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/nexus/src/db/model/mod.rs b/nexus/src/db/model/mod.rs index 0efea6a2044..df853637928 100644 --- a/nexus/src/db/model/mod.rs +++ b/nexus/src/db/model/mod.rs @@ -426,9 +426,6 @@ mod tests { TestType::NonUtf8ByteStringLiteral.to_string(), "�".to_string() ); - assert_eq!( - TestType::Rocketship.to_string(), - "🚀".to_string(), - ); + assert_eq!(TestType::Rocketship.to_string(), "🚀".to_string(),); } } From 74521e8bd3014f3cc09139c7a15a2e127f0d9b8d Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Mon, 9 May 2022 11:31:45 -0400 Subject: [PATCH 35/54] turns out I can add id without test failure...? --- common/src/sql/dbinit.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/common/src/sql/dbinit.sql b/common/src/sql/dbinit.sql index 43db577ac4c..367e8fc08a6 100644 --- a/common/src/sql/dbinit.sql +++ b/common/src/sql/dbinit.sql @@ -264,6 +264,7 @@ CREATE TABLE omicron.public.silo_saml_identity_provider ( ); CREATE INDEX ON omicron.public.silo_saml_identity_provider ( + id, silo_id ) WHERE time_deleted IS NULL; From 813876a73e46bff781117160976fd3b7c903548b Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Mon, 9 May 2022 11:40:03 -0400 Subject: [PATCH 36/54] use ErrorHandler::Conflict --- nexus/src/db/datastore.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/nexus/src/db/datastore.rs b/nexus/src/db/datastore.rs index 88db1dda178..a5d255c30f4 100644 --- a/nexus/src/db/datastore.rs +++ b/nexus/src/db/datastore.rs @@ -2744,6 +2744,7 @@ impl DataStore { ) -> CreateResult { opctx.authorize(authz::Action::CreateChild, authz_silo).await?; + let name = provider.identity().name.to_string(); self.pool_authorized(opctx) .await? .transaction(move |conn| { @@ -2772,8 +2773,14 @@ impl DataStore { Ok(result) }) .await - .map_err(|e: TransactionError| { - Error::internal_error(&format!("Transaction error: {}", e)) + .map_err(|e| { + public_error_from_diesel_pool( + e, + ErrorHandler::Conflict( + ResourceType::SiloSamlIdentityProvider, + &name, + ), + ) }) } From 56320f37d1fcefebb104ef4e835bbfaaba0dcdb1 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Mon, 9 May 2022 13:10:07 -0400 Subject: [PATCH 37/54] better comment --- nexus/src/authn/silos.rs | 3 ++- nexus/tests/integration_tests/silos.rs | 4 ---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/nexus/src/authn/silos.rs b/nexus/src/authn/silos.rs index 3b041dede16..ef00f212671 100644 --- a/nexus/src/authn/silos.rs +++ b/nexus/src/authn/silos.rs @@ -41,7 +41,8 @@ impl SiloSamlIdentityProvider { bail!("public and private key must be supplied together"); } - // TODO validate DER keys + // TODO if supplied, validate that the cert and key pair of [u8] is + // actually DER formatted X509 keys Ok(()) } diff --git a/nexus/tests/integration_tests/silos.rs b/nexus/tests/integration_tests/silos.rs index ab229921664..ece6e7321b8 100644 --- a/nexus/tests/integration_tests/silos.rs +++ b/nexus/tests/integration_tests/silos.rs @@ -274,10 +274,6 @@ async fn test_create_a_saml_idp(cptestctx: &ControlPlaneTestContext) { omicron_nexus::authn::silos::SiloIdentityProviderType::Saml(_) => { // ok } - - _ => { - assert!(false); - } } // Check that created identity provider exists From 6597d6703af4963d24249156bc6910bf367a07e6 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Mon, 9 May 2022 15:56:02 -0400 Subject: [PATCH 38/54] use lazy_static http server, restore unauthorized_coverage remove silo_id from params, it was not used --- nexus/src/external_api/params.rs | 2 - nexus/tests/integration_tests/endpoints.rs | 90 ++++++ nexus/tests/integration_tests/mod.rs | 1 + nexus/tests/integration_tests/silos.rs | 7 - nexus/tests/integration_tests/unauthorized.rs | 306 +++--------------- .../unauthorized_coverage.rs | 147 +++++++++ openapi/nexus.json | 5 - 7 files changed, 275 insertions(+), 283 deletions(-) create mode 100644 nexus/tests/integration_tests/unauthorized_coverage.rs diff --git a/nexus/src/external_api/params.rs b/nexus/src/external_api/params.rs index 641ac1bc605..ba394fb307b 100644 --- a/nexus/src/external_api/params.rs +++ b/nexus/src/external_api/params.rs @@ -32,8 +32,6 @@ pub struct SiloSamlIdentityProviderCreate { #[serde(flatten)] pub identity: IdentityMetadataCreateParams, - pub silo_id: Uuid, - /// url where identity provider metadata descriptor is pub idp_metadata_url: String, diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index 4847e831450..d33eedd4e60 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -28,6 +28,7 @@ use omicron_nexus::external_api::params; use omicron_nexus::external_api::shared; use std::net::IpAddr; use std::net::Ipv4Addr; +use crate::integration_tests::unauthorized::HTTP_SERVER; lazy_static! { pub static ref HARDWARE_RACK_URL: String = @@ -221,6 +222,15 @@ lazy_static! { pub static ref DEMO_IMAGE_NAME: Name = "demo-image".parse().unwrap(); pub static ref DEMO_PROJECT_IMAGE_URL: String = format!("{}/{}", *DEMO_PROJECT_URL_IMAGES, *DEMO_IMAGE_NAME); + pub static ref DEMO_IMAGE_CREATE: params::ImageCreate = + params::ImageCreate { + identity: IdentityMetadataCreateParams { + name: DEMO_IMAGE_NAME.clone(), + description: String::from(""), + }, + source: params::ImageSource::Url(HTTP_SERVER.url("/image.raw").to_string()), + block_size: params::BlockSize::try_from(4096).unwrap(), + }; // Global Images pub static ref DEMO_GLOBAL_IMAGE_URL: String = @@ -238,6 +248,26 @@ lazy_static! { }, disk: DEMO_DISK_NAME.clone(), }; + + // SAML identity provider + pub static ref SILO_SAML_IDENTITY_PROVIDER: params::SiloSamlIdentityProviderCreate = + params::SiloSamlIdentityProviderCreate { + identity: IdentityMetadataCreateParams { + name: "demo-saml-provider".to_string().parse().unwrap(), + description: "a demo provider".to_string(), + }, + + idp_metadata_url: HTTP_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, + }; } /// Describes an API endpoint to be verified by the "unauthorized" test @@ -659,6 +689,28 @@ lazy_static! { ], }, + /* Project images */ + + VerifyEndpoint { + url: &*DEMO_PROJECT_URL_IMAGES, + visibility: Visibility::Protected, + allowed_methods: vec![ + AllowedMethod::GetUnimplemented, + AllowedMethod::Post( + serde_json::to_value(&*DEMO_IMAGE_CREATE).unwrap() + ), + ], + }, + + VerifyEndpoint { + url: &*DEMO_PROJECT_IMAGE_URL, + visibility: Visibility::Protected, + allowed_methods: vec![ + AllowedMethod::GetUnimplemented, + AllowedMethod::Delete, + ], + }, + /* Snapshots */ VerifyEndpoint { @@ -835,5 +887,43 @@ lazy_static! { serde_json::Value::Null )], }, + + /* Global Images */ + + VerifyEndpoint { + url: "/images", + visibility: Visibility::Public, + allowed_methods: vec![ + AllowedMethod::Get, + AllowedMethod::Post( + serde_json::to_value(&*DEMO_IMAGE_CREATE).unwrap() + ), + ], + }, + + VerifyEndpoint { + url: &*DEMO_GLOBAL_IMAGE_URL, + visibility: Visibility::Protected, + allowed_methods: vec![ + AllowedMethod::Get, + AllowedMethod::Delete, + ], + }, + + /* Silo identity providers */ + + VerifyEndpoint { + url: "/silos/default-silo/saml_identity_provider", + visibility: Visibility::Public, // Users can see their own silo! This includes USER_TEST_UNPRIVILEGED + allowed_methods: vec![AllowedMethod::Post( + serde_json::to_value(&*SILO_SAML_IDENTITY_PROVIDER).unwrap(), + )], + }, + VerifyEndpoint { + url: + "/silos/default-silo/saml_identity_provider/demo-saml-provider", + visibility: Visibility::Protected, + allowed_methods: vec![AllowedMethod::Get], + }, ]; } diff --git a/nexus/tests/integration_tests/mod.rs b/nexus/tests/integration_tests/mod.rs index d9a6538482e..de5de9679bd 100644 --- a/nexus/tests/integration_tests/mod.rs +++ b/nexus/tests/integration_tests/mod.rs @@ -22,6 +22,7 @@ mod ssh_keys; mod subnet_allocation; mod timeseries; mod unauthorized; +mod unauthorized_coverage; mod updates; mod users_builtin; mod vpc_firewall; diff --git a/nexus/tests/integration_tests/silos.rs b/nexus/tests/integration_tests/silos.rs index ece6e7321b8..1aecd2f7419 100644 --- a/nexus/tests/integration_tests/silos.rs +++ b/nexus/tests/integration_tests/silos.rs @@ -223,7 +223,6 @@ async fn test_create_a_saml_idp(cptestctx: &ControlPlaneTestContext) { description: "a demo provider".to_string(), }, - silo_id: silo.identity.id, idp_metadata_url: server.url("/descriptor").to_string(), idp_entity_id: "entity_id".to_string(), @@ -349,7 +348,6 @@ async fn test_deleting_a_silo_deletes_the_idp( description: "a demo provider".to_string(), }, - silo_id: silo.identity.id, idp_metadata_url: server.url("/descriptor").to_string(), idp_entity_id: "entity_id".to_string(), @@ -423,7 +421,6 @@ async fn test_create_a_saml_idp_invalid_descriptor_truncated( description: "a demo provider".to_string(), }, - silo_id: silo.identity.id, idp_metadata_url: server.url("/descriptor").to_string(), idp_entity_id: "entity_id".to_string(), @@ -502,7 +499,6 @@ async fn test_create_a_saml_idp_invalid_descriptor_no_redirect_binding( description: "a demo provider".to_string(), }, - silo_id: silo.identity.id, idp_metadata_url: server.url("/descriptor").to_string(), idp_entity_id: "entity_id".to_string(), @@ -554,7 +550,6 @@ async fn test_create_a_hidden_silo_saml_idp( description: "a demo provider".to_string(), }, - silo_id: silo.identity.id, idp_metadata_url: server.url("/descriptor").to_string(), idp_entity_id: "entity_id".to_string(), @@ -632,7 +627,6 @@ async fn test_saml_idp_metadata_url_404(cptestctx: &ControlPlaneTestContext) { description: "a demo provider".to_string(), }, - silo_id: silo.identity.id, idp_metadata_url: server.url("/descriptor").to_string(), idp_entity_id: "entity_id".to_string(), @@ -688,7 +682,6 @@ async fn test_saml_idp_metadata_url_invalid( description: "a demo provider".to_string(), }, - silo_id: silo.identity.id, idp_metadata_url: "htttps://fake.url".to_string(), idp_entity_id: "entity_id".to_string(), diff --git a/nexus/tests/integration_tests/unauthorized.rs b/nexus/tests/integration_tests/unauthorized.rs index 435e0539c3e..7e0e677ad6f 100644 --- a/nexus/tests/integration_tests/unauthorized.rs +++ b/nexus/tests/integration_tests/unauthorized.rs @@ -9,7 +9,6 @@ use super::endpoints::*; use crate::integration_tests::silos::SAML_IDP_DESCRIPTOR; use dropshot::test_util::ClientTestContext; use dropshot::HttpErrorResponseBody; -use expectorate::assert_contents; use headers::authorization::Credentials; use http::method::Method; use http::StatusCode; @@ -25,17 +24,7 @@ use nexus_test_utils_macros::nexus_test; use omicron_common::api::external::IdentityMetadataCreateParams; use omicron_nexus::authn::external::spoof; use omicron_nexus::db::fixed_data::silo::DEFAULT_SILO; -use omicron_nexus::db::identity::Resource; use omicron_nexus::external_api::params; -use openapiv3::OpenAPI; -use std::collections::BTreeMap; - -#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] -struct Operation<'a> { - method: &'a str, - path: &'a str, - label: String, -} // This test hits a list Nexus API endpoints using both unauthenticated and // unauthorized requests to make sure we get the expected behavior (generally: @@ -70,28 +59,7 @@ async fn test_unauthorized(cptestctx: &ControlPlaneTestContext) { let client = &cptestctx.external_client; let log = &cptestctx.logctx.log; - // Run a httptest server - let server = ServerBuilder::new().run().unwrap(); - - // Fake some data - server.expect( - Expectation::matching(request::method_path("HEAD", "/image.raw")) - .times(1..) - .respond_with( - status_code(200).append_header( - "Content-Length", - format!("{}", 4096 * 1000), - ), - ), - ); - - server.expect( - Expectation::matching(request::method_path("GET", "/descriptor")) - .times(1..) - .respond_with(status_code(200).body(SAML_IDP_DESCRIPTOR)), - ); - - // Create test data with hardcoded values. + // Create test data. info!(log, "setting up resource hierarchy"); for request in &*SETUP_REQUESTS { NexusRequest::objects_post(client, request.url, &request.body) @@ -101,121 +69,12 @@ async fn test_unauthorized(cptestctx: &ControlPlaneTestContext) { .unwrap(); } - // Create test data with non-hardcoded values. - - // Create a GlobalImage - let demo_image_create: params::ImageCreate = params::ImageCreate { - identity: IdentityMetadataCreateParams { - name: DEMO_IMAGE_NAME.clone(), - description: String::from(""), - }, - source: params::ImageSource::Url(server.url("/image.raw").to_string()), - block_size: params::BlockSize::try_from(4096).unwrap(), - }; - - NexusRequest::objects_post(client, "/images", &demo_image_create) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .unwrap(); - - // Create a SAML identity provider - let demo_silo_saml_idp_create = params::SiloSamlIdentityProviderCreate { - identity: IdentityMetadataCreateParams { - name: "demo-saml-provider".to_string().parse().unwrap(), - description: "a demo provider".to_string(), - }, - - silo_id: DEFAULT_SILO.id().clone(), - 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, - }; - - NexusRequest::objects_post( - client, - "/silos/default-silo/saml_identity_provider", - &demo_silo_saml_idp_create, - ) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .unwrap(); - - let mut non_hardcoded_endpoints = vec![ - /* Project images */ - VerifyEndpoint { - url: &*DEMO_PROJECT_URL_IMAGES, - visibility: Visibility::Protected, - allowed_methods: vec![ - AllowedMethod::GetUnimplemented, - AllowedMethod::Post( - serde_json::to_value(&demo_image_create).unwrap(), - ), - ], - }, - VerifyEndpoint { - url: &*DEMO_PROJECT_IMAGE_URL, - visibility: Visibility::Protected, - allowed_methods: vec![ - AllowedMethod::GetUnimplemented, - AllowedMethod::Delete, - ], - }, - /* Global images */ - VerifyEndpoint { - url: "/images", - visibility: Visibility::Public, - allowed_methods: vec![ - AllowedMethod::Get, - AllowedMethod::Post( - serde_json::to_value(&demo_image_create).unwrap(), - ), - ], - }, - VerifyEndpoint { - url: &*DEMO_GLOBAL_IMAGE_URL, - visibility: Visibility::Protected, - allowed_methods: vec![AllowedMethod::Get, AllowedMethod::Delete], - }, - /* Silo identity providers */ - VerifyEndpoint { - url: "/silos/default-silo/saml_identity_provider", - visibility: Visibility::Public, // Users can see their own silo! This includes USER_TEST_UNPRIVILEGED - allowed_methods: vec![AllowedMethod::Post( - serde_json::to_value(&demo_silo_saml_idp_create).unwrap(), - )], - }, - VerifyEndpoint { - url: - "/silos/default-silo/saml_identity_provider/demo-saml-provider", - visibility: Visibility::Protected, - allowed_methods: vec![AllowedMethod::Get], - }, - ]; - - let mut endpoints: Vec = Vec::with_capacity( - VERIFY_ENDPOINTS.len() + non_hardcoded_endpoints.len(), - ); - endpoints.extend(VERIFY_ENDPOINTS.clone()); - endpoints.append(&mut non_hardcoded_endpoints); - // Verify the endpoints. info!(log, "verifying endpoints"); print!("{}", VERIFY_HEADER); - for endpoint in &endpoints { + for endpoint in &*VERIFY_ENDPOINTS { verify_endpoint(&log, client, endpoint).await; } - - // Validate unauthorized coverage - unauthorized_coverage(&endpoints); } const VERIFY_HEADER: &str = r#" @@ -268,6 +127,31 @@ struct SetupReq { } lazy_static! { + pub static ref HTTP_SERVER: httptest::Server = { + // Run a httptest server + let server = ServerBuilder::new().run().unwrap(); + + // Fake some data + server.expect( + Expectation::matching(request::method_path("HEAD", "/image.raw")) + .times(1..) + .respond_with( + status_code(200).append_header( + "Content-Length", + format!("{}", 4096 * 1000), + ), + ), + ); + + server.expect( + Expectation::matching(request::method_path("GET", "/descriptor")) + .times(1..) + .respond_with(status_code(200).body(SAML_IDP_DESCRIPTOR)), + ); + + server + }; + /// List of requests to execute at setup time static ref SETUP_REQUESTS: Vec = vec![ // Create a separate Silo (not used for anything else) @@ -315,6 +199,16 @@ lazy_static! { url: &*DEMO_PROJECT_URL_INSTANCES, body: serde_json::to_value(&*DEMO_INSTANCE_CREATE).unwrap(), }, + // Create a GlobalImage + SetupReq { + url: "/images", + body: serde_json::to_value(&*DEMO_IMAGE_CREATE).unwrap(), + }, + // Create a SAML identity provider + SetupReq { + url: "/silos/default-silo/saml_identity_provider", + body: serde_json::to_value(&*SILO_SAML_IDENTITY_PROVIDER).unwrap(), + }, ]; } @@ -656,129 +550,3 @@ fn record_operation(whichtest: WhichTest<'_>) { print!("{}", c); } } - -fn unauthorized_coverage(endpoints: &Vec) { - // Load the OpenAPI schema for Nexus's public API. - let schema_path = "../openapi/nexus.json"; - let schema_contents = std::fs::read_to_string(&schema_path) - .expect("failed to read Nexus OpenAPI spec"); - let spec: OpenAPI = serde_json::from_str(&schema_contents) - .expect("Nexus OpenAPI spec was not valid OpenAPI"); - - // Take each operation that we find in the OpenAPI spec, make a regular - // expression that we can use to match against it (more on this below), and - // throw them into a BTreeMap. - let mut spec_operations: BTreeMap = spec - .operations() - .map(|(path, method, op)| { - // We're going to take URLs from our test cases and match them - // against operations in the API spec. The URLs from the API spec - // contain variables (e.g., "/instances/{instance_name}"). Our test - // cases have those variables filled in already (e.g., - // "/instances/my-instance"). - // - // To match a URL from the test case against one from the OpenAPI - // spec, we're going to: - // - // - use a regular expression to replace `{varname}` in the API - // spec's URL with `[^/]+` (one or more non-slash characters) - // - // - use that string as the basis for a second regex that we'll - // store with the Operation. We'll use this second regex to match - // URLs to their operation. - // - // This is slow (lookups will take time linear in the total number - // of API endpoints) and a little cheesy, but it's expedient and - // robust enough for our purposes. - // - // This will fail badly if it turns out that the URL contains any - // characters that would be interpreted specially by the regular - // expression engine. So let's check up front that those aren't - // present. - assert!( - path.chars().all(|c| c.is_ascii_alphanumeric() - || c == '_' - || c == '-' - || c == '{' - || c == '}' - || c == '/'), - "unexpected character in URL: {:?}", - path - ); - let re = regex::Regex::new("/\\{[^}]+\\}").unwrap(); - let regex_path = re.replace_all(path, "/[^/]+"); - let regex = regex::Regex::new(&format!("^{}$", regex_path)) - .expect("modified URL string was not a valid regex"); - let label = op - .operation_id - .clone() - .unwrap_or(String::from("unknown operation-id")); - (Operation { method, path, label }, regex) - }) - .collect(); - - // Go through each of the authz test cases and match each one against an - // OpenAPI operation. - let mut unexpected_endpoints = String::from( - "API endpoints tested by unauthorized.rs but not found \ - in the OpenAPI spec:\n", - ); - for v in endpoints { - for m in &v.allowed_methods { - let method_string = m.http_method().to_string().to_uppercase(); - let found = spec_operations.iter().find(|(op, regex)| { - op.method.to_uppercase() == method_string - && regex.is_match(v.url) - }); - if let Some((op, _)) = found { - println!( - "covered: {:40} ({:6?} {:?}) (by {:?})", - op.label, op.method, op.path, v.url - ); - let op = op.clone(); - spec_operations.remove(&op); - } else { - unexpected_endpoints - .push_str(&format!("{:6} {:?}", method_string, v.url)); - } - } - } - - println!("-----"); - - // If you're here because this assertion failed, we found an endpoint tested - // by "unauthorized.rs" that's not in the OpenAPI spec. This could happen - // if you're adding a test for an endpoint that's marked "unpublished". In - // that case, you might just allow expectorate to add this endpoint to the - // allowlist here. - assert_contents( - "tests/output/unexpected-authz-endpoints.txt", - &unexpected_endpoints, - ); - - // Check for uncovered endpoints (endpoints that are in the OpenAPI spec but - // not tested by the authz tests). - let mut uncovered_endpoints = - "API endpoints with no coverage in authz tests:\n".to_string(); - for op in spec_operations.keys() { - uncovered_endpoints.push_str(&format!( - "{:40} ({:6} {:?})\n", - op.label, op.method, op.path - )); - } - - // If you're here because this assertion failed, check that if you've added - // any API operations to Nexus, you've also added a corresponding test in - // "unauthorized.rs" so that it will automatically be checked for its - // behavior for unauthenticated and unauthorized users. DO NOT SKIP THIS. - // Even if you're just adding a stub, see [`Nexus::unimplemented_todo()`]. - // If you _added_ a test that covered an endpoint from the allowlist -- - // hooray! Just delete the corresponding line from this file. (Why is this - // not `expectorage::assert_contents`? Because we only expect this file to - // ever shrink, which is easy enough to fix by hand, and we don't want to - // make it easy to accidentally add things to the allowlist.) - let expected_uncovered_endpoints = - std::fs::read_to_string("tests/output/uncovered-authz-endpoints.txt") - .expect("failed to load file of allowed uncovered endpoints"); - assert_eq!(expected_uncovered_endpoints, uncovered_endpoints); -} diff --git a/nexus/tests/integration_tests/unauthorized_coverage.rs b/nexus/tests/integration_tests/unauthorized_coverage.rs new file mode 100644 index 00000000000..e92a5af98be --- /dev/null +++ b/nexus/tests/integration_tests/unauthorized_coverage.rs @@ -0,0 +1,147 @@ +// 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/. + +use super::endpoints::VERIFY_ENDPOINTS; +use expectorate::assert_contents; +use openapiv3::OpenAPI; +use std::collections::BTreeMap; + +/// Checks for uncovered public API endpoints +/// +/// This test compares the endpoints covered by the "unauthorized" test with +/// what's in the OpenAPI spec for the public API to make sure all endpoints are +/// accounted-for. +#[test] +fn test_unauthorized_coverage() { + // Load the OpenAPI schema for Nexus's public API. + let schema_path = "../openapi/nexus.json"; + let schema_contents = std::fs::read_to_string(&schema_path) + .expect("failed to read Nexus OpenAPI spec"); + let spec: OpenAPI = serde_json::from_str(&schema_contents) + .expect("Nexus OpenAPI spec was not valid OpenAPI"); + + // Take each operation that we find in the OpenAPI spec, make a regular + // expression that we can use to match against it (more on this below), and + // throw them into a BTreeMap. + let mut spec_operations: BTreeMap = spec + .operations() + .map(|(path, method, op)| { + // We're going to take URLs from our test cases and match them + // against operations in the API spec. The URLs from the API spec + // contain variables (e.g., "/instances/{instance_name}"). Our test + // cases have those variables filled in already (e.g., + // "/instances/my-instance"). + // + // To match a URL from the test case against one from the OpenAPI + // spec, we're going to: + // + // - use a regular expression to replace `{varname}` in the API + // spec's URL with `[^/]+` (one or more non-slash characters) + // + // - use that string as the basis for a second regex that we'll + // store with the Operation. We'll use this second regex to match + // URLs to their operation. + // + // This is slow (lookups will take time linear in the total number + // of API endpoints) and a little cheesy, but it's expedient and + // robust enough for our purposes. + // + // This will fail badly if it turns out that the URL contains any + // characters that would be interpreted specially by the regular + // expression engine. So let's check up front that those aren't + // present. + assert!( + path.chars().all(|c| c.is_ascii_alphanumeric() + || c == '_' + || c == '-' + || c == '{' + || c == '}' + || c == '/'), + "unexpected character in URL: {:?}", + path + ); + let re = regex::Regex::new("/\\{[^}]+\\}").unwrap(); + let regex_path = re.replace_all(path, "/[^/]+"); + let regex = regex::Regex::new(&format!("^{}$", regex_path)) + .expect("modified URL string was not a valid regex"); + let label = op + .operation_id + .clone() + .unwrap_or(String::from("unknown operation-id")); + (Operation { method, path, label }, regex) + }) + .collect(); + + // Go through each of the authz test cases and match each one against an + // OpenAPI operation. + let mut unexpected_endpoints = String::from( + "API endpoints tested by unauthorized.rs but not found \ + in the OpenAPI spec:\n", + ); + for v in &*VERIFY_ENDPOINTS { + for m in &v.allowed_methods { + let method_string = m.http_method().to_string().to_uppercase(); + let found = spec_operations.iter().find(|(op, regex)| { + op.method.to_uppercase() == method_string + && regex.is_match(v.url) + }); + if let Some((op, _)) = found { + println!( + "covered: {:40} ({:6?} {:?}) (by {:?})", + op.label, op.method, op.path, v.url + ); + let op = op.clone(); + spec_operations.remove(&op); + } else { + unexpected_endpoints + .push_str(&format!("{:6} {:?}", method_string, v.url)); + } + } + } + + println!("-----"); + + // If you're here because this assertion failed, we found an endpoint tested + // by "unauthorized.rs" that's not in the OpenAPI spec. This could happen + // if you're adding a test for an endpoint that's marked "unpublished". In + // that case, you might just allow expectorate to add this endpoint to the + // allowlist here. + assert_contents( + "tests/output/unexpected-authz-endpoints.txt", + &unexpected_endpoints, + ); + + // Check for uncovered endpoints (endpoints that are in the OpenAPI spec but + // not tested by the authz tests). + let mut uncovered_endpoints = + "API endpoints with no coverage in authz tests:\n".to_string(); + for op in spec_operations.keys() { + uncovered_endpoints.push_str(&format!( + "{:40} ({:6} {:?})\n", + op.label, op.method, op.path + )); + } + + // If you're here because this assertion failed, check that if you've added + // any API operations to Nexus, you've also added a corresponding test in + // "unauthorized.rs" so that it will automatically be checked for its + // behavior for unauthenticated and unauthorized users. DO NOT SKIP THIS. + // Even if you're just adding a stub, see [`Nexus::unimplemented_todo()`]. + // If you _added_ a test that covered an endpoint from the allowlist -- + // hooray! Just delete the corresponding line from this file. (Why is this + // not `expectorage::assert_contents`? Because we only expect this file to + // ever shrink, which is easy enough to fix by hand, and we don't want to + // make it easy to accidentally add things to the allowlist.) + let expected_uncovered_endpoints = + std::fs::read_to_string("tests/output/uncovered-authz-endpoints.txt") + .expect("failed to load file of allowed uncovered endpoints"); + assert_eq!(expected_uncovered_endpoints, uncovered_endpoints); +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct Operation<'a> { + method: &'a str, + path: &'a str, + label: String, +} diff --git a/openapi/nexus.json b/openapi/nexus.json index b41af4696d6..3bd9d5aac91 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -7490,10 +7490,6 @@ "description": "optional request signing public certificate (base64 encoded der file)", "type": "string" }, - "silo_id": { - "type": "string", - "format": "uuid" - }, "slo_url": { "description": "service provider endpoint where the idp should send log out requests", "type": "string" @@ -7513,7 +7509,6 @@ "idp_entity_id", "idp_metadata_url", "name", - "silo_id", "slo_url", "sp_client_id", "technical_contact_email" From c9d369c0da85aeff5c7c84ed11a554ae31b583d6 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Mon, 9 May 2022 16:24:47 -0400 Subject: [PATCH 39/54] properly timeout, and return better error messages for idp urls --- nexus/src/nexus.rs | 47 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/nexus/src/nexus.rs b/nexus/src/nexus.rs index def20355555..007ce268ec9 100644 --- a/nexus/src/nexus.rs +++ b/nexus/src/nexus.rs @@ -1147,8 +1147,15 @@ impl Nexus { serde_json::to_string(&volume_construction_request)?; // use reqwest to query url for size + let dur = std::time::Duration::from_secs(5); + let client = reqwest::ClientBuilder::new() + .connect_timeout(dur) + .timeout(dur) + .build() + .map_err(|e| Error::internal_error(&format!("failed to build reqwest client: {}", e)))?; + let response = - reqwest::Client::new().head(url).send().await.map_err( + client.head(url).send().await.map_err( |e| Error::InvalidValue { label: String::from("url"), message: format!("error querying url: {}", e), @@ -3842,13 +3849,37 @@ impl Nexus { // // 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 dur = std::time::Duration::from_secs(5); + let client = reqwest::ClientBuilder::new() + .connect_timeout(dur) + .timeout(dur) + .build() + .map_err(|e| Error::internal_error(&format!("failed to build reqwest client: {}", e)))?; + + let response = + client.get(¶ms.idp_metadata_url).send().await.map_err( + |e| Error::InvalidValue { + label: String::from("url"), + message: format!("error querying url: {}", e), + }, + )?; + + if !response.status().is_success() { + return Err(Error::InvalidValue { + label: String::from("url"), + message: format!( + "querying url returned: {}", + response.status() + ), + }); + } + + let idp_metadata_document_string = response.text().await.map_err(|e| + Error::InvalidValue { + label: String::from("url"), + message: format!("error getting text from url: {}", e), + }, + )?; let provider = db::model::SiloSamlIdentityProvider { identity: db::model::SiloSamlIdentityProviderIdentity::new( From 36d3588183d6d37a518a8e3cda42415f0e60b3c6 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Mon, 9 May 2022 16:37:32 -0400 Subject: [PATCH 40/54] saml_identity_provider -> saml_identity_providers --- nexus/src/external_api/http_entrypoints.rs | 4 ++-- nexus/tests/integration_tests/endpoints.rs | 4 ++-- nexus/tests/integration_tests/silos.rs | 16 ++++++++-------- nexus/tests/integration_tests/unauthorized.rs | 2 +- nexus/tests/output/nexus_tags.txt | 4 ++-- openapi/nexus.json | 4 ++-- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index f70d2004f3f..bcaaaa88589 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -364,7 +364,7 @@ async fn silos_delete_silo( /// Create a new SAML identity provider for a silo. #[endpoint { method = POST, - path = "/silos/{silo_name}/saml_identity_provider", + path = "/silos/{silo_name}/saml_identity_providers", tags = ["silos"], }] async fn silo_saml_idp_create( @@ -401,7 +401,7 @@ struct SiloSamlPathParam { /// GET a silo's SAML identity provider #[endpoint { method = GET, - path = "/silos/{silo_name}/saml_identity_provider/{provider_name}", + path = "/silos/{silo_name}/saml_identity_providers/{provider_name}", tags = ["silos"], }] async fn silo_saml_idp_fetch( diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index d33eedd4e60..4a22aaf0d22 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -913,7 +913,7 @@ lazy_static! { /* Silo identity providers */ VerifyEndpoint { - url: "/silos/default-silo/saml_identity_provider", + url: "/silos/default-silo/saml_identity_providers", visibility: Visibility::Public, // Users can see their own silo! This includes USER_TEST_UNPRIVILEGED allowed_methods: vec![AllowedMethod::Post( serde_json::to_value(&*SILO_SAML_IDENTITY_PROVIDER).unwrap(), @@ -921,7 +921,7 @@ lazy_static! { }, VerifyEndpoint { url: - "/silos/default-silo/saml_identity_provider/demo-saml-provider", + "/silos/default-silo/saml_identity_providers/demo-saml-provider", visibility: Visibility::Protected, allowed_methods: vec![AllowedMethod::Get], }, diff --git a/nexus/tests/integration_tests/silos.rs b/nexus/tests/integration_tests/silos.rs index 1aecd2f7419..224aa9aef5d 100644 --- a/nexus/tests/integration_tests/silos.rs +++ b/nexus/tests/integration_tests/silos.rs @@ -213,7 +213,7 @@ async fn test_create_a_saml_idp(cptestctx: &ControlPlaneTestContext) { let silo_saml_idp: SiloSamlIdentityProvider = object_create( client, - &format!("/silos/{}/saml_identity_provider", SILO_NAME), + &format!("/silos/{}/saml_identity_providers", SILO_NAME), ¶ms::SiloSamlIdentityProviderCreate { identity: IdentityMetadataCreateParams { name: "some-totally-real-saml-provider" @@ -280,7 +280,7 @@ async fn test_create_a_saml_idp(cptestctx: &ControlPlaneTestContext) { NexusRequest::object_get( &client, &format!( - "/silos/{}/saml_identity_provider/{}", + "/silos/{}/saml_identity_providers/{}", silo.identity.name, silo_saml_idp.identity.name, ), ) @@ -338,7 +338,7 @@ async fn test_deleting_a_silo_deletes_the_idp( let _silo_saml_idp: SiloSamlIdentityProvider = object_create( client, - &format!("/silos/{}/saml_identity_provider", SILO_NAME), + &format!("/silos/{}/saml_identity_providers", SILO_NAME), ¶ms::SiloSamlIdentityProviderCreate { identity: IdentityMetadataCreateParams { name: "some-totally-real-saml-provider" @@ -410,7 +410,7 @@ async fn test_create_a_saml_idp_invalid_descriptor_truncated( RequestBuilder::new( client, Method::POST, - &format!("/silos/{}/saml_identity_provider", SILO_NAME), + &format!("/silos/{}/saml_identity_providers", SILO_NAME), ) .body(Some(¶ms::SiloSamlIdentityProviderCreate { identity: IdentityMetadataCreateParams { @@ -488,7 +488,7 @@ async fn test_create_a_saml_idp_invalid_descriptor_no_redirect_binding( RequestBuilder::new( client, Method::POST, - &format!("/silos/{}/saml_identity_provider", SILO_NAME), + &format!("/silos/{}/saml_identity_providers", SILO_NAME), ) .body(Some(¶ms::SiloSamlIdentityProviderCreate { identity: IdentityMetadataCreateParams { @@ -540,7 +540,7 @@ async fn test_create_a_hidden_silo_saml_idp( let silo_saml_idp: SiloSamlIdentityProvider = object_create( client, - "/silos/hidden/saml_identity_provider", + "/silos/hidden/saml_identity_providers", ¶ms::SiloSamlIdentityProviderCreate { identity: IdentityMetadataCreateParams { name: "some-totally-real-saml-provider" @@ -616,7 +616,7 @@ async fn test_saml_idp_metadata_url_404(cptestctx: &ControlPlaneTestContext) { RequestBuilder::new( client, Method::POST, - &format!("/silos/{}/saml_identity_provider", SILO_NAME), + &format!("/silos/{}/saml_identity_providers", SILO_NAME), ) .body(Some(¶ms::SiloSamlIdentityProviderCreate { identity: IdentityMetadataCreateParams { @@ -671,7 +671,7 @@ async fn test_saml_idp_metadata_url_invalid( RequestBuilder::new( client, Method::POST, - &format!("/silos/{}/saml_identity_provider", SILO_NAME), + &format!("/silos/{}/saml_identity_providers", SILO_NAME), ) .body(Some(¶ms::SiloSamlIdentityProviderCreate { identity: IdentityMetadataCreateParams { diff --git a/nexus/tests/integration_tests/unauthorized.rs b/nexus/tests/integration_tests/unauthorized.rs index 7e0e677ad6f..6be5841b25a 100644 --- a/nexus/tests/integration_tests/unauthorized.rs +++ b/nexus/tests/integration_tests/unauthorized.rs @@ -206,7 +206,7 @@ lazy_static! { }, // Create a SAML identity provider SetupReq { - url: "/silos/default-silo/saml_identity_provider", + url: "/silos/default-silo/saml_identity_providers", body: serde_json::to_value(&*SILO_SAML_IDENTITY_PROVIDER).unwrap(), }, ]; diff --git a/nexus/tests/output/nexus_tags.txt b/nexus/tests/output/nexus_tags.txt index 1c8a3d2dfa1..ca4729ac2c0 100644 --- a/nexus/tests/output/nexus_tags.txt +++ b/nexus/tests/output/nexus_tags.txt @@ -108,8 +108,8 @@ sagas_get_saga /sagas/{saga_id} API operations found with tag "silos" OPERATION ID URL PATH -silo_saml_idp_create /silos/{silo_name}/saml_identity_provider -silo_saml_idp_fetch /silos/{silo_name}/saml_identity_provider/{provider_name} +silo_saml_idp_create /silos/{silo_name}/saml_identity_providers +silo_saml_idp_fetch /silos/{silo_name}/saml_identity_providers/{provider_name} silos_delete_silo /silos/{silo_name} silos_get /silos silos_get_silo /silos/{silo_name} diff --git a/openapi/nexus.json b/openapi/nexus.json index 3bd9d5aac91..cae6b1ea9a0 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -4889,7 +4889,7 @@ } } }, - "/silos/{silo_name}/saml_identity_provider": { + "/silos/{silo_name}/saml_identity_providers": { "post": { "tags": [ "silos" @@ -4938,7 +4938,7 @@ } } }, - "/silos/{silo_name}/saml_identity_provider/{provider_name}": { + "/silos/{silo_name}/saml_identity_providers/{provider_name}": { "get": { "tags": [ "silos" From 5f352d058009f38d17398a120cc40952d3bff16e Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Mon, 9 May 2022 17:11:50 -0400 Subject: [PATCH 41/54] add view for SiloSamlIdentityProvider add DerEncodedKeyPair type to add a bit of API safety remove get prefix from functions validate DER keys --- nexus/Cargo.toml | 1 + nexus/src/authn/silos.rs | 29 +++++++---- nexus/src/external_api/http_entrypoints.rs | 6 +-- nexus/src/external_api/params.rs | 17 ++++--- nexus/src/external_api/views.rs | 50 +++++++++++++++++++ nexus/src/nexus.rs | 4 +- nexus/tests/integration_tests/endpoints.rs | 3 +- nexus/tests/integration_tests/silos.rs | 21 +++----- openapi/nexus.json | 58 ++++++++++++++-------- 9 files changed, 131 insertions(+), 58 deletions(-) diff --git a/nexus/Cargo.toml b/nexus/Cargo.toml index 7212cc342cb..08ecf563fa6 100644 --- a/nexus/Cargo.toml +++ b/nexus/Cargo.toml @@ -31,6 +31,7 @@ macaddr = { version = "1.0.1", features = [ "serde_std" ]} mime_guess = "2.0.4" newtype_derive = "0.1.6" num-integer = "0.1.44" +# must match samael's crate! openssl = "0.10" openssl-sys = "0.9" openssl-probe = "0.1.2" diff --git a/nexus/src/authn/silos.rs b/nexus/src/authn/silos.rs index ef00f212671..53f9a0c6a7e 100644 --- a/nexus/src/authn/silos.rs +++ b/nexus/src/authn/silos.rs @@ -30,19 +30,30 @@ impl SiloSamlIdentityProvider { 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() + if self.public_cert_bytes()?.is_some() + && self.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() + if self.public_cert_bytes()?.is_none() + && self.private_key_bytes()?.is_some() { bail!("public and private key must be supplied together"); } - // TODO if supplied, validate that the cert and key pair of [u8] is - // actually DER formatted X509 keys + // If supplied, validate that the cert and key pair of [u8] is actually + // DER formatted X509 keys + if let Some(public_cert) = self.public_cert_bytes()? { + if openssl::x509::X509::from_der(&public_cert).is_err() { + bail!("public certificate must be DER formatted X509"); + } + } + + if let Some(private_key) = self.private_key_bytes()? { + if openssl::pkey::PKey::private_key_from_der(&private_key).is_err() { + bail!("private key must be DER formatted"); + } + } Ok(()) } @@ -90,7 +101,7 @@ impl SiloSamlIdentityProvider { }; let authn_request_url = - if let Some(key) = self.get_private_key_bytes()? { + if let Some(key) = self.private_key_bytes()? { // sign authn request if keys were supplied authn_request.signed_redirect(&encoded_relay_state, &key) } else { @@ -137,7 +148,7 @@ impl SiloSamlIdentityProvider { Ok(sp_builder.build()?) } - fn get_public_cert_bytes(&self) -> Result>> { + fn public_cert_bytes(&self) -> Result>> { if let Some(cert) = &self.public_cert { Ok(Some(base64::decode(cert.as_bytes())?)) } else { @@ -145,7 +156,7 @@ impl SiloSamlIdentityProvider { } } - fn get_private_key_bytes(&self) -> Result>> { + fn private_key_bytes(&self) -> Result>> { if let Some(key) = &self.private_key { Ok(Some(base64::decode(key.as_bytes())?)) } else { diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index bcaaaa88589..cc09f74b92c 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -6,6 +6,7 @@ use super::{ console_api, params, + views, views::{ GlobalImage, Image, Organization, Project, Rack, Role, Silo, Sled, Snapshot, SshKey, User, Vpc, VpcRouter, VpcSubnet, @@ -55,7 +56,6 @@ 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 omicron_common::{ @@ -371,7 +371,7 @@ async fn silo_saml_idp_create( rqctx: Arc>>, path_params: Path, new_provider: TypedBody, -) -> Result, HttpError> { +) -> Result, HttpError> { let apictx = rqctx.context(); let nexus = &apictx.nexus; @@ -407,7 +407,7 @@ struct SiloSamlPathParam { async fn silo_saml_idp_fetch( rqctx: Arc>>, path_params: Path, -) -> Result, HttpError> { +) -> Result, HttpError> { let apictx = rqctx.context(); let nexus = &apictx.nexus; diff --git a/nexus/src/external_api/params.rs b/nexus/src/external_api/params.rs index ba394fb307b..cc18866a8f2 100644 --- a/nexus/src/external_api/params.rs +++ b/nexus/src/external_api/params.rs @@ -27,6 +27,15 @@ pub struct SiloCreate { // Silo identity providers +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct DerEncodedKeyPair { + /// request signing public certificate (base64 encoded der file) + pub public_cert: String, + + /// request signing private key (base64 encoded der file) + pub private_key: String, +} + #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] pub struct SiloSamlIdentityProviderCreate { #[serde(flatten)] @@ -50,12 +59,8 @@ pub struct SiloSamlIdentityProviderCreate { /// customer's technical contact for saml configuration pub technical_contact_email: String, - /// 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, + /// optional request signing key pair + pub signing_keypair: Option, } // ORGANIZATIONS diff --git a/nexus/src/external_api/views.rs b/nexus/src/external_api/views.rs index 3c2e1914c16..27ffda5c7c5 100644 --- a/nexus/src/external_api/views.rs +++ b/nexus/src/external_api/views.rs @@ -36,6 +36,56 @@ impl Into for model::Silo { } } +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct DerEncodedKeyPair { + /// request signing public certificate (base64 encoded der file) + pub public_cert: String, +} + +#[derive(ObjectIdentity, Clone, Debug, Deserialize, Serialize, JsonSchema)] +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 + pub signing_keypair: Option, +} + +impl From for SiloSamlIdentityProvider { + fn from(saml_idp: model::SiloSamlIdentityProvider) -> Self { + Self { + identity: saml_idp.identity(), + idp_metadata_url: saml_idp.idp_metadata_url, + idp_entity_id: saml_idp.idp_entity_id, + sp_client_id: saml_idp.sp_client_id, + acs_url: saml_idp.acs_url, + slo_url: saml_idp.slo_url, + technical_contact_email: saml_idp.technical_contact_email, + signing_keypair: saml_idp.public_cert.map(|x| DerEncodedKeyPair { + public_cert: x.clone(), + }), + } + } +} + // ORGANIZATIONS /// Client view of an [`Organization`] diff --git a/nexus/src/nexus.rs b/nexus/src/nexus.rs index 007ce268ec9..64f5abd4c64 100644 --- a/nexus/src/nexus.rs +++ b/nexus/src/nexus.rs @@ -3896,8 +3896,8 @@ impl Nexus { 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, + public_cert: params.signing_keypair.as_ref().map(|x| x.public_cert.clone()), + private_key: params.signing_keypair.as_ref().map(|x| x.private_key.clone()), }; provider diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index 4a22aaf0d22..563ea7d7f94 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -265,8 +265,7 @@ lazy_static! { slo_url: "http://slo".to_string(), technical_contact_email: "technical@fake".to_string(), - public_cert: None, - private_key: None, + signing_keypair: None, }; } diff --git a/nexus/tests/integration_tests/silos.rs b/nexus/tests/integration_tests/silos.rs index 224aa9aef5d..01f26516af9 100644 --- a/nexus/tests/integration_tests/silos.rs +++ b/nexus/tests/integration_tests/silos.rs @@ -231,8 +231,7 @@ async fn test_create_a_saml_idp(cptestctx: &ControlPlaneTestContext) { slo_url: "http://slo".to_string(), technical_contact_email: "technical@fake".to_string(), - public_cert: None, - private_key: None, + signing_keypair: None, }, ) .await; @@ -356,8 +355,7 @@ async fn test_deleting_a_silo_deletes_the_idp( slo_url: "http://slo".to_string(), technical_contact_email: "technical@fake".to_string(), - public_cert: None, - private_key: None, + signing_keypair: None, }, ) .await; @@ -429,8 +427,7 @@ async fn test_create_a_saml_idp_invalid_descriptor_truncated( slo_url: "http://slo".to_string(), technical_contact_email: "technical@fake".to_string(), - public_cert: None, - private_key: None, + signing_keypair: None, })) .expect_status(Some(StatusCode::BAD_REQUEST)), ) @@ -507,8 +504,7 @@ async fn test_create_a_saml_idp_invalid_descriptor_no_redirect_binding( slo_url: "http://slo".to_string(), technical_contact_email: "technical@fake".to_string(), - public_cert: None, - private_key: None, + signing_keypair: None, })) .expect_status(Some(StatusCode::BAD_REQUEST)), ) @@ -558,8 +554,7 @@ async fn test_create_a_hidden_silo_saml_idp( slo_url: "http://slo".to_string(), technical_contact_email: "technical@fake".to_string(), - public_cert: None, - private_key: None, + signing_keypair: None, }, ) .await; @@ -635,8 +630,7 @@ async fn test_saml_idp_metadata_url_404(cptestctx: &ControlPlaneTestContext) { slo_url: "http://slo".to_string(), technical_contact_email: "technical@fake".to_string(), - public_cert: None, - private_key: None, + signing_keypair: None, })) .expect_status(Some(StatusCode::BAD_REQUEST)), ) @@ -690,8 +684,7 @@ async fn test_saml_idp_metadata_url_invalid( slo_url: "http://slo".to_string(), technical_contact_email: "technical@fake".to_string(), - public_cert: None, - private_key: None, + signing_keypair: None, })) .expect_status(Some(StatusCode::BAD_REQUEST)), ) diff --git a/openapi/nexus.json b/openapi/nexus.json index cae6b1ea9a0..66a05ba64ee 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -5203,6 +5203,23 @@ "HistogramF64" ] }, + "DerEncodedKeyPair": { + "type": "object", + "properties": { + "private_key": { + "description": "request signing private key (base64 encoded der file)", + "type": "string" + }, + "public_cert": { + "description": "request signing public certificate (base64 encoded der file)", + "type": "string" + } + }, + "required": [ + "private_key", + "public_cert" + ] + }, "Digest": { "oneOf": [ { @@ -7378,7 +7395,7 @@ ] }, "SiloSamlIdentityProvider": { - "description": "A SAML configuration specifies both identity provider and service provider details", + "description": "Identity-related metadata that's included in nearly all public API objects", "type": "object", "properties": { "acs_url": { @@ -7395,7 +7412,7 @@ "format": "uuid" }, "idp_entity_id": { - "description": "identity provider's entity id", + "description": "idp's entity id", "type": "string" }, "idp_metadata_url": { @@ -7410,26 +7427,25 @@ } ] }, - "private_key": { - "nullable": true, - "writeOnly": true, - "type": "string" - }, - "public_cert": { + "signing_keypair": { "nullable": true, - "description": "optional request signing key pair (base64 encoded der files)", - "type": "string" + "description": "optional request signing key pair", + "allOf": [ + { + "$ref": "#/components/schemas/DerEncodedKeyPair" + } + ] }, "slo_url": { - "description": "service provider endpoint where the identity provider should send log out requests", + "description": "service provider endpoint where the idp should send log out requests", "type": "string" }, "sp_client_id": { - "description": "service provider's client id", + "description": "sp's client id", "type": "string" }, "technical_contact_email": { - "description": "customer's technical contact for SAML configuration", + "description": "customer's technical contact for saml configuration", "type": "string" }, "time_created": { @@ -7479,16 +7495,14 @@ "name": { "$ref": "#/components/schemas/Name" }, - "private_key": { - "nullable": true, - "writeOnly": true, - "description": "optional request signing private key (base64 encoded der file)", - "type": "string" - }, - "public_cert": { + "signing_keypair": { "nullable": true, - "description": "optional request signing public certificate (base64 encoded der file)", - "type": "string" + "description": "optional request signing key pair", + "allOf": [ + { + "$ref": "#/components/schemas/DerEncodedKeyPair" + } + ] }, "slo_url": { "description": "service provider endpoint where the idp should send log out requests", From fa10a10b2e91626ccb3d0d92c7e231f2a8a4e5a9 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Wed, 11 May 2022 12:21:15 -0400 Subject: [PATCH 42/54] deserialize_with public cert and private keys Use serde's deserialize_with to validate public cert and private keys, causing failure if the DerEncodedKeyPair isn't valid. The visitor used here is a fn from `String -> Result` which allows for early validation, and afterwards any code operating on DerEncodedKeyPair can be sure it contains valid data. Unfortunately, the deserialize_with could not be a fn from `String -> an openssl::type` because the openssl types do not derive JsonSchema. Previously some of this validation was in SiloSamlIdentityProvider's validate, and that has been removed in this commit. --- nexus/src/authn/silos.rs | 26 --- nexus/src/external_api/params.rs | 126 +++++++++++- .../data/rsa-key-1-private.b64 | 1 + .../data/rsa-key-1-public.b64 | 1 + .../data/rsa-key-2-private.b64 | 1 + .../data/rsa-key-2-public.b64 | 1 + nexus/tests/integration_tests/silos.rs | 181 ++++++++++++++++++ 7 files changed, 310 insertions(+), 27 deletions(-) create mode 100644 nexus/tests/integration_tests/data/rsa-key-1-private.b64 create mode 100644 nexus/tests/integration_tests/data/rsa-key-1-public.b64 create mode 100644 nexus/tests/integration_tests/data/rsa-key-2-private.b64 create mode 100644 nexus/tests/integration_tests/data/rsa-key-2-public.b64 diff --git a/nexus/src/authn/silos.rs b/nexus/src/authn/silos.rs index 53f9a0c6a7e..d813eb87be2 100644 --- a/nexus/src/authn/silos.rs +++ b/nexus/src/authn/silos.rs @@ -29,32 +29,6 @@ impl SiloSamlIdentityProvider { // 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.public_cert_bytes()?.is_some() - && self.private_key_bytes()?.is_none() - { - bail!("public and private key must be supplied together"); - } - if self.public_cert_bytes()?.is_none() - && self.private_key_bytes()?.is_some() - { - bail!("public and private key must be supplied together"); - } - - // If supplied, validate that the cert and key pair of [u8] is actually - // DER formatted X509 keys - if let Some(public_cert) = self.public_cert_bytes()? { - if openssl::x509::X509::from_der(&public_cert).is_err() { - bail!("public certificate must be DER formatted X509"); - } - } - - if let Some(private_key) = self.private_key_bytes()? { - if openssl::pkey::PKey::private_key_from_der(&private_key).is_err() { - bail!("private key must be DER formatted"); - } - } - Ok(()) } diff --git a/nexus/src/external_api/params.rs b/nexus/src/external_api/params.rs index cc18866a8f2..2c4f7a33083 100644 --- a/nexus/src/external_api/params.rs +++ b/nexus/src/external_api/params.rs @@ -9,7 +9,7 @@ use omicron_common::api::external::{ InstanceCpuCount, Ipv4Net, Ipv6Net, Name, }; use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +use serde::{de::{self, Visitor}, Deserialize, Deserializer, Serialize}; use std::collections::BTreeMap; use std::net::IpAddr; use uuid::Uuid; @@ -30,12 +30,79 @@ pub struct SiloCreate { #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] pub struct DerEncodedKeyPair { /// request signing public certificate (base64 encoded der file) + #[serde(deserialize_with = "x509_cert_from_base64_encoded_der")] pub public_cert: String, /// request signing private key (base64 encoded der file) + #[serde(deserialize_with = "key_from_base64_encoded_der")] pub private_key: String, } +struct X509CertVisitor; + +impl<'de> Visitor<'de> for X509CertVisitor { + type Value = String; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a DER formatted X509 certificate as a string of base64 encoded bytes") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + let raw_bytes = base64::decode(&value.as_bytes()) + .map_err(|e| de::Error::custom(format!("could not base64 decode public_cert: {}", e)))?; + let _parsed = openssl::x509::X509::from_der(&raw_bytes) + .map_err(|e| de::Error::custom(format!("public_cert is not recognized as a X509 certificate: {}", e)))?; + + Ok(value.to_string()) + } +} + +fn x509_cert_from_base64_encoded_der<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + deserializer.deserialize_str(X509CertVisitor) +} + +struct KeyVisitor; + +impl<'de> Visitor<'de> for KeyVisitor { + type Value = String; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a DER formatted key as a string of base64 encoded bytes") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + let raw_bytes = base64::decode(&value) + .map_err(|e| de::Error::custom(format!("could not base64 decode private_key: {}", e)))?; + + // TODO: samael does not support ECDSA, update to generic PKey type when it does + //let _parsed = openssl::pkey::PKey::private_key_from_der(&raw_bytes) + // .map_err(|e| de::Error::custom(format!("could not base64 decode private_key: {}", e)))?; + + let parsed = openssl::rsa::Rsa::private_key_from_der(&raw_bytes) + .map_err(|e| de::Error::custom(format!("private_key is not recognized as a RSA private key: {}", e)))?; + let _parsed = openssl::pkey::PKey::from_rsa(parsed) + .map_err(|e| de::Error::custom(format!("private_key is not recognized as a RSA private key: {}", e)))?; + + Ok(value.to_string()) + } +} + +fn key_from_base64_encoded_der<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + deserializer.deserialize_str(KeyVisitor) +} + #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] pub struct SiloSamlIdentityProviderCreate { #[serde(flatten)] @@ -60,9 +127,66 @@ pub struct SiloSamlIdentityProviderCreate { pub technical_contact_email: String, /// optional request signing key pair + #[serde(deserialize_with = "validate_key_pair")] pub signing_keypair: Option, } +/// sign some junk data and validate it with the key pair +fn sign_junk_data(key_pair: &DerEncodedKeyPair) -> Result<(), anyhow::Error> { + let private_key = { + let raw_bytes = base64::decode(&key_pair.private_key)?; + // TODO: samael does not support ECDSA, update to generic PKey type when it does + //let parsed = openssl::pkey::PKey::private_key_from_der(&raw_bytes)?; + let parsed = openssl::rsa::Rsa::private_key_from_der(&raw_bytes)?; + let parsed = openssl::pkey::PKey::from_rsa(parsed)?; + parsed + }; + + let public_key = { + let raw_bytes = base64::decode(&key_pair.public_cert)?; + let parsed = openssl::x509::X509::from_der(&raw_bytes)?; + parsed.public_key()? + }; + + let mut signer = openssl::sign::Signer::new( + openssl::hash::MessageDigest::sha256(), &private_key.as_ref(), + )?; + + let some_junk_data = b"this is some junk data"; + + signer.update(some_junk_data)?; + let signature = signer.sign_to_vec()?; + + let mut verifier = openssl::sign::Verifier::new( + openssl::hash::MessageDigest::sha256(), + &public_key, + )?; + + verifier.update(some_junk_data)?; + + if !verifier.verify(&signature)? { + anyhow::bail!("signature validation failed!"); + } + + Ok(()) +} + +fn validate_key_pair<'de, D>(deserializer: D) -> Result, D::Error> +where D: Deserializer<'de> +{ + let v = Option::::deserialize(deserializer)?; + + if let Some(ref key_pair) = v { + if let Err(e) = sign_junk_data(&key_pair) { + return Err(de::Error::custom( + format!("data signed with key not verified with certificate! {}", e) + )); + } + } + + Ok(v) +} + // ORGANIZATIONS /// Create-time parameters for an [`Organization`](crate::external_api::views::Organization) diff --git a/nexus/tests/integration_tests/data/rsa-key-1-private.b64 b/nexus/tests/integration_tests/data/rsa-key-1-private.b64 new file mode 100644 index 00000000000..2cea76b087b --- /dev/null +++ b/nexus/tests/integration_tests/data/rsa-key-1-private.b64 @@ -0,0 +1 @@ +MIIEpAIBAAKCAQEAp6RueynV/RybX8qsfh+DgUtFXIB3hNJaWNzpMkAGXPsmfycz5eBRpKr1kalTVde0HImBHMDH4ye/BXb7+KVUOxJSAzPOlXe5BLUvSBJ3h9zIrIAns9IFd785PJJJsFlEfaH6fb6TGlrfQSmrXCVKb17YIgw7miQNhoZFZG0+qEE6bmSu2zjtppX8/k4d5fCi/b5tdBvSy/GZAnBa6gweQeIH4Akt0jLZrUrpUY3GOLlZ/nVFhYwwaDMh2GMtZow44U2G1YhAkTTzBADk60+FXgrtPdzV2w2hnD5TU3pQAjVZikiRQw8fg8PVKCI8AZ9NFnsaDm1hpG/W7r1fste2dwIDAQABAoIBAQCYU+NH6qXUzk+oZSMDn2MA8wJdoSX4/KK3qFQFIwQlLNi4JUkVEhVdiTKGXtOoZs30OEWneMyobY83Sfx+3MuCuYzn+AU474ag7nm+BXmzbDyz8echkC8DtjAuB8cJhLOlbK+N3sMP6Y5/SXu5yPCv7gB6P59Q2n2nxQ38yP9sJhBmKqEJULDYvtgSz9dwwoymIF9XI1fnB+XnxMYrXpAfna9cJr4vgrB+xV+gtNJmNz+5+8UfDcWhkL9H622cOfFFgFc80ncFjpjHGueDef36uciQIdU6buvY15p3vgwYHC+KXkMGlciNSo1wKYRhk3+nSPYBenZPQz5cCXX1aisxAoGBAMjRtCGwbVBtmAKpkvOTvONwrfPWiSDT9GQqPUOT12GaxNPeCc/mt2sEUFfUlBtGt6P6KeLuGzY+m0veejwkIiPbNTsO8ocdneGSP7NTzD5hKkKcU7wVFKaxJgXB8yhl5wHYpcUUfiR+YJ0Oe2VsaHMxVgpA+veHGs6AiuJbfNQlAoGBANW09PWHGBdOMhXwpyPRjZSt7e5rQXMvkygsO1HNILtjXTWvYXMGwl9xutKHw3rEbmEKDG+NtGpgHDitIN1ZLu5p1MzKIOF5aZfE8tFSCTU0VaSUGHAIubLycJjRwpD8F9V6QhvwsWXlWOymuqvJbegCZFPYAMcv58YD8jiQQ29rAoGAZ2YSKYZ9wnurWTOWxnO7PiA2cOZ1lMGNhEV7ZeApdcgKsEwTIUjaB/Agrhh2adTvmS6lgoK24Cc8LsROi8jPC0dDETWRCqDlOc/jnKH49+VvrPxw4Na521o7CZvjZ1mQqBK0x9TVXlTzyeo6/u3ime09L+plTi3yT4FAAWy5yUECgYEAmbNXRsuN4R0lWrBFlbZebKOXb5WGcjCyVv9Q/qlYtE1nuXfUz6T54Slr44Uva7mhZXuTrBuvuZ48Ter+qxQ8c8579XoeoevvrO9CcJfe9XwZaI/274TnAjPqFY8vr5UQE0KmD3BSNmX4SeQ0d98cg/RMchz1mkzzFnC6IkJnrdcCgYBHx123AEMwDOBp5iiGFyGuNI7+7p8tVLoEQkW8fpZ4B4IDwmu88ccOVMZyacrcmhysVDFby2xlQC1aL9avOESmjyJcKmfgfBkTCQTMhSgrn5rPfkZlPp2TtEkxXWmgCaZz1Rvn6ZoAgqVA/bnhpgUfyTTNlEN76IaXIm1A8YnP/w== \ No newline at end of file diff --git a/nexus/tests/integration_tests/data/rsa-key-1-public.b64 b/nexus/tests/integration_tests/data/rsa-key-1-public.b64 new file mode 100644 index 00000000000..d69091bf6f3 --- /dev/null +++ b/nexus/tests/integration_tests/data/rsa-key-1-public.b64 @@ -0,0 +1 @@ +MIIDXDCCAkSgAwIBAgIUHf/dNzLiRs3w3l0/Wlc/mBfH5/cwDQYJKoZIhvcNAQELBQAwRjELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1TYW4gRnJhbmNpc2NvMRIwEAYDVQQDEwlzYW1sLnRlc3QwHhcNMjIwNTEwMTk0OTAwWhcNMjIwNTEwMTk1MDAwWjBGMQswCQYDVQQGEwJVUzELMAkGA1UECBMCQ0ExFjAUBgNVBAcTDVNhbiBGcmFuY2lzY28xEjAQBgNVBAMTCXNhbWwudGVzdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKekbnsp1f0cm1/KrH4fg4FLRVyAd4TSWljc6TJABlz7Jn8nM+XgUaSq9ZGpU1XXtByJgRzAx+MnvwV2+/ilVDsSUgMzzpV3uQS1L0gSd4fcyKyAJ7PSBXe/OTySSbBZRH2h+n2+kxpa30Epq1wlSm9e2CIMO5okDYaGRWRtPqhBOm5krts47aaV/P5OHeXwov2+bXQb0svxmQJwWuoMHkHiB+AJLdIy2a1K6VGNxji5Wf51RYWMMGgzIdhjLWaMOOFNhtWIQJE08wQA5OtPhV4K7T3c1dsNoZw+U1N6UAI1WYpIkUMPH4PD1SgiPAGfTRZ7Gg5tYaRv1u69X7LXtncCAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFKjukf8rhxLMo3MYYu5sfN1axY8SMA0GCSqGSIb3DQEBCwUAA4IBAQA/p2O/CGHp5EWEd3Yd47a24NLuX6ZZXNS+TlRmP0CSsq/vFKMXUiY9Q3p3GcuyHUux3/wPtZCLxgPcmE/m/3SWzHHKB+yEyqEzDJqHVKUZ8Y5tfXAHdXmEHkALpvLsWBNvde4HGFCZBQBiA4gTsi2qT6VfGM5OSa4HHWX8RURiMxjiE7Hz7KM+ZipJGsXfIKqMeeBx0Ke1Q7X3aM/ugIdBkY+tJd2MtqyPU5yqDJFZvrb0yV6uRdyS4AYIJ0x7pfMQWxz9S1LqQn2Cl2pab+EDiJtsmrjZTBlgG2rJ1p4PDBUbi8dChUjJnigFgwhTS5SI3iUMOWjsA3CXEFnlpgXM \ No newline at end of file diff --git a/nexus/tests/integration_tests/data/rsa-key-2-private.b64 b/nexus/tests/integration_tests/data/rsa-key-2-private.b64 new file mode 100644 index 00000000000..555c3d9ed32 --- /dev/null +++ b/nexus/tests/integration_tests/data/rsa-key-2-private.b64 @@ -0,0 +1 @@ +MIIJJwIBAAKCAgEApYGDHUgwhA3K7X1zjdBdbZbE98nCUjyOw0lbCH6RvTaWQI7kriMJlb6ped5C0Q/RvORM+rbkZEh3eu1x1dncaU9z0CDT7bux65HHf5M127mrDZ51LAMhWI2tv+KraZUNS7aCYv2nLXuF2p7z7z2yEW46CRmhtxiwjvY0FjN2gRgjcHT6eweFLzOo8y3b5YR3zFsoi1wusDVwoJ00WCdu4eELOhldjPP+vWuHbKV2sjRC9LdApEF+PzF325nthBUmrOqh21sOltthdw8wmM6Lf77UZqUKUy5UMPJ3adpZQETP2Ak6ISKJ3aqrDaPuDdOgozs9EZK1D6y7OGwpl1uBt2gEmGAQsQ9lH1e+aVxsWASVdi+5E8GJSCS5YEhigIUWh/VyYrBntVDJDs5gk/5vY+XVQ0lw2acukpbeW6GFWYo3zziBCHm+SdQ/0OhVgRUeEWqjPF1+CGFmdOimUKPp9oYP40v72NPbRa8WB2kVZcVXqxcqG4RQK+VpwcKeWTtI2lnvfKOwofePjNzc1JMrtBV7iflQWB6RD+qgvDceUWX5P3VGdQfcZEr+shBtkcnPOgDsRKTxJ5cCeQXFLL0Z2RI0lB22dqs/1fj/7wfd62JBhJBI8xcthfzgs8CNKLIgj1OtS/2+xiNUo7Zv6l3UQCbxo8dhpzAAb3MHqIJ6QAUCAwEAAQKCAgBwV3HtPWQZLteQzvfRyh6w1YdLfrsVYR+ytSdCo88/NT9WAOh+vy+xYmLdYx3NlMRUSE9sWxq6a2oWmfgMJb50CUdeffn8w8voT+Kv2PfU9rmCHA4C2vkWh8zpk+2wVElbHD5y/SQuPktEc2K3ARTOuhhQtwJLK0olMD941mPZCs57dhvTyO4BdTp4HqfFql4666Ggvui+GPgjPbIbKGEel8gsHq2ekLxYTRX2jHX+TnUocP9Cv2X3dRebi2dqoYTIGNfW8n77rVwCGeBtyL1t79Vy+xIAFlF1jA+8XUb51fuS8+huN2iHe2JydtSOtBi00/AG7qNSSXgnu1ub7rQj98ULk4vsX9NAQcIm/Ds5NOAPDZ0UkDOKeE1kg1sSDPZulbwKTQUuJ3xejcLFFOi6YM9C86NzW9gv34U/EbNiSpPEQ2NMQUflkTP38xeYAV3lTRRLVKgQKEBCTjc8xA42HbGrbsZFMYU/fosYJSz4XTthW2mDrSGDb4CUcS/l5p5p0AG3gs0DTJ7BCrivSkjOIIv45riLJ4uV2ZMFpaMOkO4bBZhBglbNiUqw2JucDD4F2Fcw2GftX06PMjq5QD71dUCsIRJMpkC7f0edzwdtxhexMWG6OOSfbzNpcFO+UjDRIqTkseUcjzI8qTDIuUSdpYm4u9zbOP6ECUqEYYM/PQKCAQEAyQFHNQ643kurP77iw8eBFwxZLXF917GSlQmvpMWa4EcYIGdR4wenuCaH3PSt0tJKz8RbwzySF53t6CHYhsbN4BHeYQN1PiTLuEFcqR7WWXUsOHS/pPsZJZekPGyQozNWMC9ohVT5HQgMQ56ntT9QspNynjnmVaGlnm/fHUNcLFeHGzLhNZr2/mUeLd0NxBXF+aeRnFyFq7P8MOBYKgsPdpCyNRR7j6lLdOrX9UxQFvpTyuBWoJKFF5UXttQ3kz4etQ1IFuRo+PBvqTKJCfa91CYZ2EiSvj+rwoihDr70wM5WbC7ox8WRwDoRjOqr5kEJwdRo2bjRE1DTeiOKxwSJewKCAQEA0snRwp0ZyKE/cYivmL2ToxRB/x6X7//lgsAaha3XqlIRBZHFyHJFikGpt99D9RUvumCwicaNwuNzMvxOThF9IlX3D88cR2DxCukpH8Q3RpdqDllehSXngNEBJeUmSTe0T4xCE+BkjV8AVXlZeWtMwsbmH4cLgi0hqT/3jGsLM1hsve/JcqC8jmFdBnkO0AGBD1hrPENNNtfSGPbu3QNldB1jpsKwRX4iR3wt1IFISGieDlU3JR2RLLQBV+hYhnH8djKKIKsbYkbTlq1a/xw0DhKgsqfgWMBpPAOINrpFPeeSynGNcL336KBr5XrCZPfOVLpxTq/bTzhNODgKHrJkfwKCAQBdT9KWtvbre4VMWnk7Geq7oGflyMH619yMg6qee32ikF6K7Gv/URZzTq/Ty2LGdAl22lkfEYdgn1hKYyv5pWD9nE34C3rqFnrcVruFZ2NqtBKLQueU11ydLwB3bI7YtIRWaivDeecLqyjGW2jPo0z7Gagj/A0Jw7j3DEgvdY3cp+V4ou4ZzI7NGnQgJna1iMYXV8spI2qKg0uYBQ3otqm/CP0x1whlcNoutLb8kSi9AgjULcEJWfufLv+LSIlkOXpX4oqM1gxFRJkRmvwzO/B0BBwLY+V7nGNIM9VQ2yUUPLWyEzTNSNKYwlxTZr3WbmrxKIJkUH/+z47dLJLIQTrxAoIBAAudOSSK+W+3isJbsKku0OKsbBJ9ggukQuYYZZ21/WsSCIQRCx/HRBOhGJPcBmeLmkyfpTqCKS9yztchVcMxbX6l0+4YEEvSiJV8UVrBufX2w840mGOnugC8A18uKBTir9muNbnYpFGxyVfsTsTE577XrLhR/Y1XpUIpFx+yijRzC9LPUn8xYhJKRRDlPK6zVoQc8BOq9acu7xGXEYQ1+rISKHp4wbOihor/yZqq4Ou0b/kEMvyli2k2JdjNIYuO3kU49allJCYfFut3c8sYp7maxyXw4Aij2WiIHUo+qzAFAW6MISn0HaPAqxFC2VEs4j6C41ldkSzlQkP1uoEEfUsCggEAQsTxmDsZGGTVH+SR2uSpjJtzOku1yyVvWLrGoh90EA8oAByfQhyisJhV0k/RxzUX9EdlWkiz56TdXh3JCVQdgyIY0QdtX0WoSzY8Dzkmgl+zk9Q3CwkIVWdEDrUV3oHVk44OEaTkdpxAjGDorf6nLDcZEUwGZlvTTp+QlOFX/VpeVp6sRHpx+8X5INZHthL0FMjM52t9lQ0cQivCmV8P0qbnoSAoq4I7FAYZ91QAlTjlCNzsxKbrhYYOxmT1f2vqUfRddyLWtNze/fJ4rmD8LpRCisPnkhPqYNpJHrQKKq3dNBp9eIUkrKvHUoTSe5iH1xm+Ei0PynUGAIfkLqFUNA== \ No newline at end of file diff --git a/nexus/tests/integration_tests/data/rsa-key-2-public.b64 b/nexus/tests/integration_tests/data/rsa-key-2-public.b64 new file mode 100644 index 00000000000..2179b594a3f --- /dev/null +++ b/nexus/tests/integration_tests/data/rsa-key-2-public.b64 @@ -0,0 +1 @@ +MIIFXDCCA0SgAwIBAgIUVLhqPsB0pkEG1OklqGpKYobV7WQwDQYJKoZIhvcNAQENBQAwRjELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1TYW4gRnJhbmNpc2NvMRIwEAYDVQQDEwlzYW1sLnRlc3QwHhcNMjIwNTExMTQ0NjAwWhcNMjcwNTEwMTQ0NjAwWjBGMQswCQYDVQQGEwJVUzELMAkGA1UECBMCQ0ExFjAUBgNVBAcTDVNhbiBGcmFuY2lzY28xEjAQBgNVBAMTCXNhbWwudGVzdDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAKWBgx1IMIQNyu19c43QXW2WxPfJwlI8jsNJWwh+kb02lkCO5K4jCZW+qXneQtEP0bzkTPq25GRId3rtcdXZ3GlPc9Ag0+27seuRx3+TNdu5qw2edSwDIViNrb/iq2mVDUu2gmL9py17hdqe8+89shFuOgkZobcYsI72NBYzdoEYI3B0+nsHhS8zqPMt2+WEd8xbKItcLrA1cKCdNFgnbuHhCzoZXYzz/r1rh2yldrI0QvS3QKRBfj8xd9uZ7YQVJqzqodtbDpbbYXcPMJjOi3++1GalClMuVDDyd2naWUBEz9gJOiEiid2qqw2j7g3ToKM7PRGStQ+suzhsKZdbgbdoBJhgELEPZR9XvmlcbFgElXYvuRPBiUgkuWBIYoCFFof1cmKwZ7VQyQ7OYJP+b2Pl1UNJcNmnLpKW3luhhVmKN884gQh5vknUP9DoVYEVHhFqozxdfghhZnToplCj6faGD+NL+9jT20WvFgdpFWXFV6sXKhuEUCvlacHCnlk7SNpZ73yjsKH3j4zc3NSTK7QVe4n5UFgekQ/qoLw3HlFl+T91RnUH3GRK/rIQbZHJzzoA7ESk8SeXAnkFxSy9GdkSNJQdtnarP9X4/+8H3etiQYSQSPMXLYX84LPAjSiyII9TrUv9vsYjVKO2b+pd1EAm8aPHYacwAG9zB6iCekAFAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTR+MrnWfeQAPfMauQIbEGxkfZirjANBgkqhkiG9w0BAQ0FAAOCAgEATC0sjis5aQNZBYSS5TDHG/RHHHxuvBgErcpiNwjlh+J/s1fbBRdK8zTuxYulMvKi5PMOtwSKWhcDR1xX7gx+1Ldfh4ss0VO9JJBxKLz3B8y7EybdVMJioZ7eeYGUmpJXNqdtiuRzqUDADIiQRcyLymwyMyXpFG+tW26m5jSUYhsnYMJFYKUQo8wENrrETbQ7oJjEfDjAOQNiCKv4kCjP3ImFcXNFGqItzGvEZGUL7n6IiZvPE/ML2+CVgWTKSq7uoyvMtkHETaGq1uElxxT2Wi/zbIHltx6KOkugUJeeGhiEKztyMOFs1Lw712MYhzz8wG06j7bsZ8gDdiAlizqeSGU65NouSWzv+y7QHbxeWQB9CzC63SDVL3Ky2auB8WkbIUcZTM8N+71WRSAaco/vJW0meZLiOlwz+XWKi6f71MVZW1/8Lhv8goqKxVcALuTXziIg5lPhLaIiwsoMO/n2nyGlkr/lpnWd8Nhj6d/QB250zvj8x3SHEUdCAQws6ZYDohhm1WIcp3MA+OMUYObtGS7BtN+eP+LvFkO8046dUtMJzCPf4HW28rcUhQToK8Gmc3qRvxsRxpUi9ATItLsm1Y/UQ2QHCpWCtOQc58aHw/LERffVU9y/8xf14pKPlwgw3T9dMNNvrh+KrJ+MRJ7UHmu+TTuWFo4/Mbn0Ka3qny8= \ No newline at end of file diff --git a/nexus/tests/integration_tests/silos.rs b/nexus/tests/integration_tests/silos.rs index 01f26516af9..ac17699912b 100644 --- a/nexus/tests/integration_tests/silos.rs +++ b/nexus/tests/integration_tests/silos.rs @@ -693,3 +693,184 @@ async fn test_saml_idp_metadata_url_invalid( .await .expect("unexpected success"); } + +// TODO samael does not support ECDSA yet, add tests when it does +const RSA_KEY_1_PUBLIC: &str = include_str!("data/rsa-key-1-public.b64"); +const RSA_KEY_1_PRIVATE: &str = include_str!("data/rsa-key-1-private.b64"); +const RSA_KEY_2_PUBLIC: &str = include_str!("data/rsa-key-2-public.b64"); +const RSA_KEY_2_PRIVATE: &str = include_str!("data/rsa-key-2-private.b64"); + +#[nexus_test] +async fn test_saml_idp_reject_keypair( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + let saml_idp_descriptor = SAML_IDP_DESCRIPTOR; + + // Spin up a server but expect it never to be accessed + let server = Server::run(); + server.expect( + Expectation::matching(request::method_path("GET", "/descriptor")) + .times(0) + .respond_with(status_code(200).body(saml_idp_descriptor)), + ); + + // TODO when different external-authenticator works with multiple silos + //const SILO_NAME: &str = "saml-silo"; + //let silo: Silo = create_silo(&client, SILO_NAME, true).await; + + const SILO_NAME: &str = "default-silo"; + let silo: Silo = + NexusRequest::object_get(&client, &format!("/silos/{}", SILO_NAME,)) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make request") + .parsed_body() + .unwrap(); + + let test_cases = vec![ + // Reject signing keypair if the certificate or key is not base64 + // encoded + params::DerEncodedKeyPair { + public_cert: "regular string".to_string(), + private_key: RSA_KEY_1_PRIVATE.to_string(), + }, + params::DerEncodedKeyPair { + public_cert: RSA_KEY_1_PUBLIC.to_string(), + private_key: "regular string".to_string(), + }, + + // Reject signing keypair if the certificate or key is base64 encoded + // but not valid + params::DerEncodedKeyPair { + public_cert: base64::encode("not a cert"), + private_key: RSA_KEY_1_PRIVATE.to_string(), + }, + params::DerEncodedKeyPair { + public_cert: RSA_KEY_1_PUBLIC.to_string(), + private_key: base64::encode("not a cert"), + }, + + // Reject signing keypair if cert and key are swapped + params::DerEncodedKeyPair { + public_cert: RSA_KEY_1_PRIVATE.to_string(), + private_key: RSA_KEY_1_PUBLIC.to_string(), + }, + + // Reject signing keypair if the keys do not match + params::DerEncodedKeyPair { + public_cert: RSA_KEY_1_PUBLIC.to_string(), + private_key: RSA_KEY_2_PRIVATE.to_string(), + }, + params::DerEncodedKeyPair { + public_cert: RSA_KEY_2_PUBLIC.to_string(), + private_key: RSA_KEY_1_PRIVATE.to_string(), + }, + ]; + + for test_case in test_cases { + NexusRequest::new( + RequestBuilder::new( + client, + Method::POST, + &format!("/silos/{}/saml_identity_providers", SILO_NAME), + ) + .body(Some(¶ms::SiloSamlIdentityProviderCreate { + identity: IdentityMetadataCreateParams { + name: "some-totally-real-saml-provider" + .to_string() + .parse() + .unwrap(), + description: "a demo provider".to_string(), + }, + + 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(), + + signing_keypair: Some(test_case), + })) + .expect_status(Some(StatusCode::BAD_REQUEST)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("unexpected success"); + } +} + +// Test that a RSA keypair works +#[nexus_test] +async fn test_saml_idp_rsa_keypair_ok( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + let saml_idp_descriptor = SAML_IDP_DESCRIPTOR; + + // Spin up a server but expect it never to be accessed + let server = Server::run(); + server.expect( + Expectation::matching(request::method_path("GET", "/descriptor")) + .times(1) + .respond_with(status_code(200).body(saml_idp_descriptor)), + ); + + // TODO when different external-authenticator works with multiple silos + //const SILO_NAME: &str = "saml-silo"; + //let silo: Silo = create_silo(&client, SILO_NAME, true).await; + + const SILO_NAME: &str = "default-silo"; + let silo: Silo = + NexusRequest::object_get(&client, &format!("/silos/{}", SILO_NAME,)) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make request") + .parsed_body() + .unwrap(); + + NexusRequest::new( + RequestBuilder::new( + client, + Method::POST, + &format!("/silos/{}/saml_identity_providers", SILO_NAME), + ) + .body(Some(¶ms::SiloSamlIdentityProviderCreate { + identity: IdentityMetadataCreateParams { + name: "some-totally-real-saml-provider" + .to_string() + .parse() + .unwrap(), + description: "a demo provider".to_string(), + }, + + 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(), + + signing_keypair: Some( + params::DerEncodedKeyPair { + public_cert: RSA_KEY_1_PUBLIC.to_string(), + private_key: RSA_KEY_1_PRIVATE.to_string(), + } + ), + })) + .expect_status(Some(StatusCode::CREATED)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("unexpected failure"); +} + From 9593b8f50774c75e2166ca817383b90ef2ad8160 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Wed, 11 May 2022 14:03:09 -0400 Subject: [PATCH 43/54] clippy and fmt --- nexus/src/authn/silos.rs | 27 ++++--- nexus/src/external_api/http_entrypoints.rs | 3 +- nexus/src/external_api/params.rs | 84 ++++++++++++++++------ nexus/src/external_api/views.rs | 6 +- nexus/src/nexus.rs | 52 +++++++++----- nexus/tests/integration_tests/endpoints.rs | 2 +- nexus/tests/integration_tests/silos.rs | 70 ++---------------- 7 files changed, 119 insertions(+), 125 deletions(-) diff --git a/nexus/src/authn/silos.rs b/nexus/src/authn/silos.rs index d813eb87be2..dd04125c8a4 100644 --- a/nexus/src/authn/silos.rs +++ b/nexus/src/authn/silos.rs @@ -6,7 +6,7 @@ use crate::db::model::SiloSamlIdentityProvider; -use anyhow::{anyhow, bail, Result}; +use anyhow::{anyhow, Result}; use samael::metadata::ContactPerson; use samael::metadata::ContactType; use samael::metadata::EntityDescriptor; @@ -74,15 +74,14 @@ impl SiloSamlIdentityProvider { "".to_string() }; - let authn_request_url = - if let Some(key) = self.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()))?; + let authn_request_url = if let Some(key) = self.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()) } @@ -111,11 +110,9 @@ impl SiloSamlIdentityProvider { 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)); - } + if let Some(cert) = &self.public_cert_bytes()? { + if let Ok(parsed) = openssl::x509::X509::from_der(&cert) { + sp_builder.certificate(Some(parsed)); } } diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index cc09f74b92c..5916c87d4dc 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -5,8 +5,7 @@ //! Handler functions (entrypoints) for external HTTP APIs use super::{ - console_api, params, - views, + console_api, params, views, views::{ GlobalImage, Image, Organization, Project, Rack, Role, Silo, Sled, Snapshot, SshKey, User, Vpc, VpcRouter, VpcSubnet, diff --git a/nexus/src/external_api/params.rs b/nexus/src/external_api/params.rs index 2c4f7a33083..8ea2a45a4a9 100644 --- a/nexus/src/external_api/params.rs +++ b/nexus/src/external_api/params.rs @@ -9,7 +9,10 @@ use omicron_common::api::external::{ InstanceCpuCount, Ipv4Net, Ipv6Net, Name, }; use schemars::JsonSchema; -use serde::{de::{self, Visitor}, Deserialize, Deserializer, Serialize}; +use serde::{ + de::{self, Visitor}, + Deserialize, Deserializer, Serialize, +}; use std::collections::BTreeMap; use std::net::IpAddr; use uuid::Uuid; @@ -43,7 +46,10 @@ struct X509CertVisitor; impl<'de> Visitor<'de> for X509CertVisitor { type Value = String; - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + fn expecting( + &self, + formatter: &mut std::fmt::Formatter, + ) -> std::fmt::Result { formatter.write_str("a DER formatted X509 certificate as a string of base64 encoded bytes") } @@ -51,16 +57,27 @@ impl<'de> Visitor<'de> for X509CertVisitor { where E: de::Error, { - let raw_bytes = base64::decode(&value.as_bytes()) - .map_err(|e| de::Error::custom(format!("could not base64 decode public_cert: {}", e)))?; - let _parsed = openssl::x509::X509::from_der(&raw_bytes) - .map_err(|e| de::Error::custom(format!("public_cert is not recognized as a X509 certificate: {}", e)))?; + let raw_bytes = base64::decode(&value.as_bytes()).map_err(|e| { + de::Error::custom(format!( + "could not base64 decode public_cert: {}", + e + )) + })?; + let _parsed = + openssl::x509::X509::from_der(&raw_bytes).map_err(|e| { + de::Error::custom(format!( + "public_cert is not recognized as a X509 certificate: {}", + e + )) + })?; Ok(value.to_string()) } } -fn x509_cert_from_base64_encoded_der<'de, D>(deserializer: D) -> Result +fn x509_cert_from_base64_encoded_der<'de, D>( + deserializer: D, +) -> Result where D: Deserializer<'de>, { @@ -72,31 +89,51 @@ struct KeyVisitor; impl<'de> Visitor<'de> for KeyVisitor { type Value = String; - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("a DER formatted key as a string of base64 encoded bytes") + fn expecting( + &self, + formatter: &mut std::fmt::Formatter, + ) -> std::fmt::Result { + formatter.write_str( + "a DER formatted key as a string of base64 encoded bytes", + ) } fn visit_str(self, value: &str) -> Result where E: de::Error, { - let raw_bytes = base64::decode(&value) - .map_err(|e| de::Error::custom(format!("could not base64 decode private_key: {}", e)))?; + let raw_bytes = base64::decode(&value).map_err(|e| { + de::Error::custom(format!( + "could not base64 decode private_key: {}", + e + )) + })?; // TODO: samael does not support ECDSA, update to generic PKey type when it does //let _parsed = openssl::pkey::PKey::private_key_from_der(&raw_bytes) // .map_err(|e| de::Error::custom(format!("could not base64 decode private_key: {}", e)))?; let parsed = openssl::rsa::Rsa::private_key_from_der(&raw_bytes) - .map_err(|e| de::Error::custom(format!("private_key is not recognized as a RSA private key: {}", e)))?; - let _parsed = openssl::pkey::PKey::from_rsa(parsed) - .map_err(|e| de::Error::custom(format!("private_key is not recognized as a RSA private key: {}", e)))?; + .map_err(|e| { + de::Error::custom(format!( + "private_key is not recognized as a RSA private key: {}", + e + )) + })?; + let _parsed = openssl::pkey::PKey::from_rsa(parsed).map_err(|e| { + de::Error::custom(format!( + "private_key is not recognized as a RSA private key: {}", + e + )) + })?; Ok(value.to_string()) } } -fn key_from_base64_encoded_der<'de, D>(deserializer: D) -> Result +fn key_from_base64_encoded_der<'de, D>( + deserializer: D, +) -> Result where D: Deserializer<'de>, { @@ -149,7 +186,8 @@ fn sign_junk_data(key_pair: &DerEncodedKeyPair) -> Result<(), anyhow::Error> { }; let mut signer = openssl::sign::Signer::new( - openssl::hash::MessageDigest::sha256(), &private_key.as_ref(), + openssl::hash::MessageDigest::sha256(), + &private_key.as_ref(), )?; let some_junk_data = b"this is some junk data"; @@ -171,16 +209,20 @@ fn sign_junk_data(key_pair: &DerEncodedKeyPair) -> Result<(), anyhow::Error> { Ok(()) } -fn validate_key_pair<'de, D>(deserializer: D) -> Result, D::Error> -where D: Deserializer<'de> +fn validate_key_pair<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, { let v = Option::::deserialize(deserializer)?; if let Some(ref key_pair) = v { if let Err(e) = sign_junk_data(&key_pair) { - return Err(de::Error::custom( - format!("data signed with key not verified with certificate! {}", e) - )); + return Err(de::Error::custom(format!( + "data signed with key not verified with certificate! {}", + e + ))); } } diff --git a/nexus/src/external_api/views.rs b/nexus/src/external_api/views.rs index 27ffda5c7c5..44a3a88445e 100644 --- a/nexus/src/external_api/views.rs +++ b/nexus/src/external_api/views.rs @@ -79,9 +79,9 @@ impl From for SiloSamlIdentityProvider { acs_url: saml_idp.acs_url, slo_url: saml_idp.slo_url, technical_contact_email: saml_idp.technical_contact_email, - signing_keypair: saml_idp.public_cert.map(|x| DerEncodedKeyPair { - public_cert: x.clone(), - }), + signing_keypair: saml_idp + .public_cert + .map(|x| DerEncodedKeyPair { public_cert: x }), } } } diff --git a/nexus/src/nexus.rs b/nexus/src/nexus.rs index 64f5abd4c64..ce3f4549401 100644 --- a/nexus/src/nexus.rs +++ b/nexus/src/nexus.rs @@ -1152,15 +1152,19 @@ impl Nexus { .connect_timeout(dur) .timeout(dur) .build() - .map_err(|e| Error::internal_error(&format!("failed to build reqwest client: {}", e)))?; + .map_err(|e| { + Error::internal_error(&format!( + "failed to build reqwest client: {}", + e + )) + })?; - let response = - client.head(url).send().await.map_err( - |e| Error::InvalidValue { - label: String::from("url"), - message: format!("error querying url: {}", e), - }, - )?; + let response = client.head(url).send().await.map_err(|e| { + Error::InvalidValue { + label: String::from("url"), + message: format!("error querying url: {}", e), + } + })?; if !response.status().is_success() { return Err(Error::InvalidValue { @@ -3854,15 +3858,20 @@ impl Nexus { .connect_timeout(dur) .timeout(dur) .build() - .map_err(|e| Error::internal_error(&format!("failed to build reqwest client: {}", e)))?; + .map_err(|e| { + Error::internal_error(&format!( + "failed to build reqwest client: {}", + e + )) + })?; let response = - client.get(¶ms.idp_metadata_url).send().await.map_err( - |e| Error::InvalidValue { + client.get(¶ms.idp_metadata_url).send().await.map_err(|e| { + Error::InvalidValue { label: String::from("url"), message: format!("error querying url: {}", e), - }, - )?; + } + })?; if !response.status().is_success() { return Err(Error::InvalidValue { @@ -3874,12 +3883,11 @@ impl Nexus { }); } - let idp_metadata_document_string = response.text().await.map_err(|e| - Error::InvalidValue { + let idp_metadata_document_string = + response.text().await.map_err(|e| Error::InvalidValue { label: String::from("url"), message: format!("error getting text from url: {}", e), - }, - )?; + })?; let provider = db::model::SiloSamlIdentityProvider { identity: db::model::SiloSamlIdentityProviderIdentity::new( @@ -3896,8 +3904,14 @@ impl Nexus { acs_url: params.acs_url, slo_url: params.slo_url, technical_contact_email: params.technical_contact_email, - public_cert: params.signing_keypair.as_ref().map(|x| x.public_cert.clone()), - private_key: params.signing_keypair.as_ref().map(|x| x.private_key.clone()), + public_cert: params + .signing_keypair + .as_ref() + .map(|x| x.public_cert.clone()), + private_key: params + .signing_keypair + .as_ref() + .map(|x| x.private_key.clone()), }; provider diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index 563ea7d7f94..9964d92f388 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -7,6 +7,7 @@ //! This is used for various authz-related tests. //! THERE ARE NO TESTS IN THIS FILE. +use crate::integration_tests::unauthorized::HTTP_SERVER; use http::method::Method; use lazy_static::lazy_static; use nexus_test_utils::RACK_UUID; @@ -28,7 +29,6 @@ use omicron_nexus::external_api::params; use omicron_nexus::external_api::shared; use std::net::IpAddr; use std::net::Ipv4Addr; -use crate::integration_tests::unauthorized::HTTP_SERVER; lazy_static! { pub static ref HARDWARE_RACK_URL: String = diff --git a/nexus/tests/integration_tests/silos.rs b/nexus/tests/integration_tests/silos.rs index ac17699912b..278379a0273 100644 --- a/nexus/tests/integration_tests/silos.rs +++ b/nexus/tests/integration_tests/silos.rs @@ -383,14 +383,6 @@ async fn test_create_a_saml_idp_invalid_descriptor_truncated( //let silo: Silo = create_silo(&client, SILO_NAME, true).await; const SILO_NAME: &str = "default-silo"; - let silo: Silo = - NexusRequest::object_get(&client, &format!("/silos/{}", SILO_NAME,)) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .expect("failed to make request") - .parsed_body() - .unwrap(); let saml_idp_descriptor = { let mut saml_idp_descriptor = SAML_IDP_DESCRIPTOR.to_string(); @@ -449,14 +441,6 @@ async fn test_create_a_saml_idp_invalid_descriptor_no_redirect_binding( //let silo: Silo = create_silo(&client, SILO_NAME, true).await; const SILO_NAME: &str = "default-silo"; - let silo: Silo = - NexusRequest::object_get(&client, &format!("/silos/{}", SILO_NAME,)) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .expect("failed to make request") - .parsed_body() - .unwrap(); let saml_idp_descriptor = { let saml_idp_descriptor = SAML_IDP_DESCRIPTOR.to_string(); @@ -592,14 +576,6 @@ async fn test_saml_idp_metadata_url_404(cptestctx: &ControlPlaneTestContext) { //let silo: Silo = create_silo(&client, SILO_NAME, true).await; const SILO_NAME: &str = "default-silo"; - let silo: Silo = - NexusRequest::object_get(&client, &format!("/silos/{}", SILO_NAME,)) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .expect("failed to make request") - .parsed_body() - .unwrap(); let server = Server::run(); server.expect( @@ -652,14 +628,6 @@ async fn test_saml_idp_metadata_url_invalid( //let silo: Silo = create_silo(&client, SILO_NAME, true).await; const SILO_NAME: &str = "default-silo"; - let silo: Silo = - NexusRequest::object_get(&client, &format!("/silos/{}", SILO_NAME,)) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .expect("failed to make request") - .parsed_body() - .unwrap(); NexusRequest::new( RequestBuilder::new( @@ -701,9 +669,7 @@ const RSA_KEY_2_PUBLIC: &str = include_str!("data/rsa-key-2-public.b64"); const RSA_KEY_2_PRIVATE: &str = include_str!("data/rsa-key-2-private.b64"); #[nexus_test] -async fn test_saml_idp_reject_keypair( - cptestctx: &ControlPlaneTestContext, -) { +async fn test_saml_idp_reject_keypair(cptestctx: &ControlPlaneTestContext) { let client = &cptestctx.external_client; let saml_idp_descriptor = SAML_IDP_DESCRIPTOR; @@ -721,14 +687,6 @@ async fn test_saml_idp_reject_keypair( //let silo: Silo = create_silo(&client, SILO_NAME, true).await; const SILO_NAME: &str = "default-silo"; - let silo: Silo = - NexusRequest::object_get(&client, &format!("/silos/{}", SILO_NAME,)) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .expect("failed to make request") - .parsed_body() - .unwrap(); let test_cases = vec![ // Reject signing keypair if the certificate or key is not base64 @@ -741,7 +699,6 @@ async fn test_saml_idp_reject_keypair( public_cert: RSA_KEY_1_PUBLIC.to_string(), private_key: "regular string".to_string(), }, - // Reject signing keypair if the certificate or key is base64 encoded // but not valid params::DerEncodedKeyPair { @@ -752,13 +709,11 @@ async fn test_saml_idp_reject_keypair( public_cert: RSA_KEY_1_PUBLIC.to_string(), private_key: base64::encode("not a cert"), }, - // Reject signing keypair if cert and key are swapped params::DerEncodedKeyPair { public_cert: RSA_KEY_1_PRIVATE.to_string(), private_key: RSA_KEY_1_PUBLIC.to_string(), }, - // Reject signing keypair if the keys do not match params::DerEncodedKeyPair { public_cert: RSA_KEY_1_PUBLIC.to_string(), @@ -807,9 +762,7 @@ async fn test_saml_idp_reject_keypair( // Test that a RSA keypair works #[nexus_test] -async fn test_saml_idp_rsa_keypair_ok( - cptestctx: &ControlPlaneTestContext, -) { +async fn test_saml_idp_rsa_keypair_ok(cptestctx: &ControlPlaneTestContext) { let client = &cptestctx.external_client; let saml_idp_descriptor = SAML_IDP_DESCRIPTOR; @@ -827,14 +780,6 @@ async fn test_saml_idp_rsa_keypair_ok( //let silo: Silo = create_silo(&client, SILO_NAME, true).await; const SILO_NAME: &str = "default-silo"; - let silo: Silo = - NexusRequest::object_get(&client, &format!("/silos/{}", SILO_NAME,)) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .expect("failed to make request") - .parsed_body() - .unwrap(); NexusRequest::new( RequestBuilder::new( @@ -859,12 +804,10 @@ async fn test_saml_idp_rsa_keypair_ok( slo_url: "http://slo".to_string(), technical_contact_email: "technical@fake".to_string(), - signing_keypair: Some( - params::DerEncodedKeyPair { - public_cert: RSA_KEY_1_PUBLIC.to_string(), - private_key: RSA_KEY_1_PRIVATE.to_string(), - } - ), + signing_keypair: Some(params::DerEncodedKeyPair { + public_cert: RSA_KEY_1_PUBLIC.to_string(), + private_key: RSA_KEY_1_PRIVATE.to_string(), + }), })) .expect_status(Some(StatusCode::CREATED)), ) @@ -873,4 +816,3 @@ async fn test_saml_idp_rsa_keypair_ok( .await .expect("unexpected failure"); } - From 378b2c169b33ab72a7076a158031aede7308cb0c Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Wed, 11 May 2022 14:56:02 -0400 Subject: [PATCH 44/54] Add authn::SiloSamlIdentityProvider Instead of putting an impl for the model::SiloSamlIdentityProvider type into the authn subsystem, make a new authn type, and convert the model type into that. Move the logic that looks up SiloIdentityProviderType from nexus.rs to authn silo subsystem. Move validate function logic into a new TryFrom impl for the authn SiloSamlIdentityProvider type. This way it's part of the conversion and cannot be forgotten about. --- nexus/src/authn/silos.rs | 84 +++++++++++++++++-- nexus/src/external_api/console_api.rs | 28 +++---- nexus/src/nexus.rs | 40 ++------- nexus/tests/integration_tests/silos.rs | 27 +++--- nexus/tests/integration_tests/unauthorized.rs | 3 - 5 files changed, 108 insertions(+), 74 deletions(-) diff --git a/nexus/src/authn/silos.rs b/nexus/src/authn/silos.rs index dd04125c8a4..ffb832340d5 100644 --- a/nexus/src/authn/silos.rs +++ b/nexus/src/authn/silos.rs @@ -4,7 +4,10 @@ //! Silo related authentication types and functions -use crate::db::model::SiloSamlIdentityProvider; +use crate::context::OpContext; +use crate::db::lookup::LookupPath; +use crate::db::{model, DataStore}; +use omicron_common::api::external::LookupResult; use anyhow::{anyhow, Result}; use samael::metadata::ContactPerson; @@ -15,23 +18,86 @@ use samael::metadata::HTTP_REDIRECT_BINDING; use samael::service_provider::ServiceProvider; use samael::service_provider::ServiceProviderBuilder; -pub enum SiloIdentityProviderType { - Saml(SiloSamlIdentityProvider), +pub struct SiloSamlIdentityProvider { + pub idp_metadata_document_string: 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 SiloSamlIdentityProvider { - /// return an error if this SiloSamlIdentityProvider is invalid - pub fn validate(&self) -> Result<()> { +impl TryFrom for SiloSamlIdentityProvider { + type Error = anyhow::Error; + fn try_from( + model: model::SiloSamlIdentityProvider, + ) -> Result { + let provider = SiloSamlIdentityProvider { + idp_metadata_document_string: model.idp_metadata_document_string, + sp_client_id: model.sp_client_id, + acs_url: model.acs_url, + slo_url: model.slo_url, + technical_contact_email: model.technical_contact_email, + public_cert: model.public_cert, + private_key: model.private_key, + }; + // check that the idp metadata document string parses into an EntityDescriptor let _idp_metadata: EntityDescriptor = - self.idp_metadata_document_string.parse()?; + provider.idp_metadata_document_string.parse()?; // check that there is a valid sign in url - let _sign_in_url = self.sign_in_url(None)?; + let _sign_in_url = provider.sign_in_url(None)?; - Ok(()) + Ok(provider) } +} +pub enum SiloIdentityProviderType { + Saml(SiloSamlIdentityProvider), +} + +impl SiloIdentityProviderType { + /// First, look up the provider type, then look in for the specific + /// provider details. + pub async fn lookup( + datastore: &DataStore, + opctx: &OpContext, + silo_name: &model::Name, + provider_name: &model::Name, + ) -> LookupResult { + let (.., silo_identity_provider) = LookupPath::new(opctx, datastore) + .silo_name(silo_name) + .silo_identity_provider_name(provider_name) + .fetch() + .await?; + + match silo_identity_provider.provider_type { + model::SiloIdentityProviderType::Saml => { + let (.., silo_saml_identity_provider) = + LookupPath::new(opctx, datastore) + .silo_name(silo_name) + .silo_saml_identity_provider_name(provider_name) + .fetch() + .await?; + + Ok(SiloIdentityProviderType::Saml( + silo_saml_identity_provider.try_into() + .map_err(|e: anyhow::Error| + // If an error is encountered converting from the + // model to the authn type here, this is a server + // error: it was validated before it went into the + // DB. + omicron_common::api::external::Error::internal_error(&e.to_string()) + )? + )) + } + } + } +} + +impl SiloSamlIdentityProvider { pub fn sign_in_url(&self, relay_state: Option) -> Result { let idp_metadata: EntityDescriptor = self.idp_metadata_document_string.parse()?; diff --git a/nexus/src/external_api/console_api.rs b/nexus/src/external_api/console_api.rs index e9de40b1454..f3a80b53f4b 100644 --- a/nexus/src/external_api/console_api.rs +++ b/nexus/src/external_api/console_api.rs @@ -117,13 +117,13 @@ pub async fn ask_user_to_login_to_provider( // unauthenticated. let opctx = nexus.opctx_external_authn(); - let identity_provider = nexus - .get_silo_identity_provider( - &opctx, - &path_params.silo_name, - &path_params.provider_name, - ) - .await?; + let identity_provider = SiloIdentityProviderType::lookup( + &nexus.datastore(), + &opctx, + &path_params.silo_name, + &path_params.provider_name, + ) + .await?; match identity_provider { SiloIdentityProviderType::Saml(silo_saml_identity_provider) => { @@ -168,13 +168,13 @@ pub async fn consume_credentials_and_authn_user( // unauthenticated. let opctx = nexus.opctx_external_authn(); - let identity_provider = nexus - .get_silo_identity_provider( - &opctx, - &path_params.silo_name, - &path_params.provider_name, - ) - .await?; + let identity_provider = SiloIdentityProviderType::lookup( + &nexus.datastore(), + &opctx, + &path_params.silo_name, + &path_params.provider_name, + ) + .await?; match identity_provider { SiloIdentityProviderType::Saml(_silo_saml_identity_provider) => { diff --git a/nexus/src/nexus.rs b/nexus/src/nexus.rs index ce3f4549401..e5970106ec3 100644 --- a/nexus/src/nexus.rs +++ b/nexus/src/nexus.rs @@ -3914,9 +3914,12 @@ impl Nexus { .map(|x| x.private_key.clone()), }; - provider - .validate() - .map_err(|e| Error::invalid_request(&e.to_string()))?; + let _authn_provider: authn::silos::SiloSamlIdentityProvider = + provider.clone().try_into().map_err(|e: anyhow::Error| + // If an error is encountered converting from the model to the + // authn type here, this is a request error: something about the + // parameters of this request doesn't work. + Error::invalid_request(&e.to_string()))?; self.db_datastore .silo_saml_identity_provider_create(opctx, &authz_silo, provider) @@ -3938,37 +3941,6 @@ impl Nexus { Ok(silo_saml_identity_provider) } - /// First, look up the row in silo_identity_provider to get provider type, - /// then look in a specific table for the provider details. - pub async fn get_silo_identity_provider( - &self, - opctx: &OpContext, - silo_name: &Name, - provider_name: &Name, - ) -> LookupResult { - let (.., silo_identity_provider) = - LookupPath::new(opctx, &self.datastore()) - .silo_name(silo_name) - .silo_identity_provider_name(provider_name) - .fetch() - .await?; - - match silo_identity_provider.provider_type { - db::model::SiloIdentityProviderType::Saml => { - let (.., silo_saml_identity_provider) = - LookupPath::new(opctx, &self.datastore()) - .silo_name(silo_name) - .silo_saml_identity_provider_name(provider_name) - .fetch() - .await?; - - Ok(authn::silos::SiloIdentityProviderType::Saml( - silo_saml_identity_provider, - )) - } - } - } - // SSH public keys pub async fn ssh_keys_list( diff --git a/nexus/tests/integration_tests/silos.rs b/nexus/tests/integration_tests/silos.rs index 278379a0273..96465c5f305 100644 --- a/nexus/tests/integration_tests/silos.rs +++ b/nexus/tests/integration_tests/silos.rs @@ -5,6 +5,7 @@ use nexus_test_utils::http_testing::{AuthnMode, NexusRequest, RequestBuilder}; use omicron_common::api::external::IdentityMetadataCreateParams; use omicron_common::api::external::SiloSamlIdentityProvider; +use omicron_nexus::authn::silos::SiloIdentityProviderType; use omicron_nexus::external_api::params; use omicron_nexus::external_api::views::{Organization, Silo}; use omicron_nexus::TestInterfaces as _; @@ -251,25 +252,23 @@ async fn test_create_a_saml_idp(cptestctx: &ControlPlaneTestContext) { .await .unwrap(); - let retrieved_silo_idp_from_nexus = nexus - .get_silo_identity_provider( - &nexus.opctx_external_authn(), - &omicron_common::api::external::Name::try_from( - SILO_NAME.to_string(), - ) - .unwrap() - .into(), - &omicron_common::api::external::Name::try_from( - "some-totally-real-saml-provider".to_string(), - ) + let retrieved_silo_idp_from_nexus = SiloIdentityProviderType::lookup( + &nexus.datastore(), + &nexus.opctx_external_authn(), + &omicron_common::api::external::Name::try_from(SILO_NAME.to_string()) .unwrap() .into(), + &omicron_common::api::external::Name::try_from( + "some-totally-real-saml-provider".to_string(), ) - .await - .unwrap(); + .unwrap() + .into(), + ) + .await + .unwrap(); match retrieved_silo_idp_from_nexus { - omicron_nexus::authn::silos::SiloIdentityProviderType::Saml(_) => { + SiloIdentityProviderType::Saml(_) => { // ok } } diff --git a/nexus/tests/integration_tests/unauthorized.rs b/nexus/tests/integration_tests/unauthorized.rs index 6be5841b25a..0a277447497 100644 --- a/nexus/tests/integration_tests/unauthorized.rs +++ b/nexus/tests/integration_tests/unauthorized.rs @@ -21,10 +21,7 @@ use nexus_test_utils::http_testing::TestResponse; use nexus_test_utils::resource_helpers::DiskTest; use nexus_test_utils::ControlPlaneTestContext; use nexus_test_utils_macros::nexus_test; -use omicron_common::api::external::IdentityMetadataCreateParams; use omicron_nexus::authn::external::spoof; -use omicron_nexus::db::fixed_data::silo::DEFAULT_SILO; -use omicron_nexus::external_api::params; // This test hits a list Nexus API endpoints using both unauthenticated and // unauthorized requests to make sure we get the expected behavior (generally: From dab03ea9e3510392cb9e59b6864175981ac1a0c5 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Wed, 11 May 2022 17:04:27 -0400 Subject: [PATCH 45/54] Drop Silo prefix on types Ran find */ -type f -exec sed -i -e 's/SiloIdentityProvider/IdentityProvider/g' {} + ; find */ -type f -exec sed -i -e 's/SILO_IDENTITY_PROVIDER/IDENTITY_PROVIDER/g' {} + ; find */ -type f -exec sed -i -e 's/silo_identity_provider/identity_provider/g' {} + ; find */ -type f -exec sed -i -e 's/SiloSamlIdentityProvider/SamlIdentityProvider/g' {} + ; find */ -type f -exec sed -i -e 's/SILO_SAML_IDENTITY_PROVIDER/SAML_IDENTITY_PROVIDER/g' {} + ; find */ -type f -exec sed -i -e 's/silo_saml_identity_provider/saml_identity_provider/g' {} + ; and git mv ./nexus/src/db/model/silo_identity_provider.rs ./nexus/src/db/model/identity_provider.rs --- common/src/api/external/mod.rs | 6 +- common/src/sql/dbinit.sql | 6 +- nexus/src/authn/silos.rs | 32 +-- nexus/src/authz/api_resources.rs | 4 +- nexus/src/authz/omicron.polar | 28 +-- nexus/src/authz/oso_generic.rs | 4 +- nexus/src/db/datastore.rs | 61 +++-- nexus/src/db/lookup.rs | 6 +- ...ntity_provider.rs => identity_provider.rs} | 28 +-- nexus/src/db/model/mod.rs | 4 +- nexus/src/db/schema.rs | 8 +- nexus/src/external_api/console_api.rs | 20 +- nexus/src/external_api/http_entrypoints.rs | 10 +- nexus/src/external_api/params.rs | 2 +- nexus/src/external_api/views.rs | 6 +- nexus/src/nexus.rs | 24 +- nexus/tests/integration_tests/endpoints.rs | 6 +- nexus/tests/integration_tests/silos.rs | 34 +-- nexus/tests/integration_tests/unauthorized.rs | 2 +- openapi/nexus.json | 208 +++++++++--------- 20 files changed, 246 insertions(+), 253 deletions(-) rename nexus/src/db/model/{silo_identity_provider.rs => identity_provider.rs} (71%) diff --git a/common/src/api/external/mod.rs b/common/src/api/external/mod.rs index d59f19b63ad..7fd339b737c 100644 --- a/common/src/api/external/mod.rs +++ b/common/src/api/external/mod.rs @@ -516,8 +516,8 @@ pub enum ResourceType { Fleet, Silo, SiloUser, - SiloIdentityProvider, - SiloSamlIdentityProvider, + IdentityProvider, + SamlIdentityProvider, SshKey, ConsoleSession, GlobalImage, @@ -1748,7 +1748,7 @@ impl std::fmt::Display for Digest { /// A SAML configuration specifies both identity provider and service provider /// details #[derive(Clone, Debug, Serialize, JsonSchema, Deserialize)] -pub struct SiloSamlIdentityProvider { +pub struct SamlIdentityProvider { #[serde(flatten)] pub identity: IdentityMetadata, diff --git a/common/src/sql/dbinit.sql b/common/src/sql/dbinit.sql index 367e8fc08a6..b72f91f4c50 100644 --- a/common/src/sql/dbinit.sql +++ b/common/src/sql/dbinit.sql @@ -227,7 +227,7 @@ CREATE TYPE omicron.public.provider_type AS ENUM ( /* * Silo identity provider list */ -CREATE TABLE omicron.public.silo_identity_provider ( +CREATE TABLE omicron.public.identity_provider ( silo_id UUID NOT NULL, provider_type omicron.public.provider_type NOT NULL, name TEXT NOT NULL, @@ -239,7 +239,7 @@ CREATE TABLE omicron.public.silo_identity_provider ( /* * Silo SAML identity provider */ -CREATE TABLE omicron.public.silo_saml_identity_provider ( +CREATE TABLE omicron.public.saml_identity_provider ( /* Identity metadata */ id UUID PRIMARY KEY, name STRING(128) NOT NULL, @@ -263,7 +263,7 @@ CREATE TABLE omicron.public.silo_saml_identity_provider ( private_key TEXT ); -CREATE INDEX ON omicron.public.silo_saml_identity_provider ( +CREATE INDEX ON omicron.public.saml_identity_provider ( id, silo_id ) WHERE diff --git a/nexus/src/authn/silos.rs b/nexus/src/authn/silos.rs index ffb832340d5..0c347659248 100644 --- a/nexus/src/authn/silos.rs +++ b/nexus/src/authn/silos.rs @@ -18,7 +18,7 @@ use samael::metadata::HTTP_REDIRECT_BINDING; use samael::service_provider::ServiceProvider; use samael::service_provider::ServiceProviderBuilder; -pub struct SiloSamlIdentityProvider { +pub struct SamlIdentityProvider { pub idp_metadata_document_string: String, pub sp_client_id: String, pub acs_url: String, @@ -28,12 +28,12 @@ pub struct SiloSamlIdentityProvider { pub private_key: Option, } -impl TryFrom for SiloSamlIdentityProvider { +impl TryFrom for SamlIdentityProvider { type Error = anyhow::Error; fn try_from( - model: model::SiloSamlIdentityProvider, + model: model::SamlIdentityProvider, ) -> Result { - let provider = SiloSamlIdentityProvider { + let provider = SamlIdentityProvider { idp_metadata_document_string: model.idp_metadata_document_string, sp_client_id: model.sp_client_id, acs_url: model.acs_url, @@ -54,11 +54,11 @@ impl TryFrom for SiloSamlIdentityProvider { } } -pub enum SiloIdentityProviderType { - Saml(SiloSamlIdentityProvider), +pub enum IdentityProviderType { + Saml(SamlIdentityProvider), } -impl SiloIdentityProviderType { +impl IdentityProviderType { /// First, look up the provider type, then look in for the specific /// provider details. pub async fn lookup( @@ -67,23 +67,23 @@ impl SiloIdentityProviderType { silo_name: &model::Name, provider_name: &model::Name, ) -> LookupResult { - let (.., silo_identity_provider) = LookupPath::new(opctx, datastore) + let (.., identity_provider) = LookupPath::new(opctx, datastore) .silo_name(silo_name) - .silo_identity_provider_name(provider_name) + .identity_provider_name(provider_name) .fetch() .await?; - match silo_identity_provider.provider_type { - model::SiloIdentityProviderType::Saml => { - let (.., silo_saml_identity_provider) = + match identity_provider.provider_type { + model::IdentityProviderType::Saml => { + let (.., saml_identity_provider) = LookupPath::new(opctx, datastore) .silo_name(silo_name) - .silo_saml_identity_provider_name(provider_name) + .saml_identity_provider_name(provider_name) .fetch() .await?; - Ok(SiloIdentityProviderType::Saml( - silo_saml_identity_provider.try_into() + Ok(IdentityProviderType::Saml( + saml_identity_provider.try_into() .map_err(|e: anyhow::Error| // If an error is encountered converting from the // model to the authn type here, this is a server @@ -97,7 +97,7 @@ impl SiloIdentityProviderType { } } -impl SiloSamlIdentityProvider { +impl SamlIdentityProvider { pub fn sign_in_url(&self, relay_state: Option) -> Result { let idp_metadata: EntityDescriptor = self.idp_metadata_document_string.parse()?; diff --git a/nexus/src/authz/api_resources.rs b/nexus/src/authz/api_resources.rs index 14b2f613320..cff0da9208e 100644 --- a/nexus/src/authz/api_resources.rs +++ b/nexus/src/authz/api_resources.rs @@ -468,7 +468,7 @@ authz_resource! { } authz_resource! { - name = "SiloIdentityProvider", + name = "IdentityProvider", parent = "Silo", primary_key = Uuid, roles_allowed = false, @@ -476,7 +476,7 @@ authz_resource! { } authz_resource! { - name = "SiloSamlIdentityProvider", + name = "SamlIdentityProvider", parent = "Silo", primary_key = Uuid, roles_allowed = false, diff --git a/nexus/src/authz/omicron.polar b/nexus/src/authz/omicron.polar index bfc89d6b53f..82af749b548 100644 --- a/nexus/src/authz/omicron.polar +++ b/nexus/src/authz/omicron.polar @@ -256,14 +256,14 @@ has_permission(actor: AuthenticatedActor, "modify", session: ConsoleSession) has_permission(actor: AuthenticatedActor, "read", silo: Silo) if has_role(actor, "external-authenticator", silo.fleet); -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); -has_permission(actor: AuthenticatedActor, "read", silo_saml_identity_provider: SiloSamlIdentityProvider) - 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); +has_permission(actor: AuthenticatedActor, "read", identity_provider: IdentityProvider) + if has_role(actor, "external-authenticator", identity_provider.silo.fleet); +has_permission(actor: AuthenticatedActor, "list_children", identity_provider: IdentityProvider) + if has_role(actor, "external-authenticator", identity_provider.silo.fleet); +has_permission(actor: AuthenticatedActor, "read", saml_identity_provider: SamlIdentityProvider) + if has_role(actor, "external-authenticator", saml_identity_provider.silo.fleet); +has_permission(actor: AuthenticatedActor, "list_children", saml_identity_provider: SamlIdentityProvider) + if has_role(actor, "external-authenticator", saml_identity_provider.silo.fleet); resource SiloUser { permissions = [ @@ -292,7 +292,7 @@ resource SshKey { has_relation(user: SiloUser, "silo_user", ssh_key: SshKey) if ssh_key.silo_user = user; -resource SiloIdentityProvider { +resource IdentityProvider { permissions = [ "read", "modify", @@ -308,10 +308,10 @@ resource SiloIdentityProvider { "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_relation(silo: Silo, "parent_silo", identity_provider: IdentityProvider) + if identity_provider.silo = silo; -resource SiloSamlIdentityProvider { +resource SamlIdentityProvider { permissions = [ "read", "modify", @@ -327,6 +327,6 @@ resource SiloSamlIdentityProvider { "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_relation(silo: Silo, "parent_silo", saml_identity_provider: SamlIdentityProvider) + if saml_identity_provider.silo = silo; diff --git a/nexus/src/authz/oso_generic.rs b/nexus/src/authz/oso_generic.rs index f7ecce7aecf..667854aadf9 100644 --- a/nexus/src/authz/oso_generic.rs +++ b/nexus/src/authz/oso_generic.rs @@ -69,8 +69,8 @@ pub fn make_omicron_oso(log: &slog::Logger) -> Result { SshKey::init(), Silo::init(), SiloUser::init(), - SiloIdentityProvider::init(), - SiloSamlIdentityProvider::init(), + IdentityProvider::init(), + SamlIdentityProvider::init(), Sled::init(), UpdateAvailableArtifact::init(), UserBuiltin::init(), diff --git a/nexus/src/db/datastore.rs b/nexus/src/db/datastore.rs index a5d255c30f4..6150dcdecdf 100644 --- a/nexus/src/db/datastore.rs +++ b/nexus/src/db/datastore.rs @@ -2697,9 +2697,9 @@ impl DataStore { 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; + use db::schema::identity_provider::dsl as idp_dsl; - let updated_rows = diesel::delete(idp_dsl::silo_identity_provider) + let updated_rows = diesel::delete(idp_dsl::identity_provider) .filter(idp_dsl::silo_id.eq(id)) .execute_async(self.pool_authorized(opctx).await?) .await @@ -2712,21 +2712,20 @@ impl DataStore { info!(opctx.log, "deleted {} silo IdPs for silo {}", updated_rows, id); - use db::schema::silo_saml_identity_provider::dsl as saml_idp_dsl; + use db::schema::saml_identity_provider::dsl as saml_idp_dsl; - let updated_rows = - diesel::update(saml_idp_dsl::silo_saml_identity_provider) - .filter(saml_idp_dsl::silo_id.eq(id)) - .filter(saml_idp_dsl::time_deleted.is_null()) - .set(saml_idp_dsl::time_deleted.eq(Utc::now())) - .execute_async(self.pool_authorized(opctx).await?) - .await - .map_err(|e| { - public_error_from_diesel_pool( - e, - ErrorHandler::NotFoundByResource(authz_silo), - ) - })?; + let updated_rows = diesel::update(saml_idp_dsl::saml_identity_provider) + .filter(saml_idp_dsl::silo_id.eq(id)) + .filter(saml_idp_dsl::time_deleted.is_null()) + .set(saml_idp_dsl::time_deleted.eq(Utc::now())) + .execute_async(self.pool_authorized(opctx).await?) + .await + .map_err(|e| { + public_error_from_diesel_pool( + e, + ErrorHandler::NotFoundByResource(authz_silo), + ) + })?; info!( opctx.log, @@ -2736,12 +2735,12 @@ impl DataStore { Ok(()) } - pub async fn silo_saml_identity_provider_create( + pub async fn saml_identity_provider_create( &self, opctx: &OpContext, authz_silo: &authz::Silo, - provider: db::model::SiloSamlIdentityProvider, - ) -> CreateResult { + provider: db::model::SamlIdentityProvider, + ) -> CreateResult { opctx.authorize(authz::Action::CreateChild, authz_silo).await?; let name = provider.identity().name.to_string(); @@ -2749,26 +2748,22 @@ impl DataStore { .await? .transaction(move |conn| { // insert silo identity provider record with type Saml - use db::schema::silo_identity_provider::dsl as idp_dsl; - diesel::insert_into(idp_dsl::silo_identity_provider) - .values(db::model::SiloIdentityProvider { + use db::schema::identity_provider::dsl as idp_dsl; + diesel::insert_into(idp_dsl::identity_provider) + .values(db::model::IdentityProvider { silo_id: provider.silo_id, name: provider.name().clone(), - provider_type: - db::model::SiloIdentityProviderType::Saml, + provider_type: db::model::IdentityProviderType::Saml, provider_id: provider.id(), }) .execute(conn)?; // insert silo saml identity provider record - 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)?; + use db::schema::saml_identity_provider::dsl; + let result = diesel::insert_into(dsl::saml_identity_provider) + .values(provider) + .returning(db::model::SamlIdentityProvider::as_returning()) + .get_result(conn)?; Ok(result) }) @@ -2777,7 +2772,7 @@ impl DataStore { public_error_from_diesel_pool( e, ErrorHandler::Conflict( - ResourceType::SiloSamlIdentityProvider, + ResourceType::SamlIdentityProvider, &name, ), ) diff --git a/nexus/src/db/lookup.rs b/nexus/src/db/lookup.rs index ac4b1243448..3d1c2d4f89e 100644 --- a/nexus/src/db/lookup.rs +++ b/nexus/src/db/lookup.rs @@ -397,7 +397,7 @@ impl<'a> Root<'a> { lookup_resource! { name = "Silo", ancestors = [], - children = [ "Organization", "SiloIdentityProvider", "SiloSamlIdentityProvider" ], + children = [ "Organization", "IdentityProvider", "SamlIdentityProvider" ], lookup_by_name = true, soft_deletes = true, primary_key_columns = [ { column_name = "id", rust_type = Uuid } ] @@ -413,7 +413,7 @@ lookup_resource! { } lookup_resource! { - name = "SiloIdentityProvider", + name = "IdentityProvider", ancestors = [ "Silo" ], children = [], lookup_by_name = true, @@ -425,7 +425,7 @@ lookup_resource! { } lookup_resource! { - name = "SiloSamlIdentityProvider", + name = "SamlIdentityProvider", ancestors = [ "Silo" ], children = [], lookup_by_name = true, diff --git a/nexus/src/db/model/silo_identity_provider.rs b/nexus/src/db/model/identity_provider.rs similarity index 71% rename from nexus/src/db/model/silo_identity_provider.rs rename to nexus/src/db/model/identity_provider.rs index feeebdd6cef..be3d99b91c5 100644 --- a/nexus/src/db/model/silo_identity_provider.rs +++ b/nexus/src/db/model/identity_provider.rs @@ -4,7 +4,7 @@ use crate::db::identity::Resource; use crate::db::model::{impl_enum_type, Name}; -use crate::db::schema::{silo_identity_provider, silo_saml_identity_provider}; +use crate::db::schema::{identity_provider, saml_identity_provider}; use db_macros::Resource; use omicron_common::api::external; @@ -15,36 +15,36 @@ use uuid::Uuid; impl_enum_type!( #[derive(SqlType, Debug, QueryId)] #[diesel(postgres_type(name = "provider_type"))] - pub struct SiloIdentityProviderTypeEnum; + pub struct IdentityProviderTypeEnum; #[derive(Copy, Clone, Debug, AsExpression, FromSqlRow, Serialize, Deserialize, PartialEq)] - #[diesel(sql_type = SiloIdentityProviderTypeEnum)] - pub enum SiloIdentityProviderType; + #[diesel(sql_type = IdentityProviderTypeEnum)] + pub enum IdentityProviderType; // Enum values Saml => b"saml" ); #[derive(Queryable, Insertable, Clone, Debug, Selectable)] -#[diesel(table_name = silo_identity_provider)] -pub struct SiloIdentityProvider { +#[diesel(table_name = identity_provider)] +pub struct IdentityProvider { pub silo_id: Uuid, - pub provider_type: SiloIdentityProviderType, + pub provider_type: IdentityProviderType, pub name: Name, pub provider_id: Uuid, } -impl SiloIdentityProvider { +impl IdentityProvider { pub fn id(&self) -> Uuid { self.provider_id } } #[derive(Queryable, Insertable, Clone, Debug, Selectable, Resource)] -#[diesel(table_name = silo_saml_identity_provider)] -pub struct SiloSamlIdentityProvider { +#[diesel(table_name = saml_identity_provider)] +pub struct SamlIdentityProvider { #[diesel(embed)] - pub identity: SiloSamlIdentityProviderIdentity, + pub identity: SamlIdentityProviderIdentity, pub silo_id: Uuid, @@ -60,9 +60,9 @@ pub struct SiloSamlIdentityProvider { pub private_key: Option, } -impl Into for SiloSamlIdentityProvider { - fn into(self) -> external::SiloSamlIdentityProvider { - external::SiloSamlIdentityProvider { +impl Into for SamlIdentityProvider { + fn into(self) -> external::SamlIdentityProvider { + external::SamlIdentityProvider { identity: self.identity(), idp_metadata_url: self.idp_metadata_url.clone(), idp_entity_id: self.idp_entity_id.clone(), diff --git a/nexus/src/db/model/mod.rs b/nexus/src/db/model/mod.rs index df853637928..6b61adafca4 100644 --- a/nexus/src/db/model/mod.rs +++ b/nexus/src/db/model/mod.rs @@ -14,6 +14,7 @@ mod disk; mod disk_state; mod generation; mod global_image; +mod identity_provider; mod image; mod instance; mod instance_cpu_count; @@ -33,7 +34,6 @@ mod region; mod role_assignment; mod role_builtin; mod silo; -mod silo_identity_provider; mod silo_user; mod sled; mod snapshot; @@ -61,6 +61,7 @@ pub use disk::*; pub use disk_state::*; pub use generation::*; pub use global_image::*; +pub use identity_provider::*; pub use image::*; pub use instance::*; pub use instance_cpu_count::*; @@ -79,7 +80,6 @@ pub use region::*; pub use role_assignment::*; pub use role_builtin::*; pub use silo::*; -pub use silo_identity_provider::*; pub use silo_user::*; pub use sled::*; pub use snapshot::*; diff --git a/nexus/src/db/schema.rs b/nexus/src/db/schema.rs index 2ce3e2abc0f..00d0ca8d942 100644 --- a/nexus/src/db/schema.rs +++ b/nexus/src/db/schema.rs @@ -159,16 +159,16 @@ table! { } table! { - silo_identity_provider (silo_id, provider_id) { + identity_provider (silo_id, provider_id) { silo_id -> Uuid, - provider_type -> crate::db::model::SiloIdentityProviderTypeEnum, + provider_type -> crate::db::model::IdentityProviderTypeEnum, name -> Text, provider_id -> Uuid, } } table! { - silo_saml_identity_provider (id) { + saml_identity_provider (id) { id -> Uuid, name -> Text, description -> Text, @@ -500,7 +500,7 @@ allow_tables_to_appear_in_same_query!( saga, saga_node_event, silo, - silo_identity_provider, + 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 f3a80b53f4b..f26a51c1321 100644 --- a/nexus/src/external_api/console_api.rs +++ b/nexus/src/external_api/console_api.rs @@ -16,8 +16,7 @@ use crate::authn::external::{ }, }; use crate::authn::{ - silos::SiloIdentityProviderType, USER_TEST_PRIVILEGED, - USER_TEST_UNPRIVILEGED, + silos::IdentityProviderType, USER_TEST_PRIVILEGED, USER_TEST_UNPRIVILEGED, }; use crate::context::OpContext; use crate::ServerContext; @@ -117,7 +116,7 @@ pub async fn ask_user_to_login_to_provider( // unauthenticated. let opctx = nexus.opctx_external_authn(); - let identity_provider = SiloIdentityProviderType::lookup( + let identity_provider = IdentityProviderType::lookup( &nexus.datastore(), &opctx, &path_params.silo_name, @@ -126,13 +125,12 @@ pub async fn ask_user_to_login_to_provider( .await?; match identity_provider { - SiloIdentityProviderType::Saml(silo_saml_identity_provider) => { + IdentityProviderType::Saml(saml_identity_provider) => { let relay_state = None; - let sign_in_url = silo_saml_identity_provider - .sign_in_url(relay_state) - .map_err(|e| { - HttpError::for_internal_error(e.to_string()) - })?; + let sign_in_url = + saml_identity_provider.sign_in_url(relay_state).map_err( + |e| HttpError::for_internal_error(e.to_string()), + )?; Ok(Response::builder() .status(StatusCode::FOUND) @@ -168,7 +166,7 @@ pub async fn consume_credentials_and_authn_user( // unauthenticated. let opctx = nexus.opctx_external_authn(); - let identity_provider = SiloIdentityProviderType::lookup( + let identity_provider = IdentityProviderType::lookup( &nexus.datastore(), &opctx, &path_params.silo_name, @@ -177,7 +175,7 @@ pub async fn consume_credentials_and_authn_user( .await?; match identity_provider { - SiloIdentityProviderType::Saml(_silo_saml_identity_provider) => { + IdentityProviderType::Saml(_saml_identity_provider) => { todo!() } } diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 5916c87d4dc..2042c56b20c 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -369,15 +369,15 @@ async fn silos_delete_silo( async fn silo_saml_idp_create( rqctx: Arc>>, path_params: Path, - new_provider: TypedBody, -) -> Result, HttpError> { + 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( + .saml_identity_provider_create( &opctx, &path_params.into_inner().silo_name, new_provider.into_inner(), @@ -406,7 +406,7 @@ struct SiloSamlPathParam { async fn silo_saml_idp_fetch( rqctx: Arc>>, path_params: Path, -) -> Result, HttpError> { +) -> Result, HttpError> { let apictx = rqctx.context(); let nexus = &apictx.nexus; @@ -415,7 +415,7 @@ async fn silo_saml_idp_fetch( let handler = async { let opctx = OpContext::for_external_api(&rqctx).await?; let provider = nexus - .silo_saml_identity_provider_fetch( + .saml_identity_provider_fetch( &opctx, &path_params.silo_name, &path_params.provider_name, diff --git a/nexus/src/external_api/params.rs b/nexus/src/external_api/params.rs index 8ea2a45a4a9..f8e120d5757 100644 --- a/nexus/src/external_api/params.rs +++ b/nexus/src/external_api/params.rs @@ -141,7 +141,7 @@ where } #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] -pub struct SiloSamlIdentityProviderCreate { +pub struct SamlIdentityProviderCreate { #[serde(flatten)] pub identity: IdentityMetadataCreateParams, diff --git a/nexus/src/external_api/views.rs b/nexus/src/external_api/views.rs index 44a3a88445e..a6831c702d5 100644 --- a/nexus/src/external_api/views.rs +++ b/nexus/src/external_api/views.rs @@ -43,7 +43,7 @@ pub struct DerEncodedKeyPair { } #[derive(ObjectIdentity, Clone, Debug, Deserialize, Serialize, JsonSchema)] -pub struct SiloSamlIdentityProvider { +pub struct SamlIdentityProvider { #[serde(flatten)] pub identity: IdentityMetadata, @@ -69,8 +69,8 @@ pub struct SiloSamlIdentityProvider { pub signing_keypair: Option, } -impl From for SiloSamlIdentityProvider { - fn from(saml_idp: model::SiloSamlIdentityProvider) -> Self { +impl From for SamlIdentityProvider { + fn from(saml_idp: model::SamlIdentityProvider) -> Self { Self { identity: saml_idp.identity(), idp_metadata_url: saml_idp.idp_metadata_url, diff --git a/nexus/src/nexus.rs b/nexus/src/nexus.rs index e5970106ec3..7c085b94b5a 100644 --- a/nexus/src/nexus.rs +++ b/nexus/src/nexus.rs @@ -3837,12 +3837,12 @@ impl Nexus { // Silo authn identity providers - pub async fn silo_saml_identity_provider_create( + pub async fn saml_identity_provider_create( &self, opctx: &OpContext, silo_name: &Name, - params: params::SiloSamlIdentityProviderCreate, - ) -> CreateResult { + params: params::SamlIdentityProviderCreate, + ) -> CreateResult { let (authz_silo, db_silo) = LookupPath::new(opctx, &self.db_datastore) .silo_name(silo_name) .fetch_for(authz::Action::CreateChild) @@ -3889,8 +3889,8 @@ impl Nexus { message: format!("error getting text from url: {}", e), })?; - let provider = db::model::SiloSamlIdentityProvider { - identity: db::model::SiloSamlIdentityProviderIdentity::new( + let provider = db::model::SamlIdentityProvider { + identity: db::model::SamlIdentityProviderIdentity::new( Uuid::new_v4(), params.identity, ), @@ -3914,7 +3914,7 @@ impl Nexus { .map(|x| x.private_key.clone()), }; - let _authn_provider: authn::silos::SiloSamlIdentityProvider = + let _authn_provider: authn::silos::SamlIdentityProvider = provider.clone().try_into().map_err(|e: anyhow::Error| // If an error is encountered converting from the model to the // authn type here, this is a request error: something about the @@ -3922,23 +3922,23 @@ impl Nexus { Error::invalid_request(&e.to_string()))?; self.db_datastore - .silo_saml_identity_provider_create(opctx, &authz_silo, provider) + .saml_identity_provider_create(opctx, &authz_silo, provider) .await } - pub async fn silo_saml_identity_provider_fetch( + pub async fn saml_identity_provider_fetch( &self, opctx: &OpContext, silo_name: &Name, provider_name: &Name, - ) -> LookupResult { - let (.., silo_saml_identity_provider) = + ) -> LookupResult { + let (.., saml_identity_provider) = LookupPath::new(opctx, &self.datastore()) .silo_name(silo_name) - .silo_saml_identity_provider_name(provider_name) + .saml_identity_provider_name(provider_name) .fetch() .await?; - Ok(silo_saml_identity_provider) + Ok(saml_identity_provider) } // SSH public keys diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index 9964d92f388..6a446b23595 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -250,8 +250,8 @@ lazy_static! { }; // SAML identity provider - pub static ref SILO_SAML_IDENTITY_PROVIDER: params::SiloSamlIdentityProviderCreate = - params::SiloSamlIdentityProviderCreate { + pub static ref SAML_IDENTITY_PROVIDER: params::SamlIdentityProviderCreate = + params::SamlIdentityProviderCreate { identity: IdentityMetadataCreateParams { name: "demo-saml-provider".to_string().parse().unwrap(), description: "a demo provider".to_string(), @@ -915,7 +915,7 @@ lazy_static! { url: "/silos/default-silo/saml_identity_providers", visibility: Visibility::Public, // Users can see their own silo! This includes USER_TEST_UNPRIVILEGED allowed_methods: vec![AllowedMethod::Post( - serde_json::to_value(&*SILO_SAML_IDENTITY_PROVIDER).unwrap(), + serde_json::to_value(&*SAML_IDENTITY_PROVIDER).unwrap(), )], }, VerifyEndpoint { diff --git a/nexus/tests/integration_tests/silos.rs b/nexus/tests/integration_tests/silos.rs index 96465c5f305..a46f4dbefd0 100644 --- a/nexus/tests/integration_tests/silos.rs +++ b/nexus/tests/integration_tests/silos.rs @@ -4,8 +4,8 @@ use nexus_test_utils::http_testing::{AuthnMode, NexusRequest, RequestBuilder}; use omicron_common::api::external::IdentityMetadataCreateParams; -use omicron_common::api::external::SiloSamlIdentityProvider; -use omicron_nexus::authn::silos::SiloIdentityProviderType; +use omicron_common::api::external::SamlIdentityProvider; +use omicron_nexus::authn::silos::IdentityProviderType; use omicron_nexus::external_api::params; use omicron_nexus::external_api::views::{Organization, Silo}; use omicron_nexus::TestInterfaces as _; @@ -212,10 +212,10 @@ async fn test_create_a_saml_idp(cptestctx: &ControlPlaneTestContext) { .respond_with(status_code(200).body(saml_idp_descriptor)), ); - let silo_saml_idp: SiloSamlIdentityProvider = object_create( + let silo_saml_idp: SamlIdentityProvider = object_create( client, &format!("/silos/{}/saml_identity_providers", SILO_NAME), - ¶ms::SiloSamlIdentityProviderCreate { + ¶ms::SamlIdentityProviderCreate { identity: IdentityMetadataCreateParams { name: "some-totally-real-saml-provider" .to_string() @@ -252,7 +252,7 @@ async fn test_create_a_saml_idp(cptestctx: &ControlPlaneTestContext) { .await .unwrap(); - let retrieved_silo_idp_from_nexus = SiloIdentityProviderType::lookup( + let retrieved_silo_idp_from_nexus = IdentityProviderType::lookup( &nexus.datastore(), &nexus.opctx_external_authn(), &omicron_common::api::external::Name::try_from(SILO_NAME.to_string()) @@ -268,13 +268,13 @@ async fn test_create_a_saml_idp(cptestctx: &ControlPlaneTestContext) { .unwrap(); match retrieved_silo_idp_from_nexus { - SiloIdentityProviderType::Saml(_) => { + IdentityProviderType::Saml(_) => { // ok } } // Check that created identity provider exists - let _retrieved_silo_saml_idp: SiloSamlIdentityProvider = + let _retrieved_silo_saml_idp: SamlIdentityProvider = NexusRequest::object_get( &client, &format!( @@ -334,10 +334,10 @@ async fn test_deleting_a_silo_deletes_the_idp( .respond_with(status_code(200).body(saml_idp_descriptor)), ); - let _silo_saml_idp: SiloSamlIdentityProvider = object_create( + let _silo_saml_idp: SamlIdentityProvider = object_create( client, &format!("/silos/{}/saml_identity_providers", SILO_NAME), - ¶ms::SiloSamlIdentityProviderCreate { + ¶ms::SamlIdentityProviderCreate { identity: IdentityMetadataCreateParams { name: "some-totally-real-saml-provider" .to_string() @@ -401,7 +401,7 @@ async fn test_create_a_saml_idp_invalid_descriptor_truncated( Method::POST, &format!("/silos/{}/saml_identity_providers", SILO_NAME), ) - .body(Some(¶ms::SiloSamlIdentityProviderCreate { + .body(Some(¶ms::SamlIdentityProviderCreate { identity: IdentityMetadataCreateParams { name: "some-totally-real-saml-provider" .to_string() @@ -470,7 +470,7 @@ async fn test_create_a_saml_idp_invalid_descriptor_no_redirect_binding( Method::POST, &format!("/silos/{}/saml_identity_providers", SILO_NAME), ) - .body(Some(¶ms::SiloSamlIdentityProviderCreate { + .body(Some(¶ms::SamlIdentityProviderCreate { identity: IdentityMetadataCreateParams { name: "some-totally-real-saml-provider" .to_string() @@ -517,10 +517,10 @@ async fn test_create_a_hidden_silo_saml_idp( .respond_with(status_code(200).body(saml_idp_descriptor)), ); - let silo_saml_idp: SiloSamlIdentityProvider = object_create( + let silo_saml_idp: SamlIdentityProvider = object_create( client, "/silos/hidden/saml_identity_providers", - ¶ms::SiloSamlIdentityProviderCreate { + ¶ms::SamlIdentityProviderCreate { identity: IdentityMetadataCreateParams { name: "some-totally-real-saml-provider" .to_string() @@ -588,7 +588,7 @@ async fn test_saml_idp_metadata_url_404(cptestctx: &ControlPlaneTestContext) { Method::POST, &format!("/silos/{}/saml_identity_providers", SILO_NAME), ) - .body(Some(¶ms::SiloSamlIdentityProviderCreate { + .body(Some(¶ms::SamlIdentityProviderCreate { identity: IdentityMetadataCreateParams { name: "some-totally-real-saml-provider" .to_string() @@ -634,7 +634,7 @@ async fn test_saml_idp_metadata_url_invalid( Method::POST, &format!("/silos/{}/saml_identity_providers", SILO_NAME), ) - .body(Some(¶ms::SiloSamlIdentityProviderCreate { + .body(Some(¶ms::SamlIdentityProviderCreate { identity: IdentityMetadataCreateParams { name: "some-totally-real-saml-provider" .to_string() @@ -731,7 +731,7 @@ async fn test_saml_idp_reject_keypair(cptestctx: &ControlPlaneTestContext) { Method::POST, &format!("/silos/{}/saml_identity_providers", SILO_NAME), ) - .body(Some(¶ms::SiloSamlIdentityProviderCreate { + .body(Some(¶ms::SamlIdentityProviderCreate { identity: IdentityMetadataCreateParams { name: "some-totally-real-saml-provider" .to_string() @@ -786,7 +786,7 @@ async fn test_saml_idp_rsa_keypair_ok(cptestctx: &ControlPlaneTestContext) { Method::POST, &format!("/silos/{}/saml_identity_providers", SILO_NAME), ) - .body(Some(¶ms::SiloSamlIdentityProviderCreate { + .body(Some(¶ms::SamlIdentityProviderCreate { identity: IdentityMetadataCreateParams { name: "some-totally-real-saml-provider" .to_string() diff --git a/nexus/tests/integration_tests/unauthorized.rs b/nexus/tests/integration_tests/unauthorized.rs index 0a277447497..5c05d6d9055 100644 --- a/nexus/tests/integration_tests/unauthorized.rs +++ b/nexus/tests/integration_tests/unauthorized.rs @@ -204,7 +204,7 @@ lazy_static! { // Create a SAML identity provider SetupReq { url: "/silos/default-silo/saml_identity_providers", - body: serde_json::to_value(&*SILO_SAML_IDENTITY_PROVIDER).unwrap(), + body: serde_json::to_value(&*SAML_IDENTITY_PROVIDER).unwrap(), }, ]; } diff --git a/openapi/nexus.json b/openapi/nexus.json index 66a05ba64ee..6ec2c8312b7 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -4912,7 +4912,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SiloSamlIdentityProviderCreate" + "$ref": "#/components/schemas/SamlIdentityProviderCreate" } } }, @@ -4924,7 +4924,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SiloSamlIdentityProvider" + "$ref": "#/components/schemas/SamlIdentityProvider" } } } @@ -4973,7 +4973,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SiloSamlIdentityProvider" + "$ref": "#/components/schemas/SamlIdentityProvider" } } } @@ -7295,106 +7295,7 @@ } ] }, - "SessionUser": { - "description": "Client view of currently authed user.", - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid" - } - }, - "required": [ - "id" - ] - }, - "Silo": { - "description": "Client view of a ['Silo']", - "type": "object", - "properties": { - "description": { - "description": "human-readable free-form text about a resource", - "type": "string" - }, - "discoverable": { - "description": "A silo where discoverable is false can be retrieved only by its id - it will not be part of the \"list all silos\" output.", - "type": "boolean" - }, - "id": { - "description": "unique, immutable, system-controlled identifier for each resource", - "type": "string", - "format": "uuid" - }, - "name": { - "description": "unique, mutable, user-controlled identifier for each resource", - "allOf": [ - { - "$ref": "#/components/schemas/Name" - } - ] - }, - "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": [ - "description", - "discoverable", - "id", - "name", - "time_created", - "time_modified" - ] - }, - "SiloCreate": { - "description": "Create-time parameters for a [`Silo`](crate::external_api::views::Silo)", - "type": "object", - "properties": { - "description": { - "type": "string" - }, - "discoverable": { - "type": "boolean" - }, - "name": { - "$ref": "#/components/schemas/Name" - } - }, - "required": [ - "description", - "discoverable", - "name" - ] - }, - "SiloResultsPage": { - "description": "A single page of results", - "type": "object", - "properties": { - "items": { - "description": "list of items on this page of results", - "type": "array", - "items": { - "$ref": "#/components/schemas/Silo" - } - }, - "next_page": { - "nullable": true, - "description": "token used to fetch the next page of results (if any)", - "type": "string" - } - }, - "required": [ - "items" - ] - }, - "SiloSamlIdentityProvider": { + "SamlIdentityProvider": { "description": "Identity-related metadata that's included in nearly all public API objects", "type": "object", "properties": { @@ -7473,7 +7374,7 @@ "time_modified" ] }, - "SiloSamlIdentityProviderCreate": { + "SamlIdentityProviderCreate": { "description": "Create-time identity-related parameters", "type": "object", "properties": { @@ -7528,6 +7429,105 @@ "technical_contact_email" ] }, + "SessionUser": { + "description": "Client view of currently authed user.", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "id" + ] + }, + "Silo": { + "description": "Client view of a ['Silo']", + "type": "object", + "properties": { + "description": { + "description": "human-readable free-form text about a resource", + "type": "string" + }, + "discoverable": { + "description": "A silo where discoverable is false can be retrieved only by its id - it will not be part of the \"list all silos\" output.", + "type": "boolean" + }, + "id": { + "description": "unique, immutable, system-controlled identifier for each resource", + "type": "string", + "format": "uuid" + }, + "name": { + "description": "unique, mutable, user-controlled identifier for each resource", + "allOf": [ + { + "$ref": "#/components/schemas/Name" + } + ] + }, + "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": [ + "description", + "discoverable", + "id", + "name", + "time_created", + "time_modified" + ] + }, + "SiloCreate": { + "description": "Create-time parameters for a [`Silo`](crate::external_api::views::Silo)", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "discoverable": { + "type": "boolean" + }, + "name": { + "$ref": "#/components/schemas/Name" + } + }, + "required": [ + "description", + "discoverable", + "name" + ] + }, + "SiloResultsPage": { + "description": "A single page of results", + "type": "object", + "properties": { + "items": { + "description": "list of items on this page of results", + "type": "array", + "items": { + "$ref": "#/components/schemas/Silo" + } + }, + "next_page": { + "nullable": true, + "description": "token used to fetch the next page of results (if any)", + "type": "string" + } + }, + "required": [ + "items" + ] + }, "Sled": { "description": "Client view of an [`Sled`]", "type": "object", From 46de770ddcd0d9d0f0c7bc034fb4438b5fb07664 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Fri, 13 May 2022 16:32:07 -0400 Subject: [PATCH 46/54] add error context with try_into fails --- nexus/src/authn/silos.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/nexus/src/authn/silos.rs b/nexus/src/authn/silos.rs index 0c347659248..8dc358fc6d4 100644 --- a/nexus/src/authn/silos.rs +++ b/nexus/src/authn/silos.rs @@ -89,7 +89,12 @@ impl IdentityProviderType { // model to the authn type here, this is a server // error: it was validated before it went into the // DB. - omicron_common::api::external::Error::internal_error(&e.to_string()) + omicron_common::api::external::Error::internal_error( + &format!( + "saml_identity_provider.try_into() failed! {}", + &e.to_string() + ) + ) )? )) } From 6c42a7c2707e94fd7489e5ba0c742768e8ea50ba Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Mon, 16 May 2022 16:14:15 -0400 Subject: [PATCH 47/54] typo --- nexus/tests/integration_tests/unauthorized.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nexus/tests/integration_tests/unauthorized.rs b/nexus/tests/integration_tests/unauthorized.rs index 5c05d6d9055..95dd0d1fd63 100644 --- a/nexus/tests/integration_tests/unauthorized.rs +++ b/nexus/tests/integration_tests/unauthorized.rs @@ -101,7 +101,7 @@ EXAMPLE: 0 3111 5555 3111 5555 5555 0 /organizations The number in each cell is the last digit of the 400-level response that was expected for this test case. - In this case, an unauthenthicated request to "GET /organizations" returned + In this case, an unauthenticated request to "GET /organizations" returned 401. All requests to "PUT /organizations" returned 405. G GET PUT POST DEL TRCE G URL From 33937992f2651ff64883c95c3fe99416b075a64d Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Wed, 18 May 2022 09:04:19 -0400 Subject: [PATCH 48/54] add regular identity to identity provider --- common/src/sql/dbinit.sql | 26 ++++++++++++++++++++----- nexus/src/db/datastore.rs | 16 +++++++++++---- nexus/src/db/lookup.rs | 4 ++-- nexus/src/db/model/identity_provider.rs | 16 ++++++--------- nexus/src/db/schema.rs | 11 ++++++++--- 5 files changed, 49 insertions(+), 24 deletions(-) diff --git a/common/src/sql/dbinit.sql b/common/src/sql/dbinit.sql index b72f91f4c50..f668dd2e069 100644 --- a/common/src/sql/dbinit.sql +++ b/common/src/sql/dbinit.sql @@ -228,14 +228,30 @@ CREATE TYPE omicron.public.provider_type AS ENUM ( * Silo identity provider list */ CREATE TABLE omicron.public.identity_provider ( - silo_id UUID NOT NULL, - provider_type omicron.public.provider_type NOT NULL, - name TEXT NOT NULL, - provider_id UUID NOT NULL, + /* 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, - PRIMARY KEY (silo_id, provider_id) + silo_id UUID NOT NULL, + provider_type omicron.public.provider_type NOT NULL ); +CREATE INDEX ON omicron.public.identity_provider ( + id, + silo_id +) WHERE + time_deleted IS NULL; + +CREATE INDEX ON omicron.public.identity_provider ( + name, + silo_id +) WHERE + time_deleted IS NULL; + /* * Silo SAML identity provider */ diff --git a/nexus/src/db/datastore.rs b/nexus/src/db/datastore.rs index 6150dcdecdf..9402d123141 100644 --- a/nexus/src/db/datastore.rs +++ b/nexus/src/db/datastore.rs @@ -41,7 +41,7 @@ use crate::db::{ InstanceRuntimeState, Name, NetworkInterface, Organization, OrganizationUpdate, OximeterInfo, ProducerEndpoint, Project, ProjectUpdate, Region, RoleAssignment, RoleBuiltin, RouterRoute, - RouterRouteUpdate, Silo, SiloUser, Sled, SshKey, + RouterRouteUpdate, Silo, IdentityProvider, SiloUser, Sled, SshKey, UpdateAvailableArtifact, UserBuiltin, Volume, Vpc, VpcFirewallRule, VpcRouter, VpcRouterUpdate, VpcSubnet, VpcSubnetUpdate, VpcUpdate, Zpool, @@ -2699,8 +2699,10 @@ impl DataStore { // delete all silo identity providers use db::schema::identity_provider::dsl as idp_dsl; - let updated_rows = diesel::delete(idp_dsl::identity_provider) + let updated_rows = diesel::update(idp_dsl::identity_provider) .filter(idp_dsl::silo_id.eq(id)) + .filter(idp_dsl::time_deleted.is_null()) + .set(idp_dsl::time_deleted.eq(Utc::now())) .execute_async(self.pool_authorized(opctx).await?) .await .map_err(|e| { @@ -2751,10 +2753,16 @@ impl DataStore { use db::schema::identity_provider::dsl as idp_dsl; diesel::insert_into(idp_dsl::identity_provider) .values(db::model::IdentityProvider { + identity: db::model::IdentityProviderIdentity { + id: provider.identity.id, + name: provider.identity.name.clone(), + description: provider.identity.description.clone(), + time_created: provider.identity.time_created, + time_modified: provider.identity.time_modified, + time_deleted: provider.identity.time_deleted, + }, silo_id: provider.silo_id, - name: provider.name().clone(), provider_type: db::model::IdentityProviderType::Saml, - provider_id: provider.id(), }) .execute(conn)?; diff --git a/nexus/src/db/lookup.rs b/nexus/src/db/lookup.rs index 3d1c2d4f89e..34360826c81 100644 --- a/nexus/src/db/lookup.rs +++ b/nexus/src/db/lookup.rs @@ -417,10 +417,10 @@ lookup_resource! { ancestors = [ "Silo" ], children = [], lookup_by_name = true, - soft_deletes = false, + soft_deletes = true, primary_key_columns = [ { column_name = "silo_id", rust_type = Uuid }, - { column_name = "provider_id", rust_type = Uuid } + { column_name = "id", rust_type = Uuid } ] } diff --git a/nexus/src/db/model/identity_provider.rs b/nexus/src/db/model/identity_provider.rs index be3d99b91c5..ad126f7632a 100644 --- a/nexus/src/db/model/identity_provider.rs +++ b/nexus/src/db/model/identity_provider.rs @@ -3,7 +3,7 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. use crate::db::identity::Resource; -use crate::db::model::{impl_enum_type, Name}; +use crate::db::model::impl_enum_type; use crate::db::schema::{identity_provider, saml_identity_provider}; use db_macros::Resource; use omicron_common::api::external; @@ -25,19 +25,15 @@ impl_enum_type!( Saml => b"saml" ); -#[derive(Queryable, Insertable, Clone, Debug, Selectable)] +#[derive(Queryable, Insertable, Clone, Debug, Selectable, Resource)] #[diesel(table_name = identity_provider)] pub struct IdentityProvider { + // Note identity here matches the specific identity provider configuration + #[diesel(embed)] + pub identity: IdentityProviderIdentity, + pub silo_id: Uuid, pub provider_type: IdentityProviderType, - pub name: Name, - pub provider_id: Uuid, -} - -impl IdentityProvider { - pub fn id(&self) -> Uuid { - self.provider_id - } } #[derive(Queryable, Insertable, Clone, Debug, Selectable, Resource)] diff --git a/nexus/src/db/schema.rs b/nexus/src/db/schema.rs index 00d0ca8d942..7d4825ab854 100644 --- a/nexus/src/db/schema.rs +++ b/nexus/src/db/schema.rs @@ -159,11 +159,16 @@ table! { } table! { - identity_provider (silo_id, provider_id) { + identity_provider (silo_id, id) { + id -> Uuid, + name -> Text, + description -> Text, + time_created -> Timestamptz, + time_modified -> Timestamptz, + time_deleted -> Nullable, + silo_id -> Uuid, provider_type -> crate::db::model::IdentityProviderTypeEnum, - name -> Text, - provider_id -> Uuid, } } From 2f45e1a3e132cc5a2ba7fbeab90a3dacfde43c23 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Wed, 18 May 2022 09:04:46 -0400 Subject: [PATCH 49/54] add support for listing identity providers --- nexus/src/db/datastore.rs | 18 +++ nexus/src/external_api/http_entrypoints.rs | 34 ++++- nexus/src/external_api/views.rs | 36 +++++ nexus/src/nexus.rs | 15 +++ nexus/tests/integration_tests/endpoints.rs | 9 ++ nexus/tests/integration_tests/silos.rs | 90 ++++++++++++- nexus/tests/output/nexus_tags.txt | 1 + openapi/nexus.json | 146 +++++++++++++++++++++ 8 files changed, 347 insertions(+), 2 deletions(-) diff --git a/nexus/src/db/datastore.rs b/nexus/src/db/datastore.rs index 9402d123141..a6ea8da6317 100644 --- a/nexus/src/db/datastore.rs +++ b/nexus/src/db/datastore.rs @@ -2737,6 +2737,24 @@ impl DataStore { Ok(()) } + pub async fn identity_provider_list( + &self, + opctx: &OpContext, + authz_silo: &authz::Silo, + pagparams: &DataPageParams<'_, Name>, + ) -> ListResultVec { + opctx.authorize(authz::Action::ListChildren, authz_silo).await?; + + use db::schema::identity_provider::dsl; + paginated(dsl::identity_provider, dsl::name, pagparams) + .filter(dsl::silo_id.eq(authz_silo.id())) + .filter(dsl::time_deleted.is_null()) + .select(IdentityProvider::as_select()) + .load_async::(self.pool_authorized(opctx).await?) + .await + .map_err(|e| public_error_from_diesel_pool(e, ErrorHandler::Server)) + } + pub async fn saml_identity_provider_create( &self, opctx: &OpContext, diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 2042c56b20c..a4c99eac2b2 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -7,7 +7,7 @@ use super::{ console_api, params, views, views::{ - GlobalImage, Image, Organization, Project, Rack, Role, Silo, Sled, + GlobalImage, Image, IdentityProvider, Organization, Project, Rack, Role, Silo, Sled, Snapshot, SshKey, User, Vpc, VpcRouter, VpcSubnet, }, }; @@ -75,6 +75,7 @@ pub fn external_api() -> NexusApiDescription { api.register(silos_get)?; api.register(silos_post)?; api.register(silos_get_silo)?; + api.register(silos_get_identity_providers)?; api.register(silos_delete_silo)?; api.register(silo_saml_idp_create)?; @@ -358,6 +359,37 @@ async fn silos_delete_silo( // Silo identity providers +/// List Silo identity providers +#[endpoint { + method = GET, + path = "/silos/{silo_name}/identity_providers", + tags = ["silos"], +}] +async fn silos_get_identity_providers( + rqctx: Arc>>, + path_params: Path, + query_params: Query, +) -> Result>, HttpError> { + let apictx = rqctx.context(); + let nexus = &apictx.nexus; + let path = path_params.into_inner(); + let silo_name = &path.silo_name; + let handler = async { + let opctx = OpContext::for_external_api(&rqctx).await?; + let query = query_params.into_inner(); + let pagination_params = data_page_params_for(&rqctx, &query)? + .map_name(|n| Name::ref_cast(n)); + let identity_providers = nexus + .identity_provider_list(&opctx, &silo_name, &pagination_params) + .await? + .into_iter() + .map(|x| x.into()) + .collect(); + Ok(HttpResponseOk(ScanByName::results_page(&query, identity_providers)?)) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + // Silo SAML identity providers /// Create a new SAML identity provider for a silo. diff --git a/nexus/src/external_api/views.rs b/nexus/src/external_api/views.rs index a6831c702d5..d9d98e817d6 100644 --- a/nexus/src/external_api/views.rs +++ b/nexus/src/external_api/views.rs @@ -36,6 +36,42 @@ impl Into for model::Silo { } } +// IDENTITY PROVIDER + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum IdentityProviderType { + /// SAML identity provider + Saml, +} + +impl Into for model::IdentityProviderType { + fn into(self) -> IdentityProviderType { + match self { + model::IdentityProviderType::Saml => IdentityProviderType::Saml, + } + } +} + +/// Client view of an ['IdentityProvider'] +#[derive(ObjectIdentity, Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct IdentityProvider { + #[serde(flatten)] + pub identity: IdentityMetadata, + + /// Identity provider type + pub provider_type: IdentityProviderType, +} + +impl Into for model::IdentityProvider { + fn into(self) -> IdentityProvider { + IdentityProvider { + identity: self.identity(), + provider_type: self.provider_type.into(), + } + } +} + #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] pub struct DerEncodedKeyPair { /// request signing public certificate (base64 encoded der file) diff --git a/nexus/src/nexus.rs b/nexus/src/nexus.rs index 7c085b94b5a..2e7cc10f06d 100644 --- a/nexus/src/nexus.rs +++ b/nexus/src/nexus.rs @@ -3835,6 +3835,21 @@ impl Nexus { Ok(db_silo_user) } + // identity providers + + pub async fn identity_provider_list( + &self, + opctx: &OpContext, + silo_name: &Name, + pagparams: &DataPageParams<'_, Name>, + ) -> ListResultVec { + let (.., authz_silo) = LookupPath::new(opctx, &self.db_datastore) + .silo_name(silo_name) + .lookup_for(authz::Action::ListChildren) + .await?; + self.db_datastore.identity_provider_list(opctx, &authz_silo, pagparams).await + } + // Silo authn identity providers pub async fn saml_identity_provider_create( diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index 6a446b23595..895d6e05ce9 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -911,6 +911,14 @@ lazy_static! { /* Silo identity providers */ + VerifyEndpoint { + url: "/silos/default-silo/identity_providers", + visibility: Visibility::Public, // Users can see their own silo! This includes USER_TEST_UNPRIVILEGED + allowed_methods: vec![ + AllowedMethod::Get, + ], + }, + VerifyEndpoint { url: "/silos/default-silo/saml_identity_providers", visibility: Visibility::Public, // Users can see their own silo! This includes USER_TEST_UNPRIVILEGED @@ -918,6 +926,7 @@ lazy_static! { serde_json::to_value(&*SAML_IDENTITY_PROVIDER).unwrap(), )], }, + VerifyEndpoint { url: "/silos/default-silo/saml_identity_providers/demo-saml-provider", diff --git a/nexus/tests/integration_tests/silos.rs b/nexus/tests/integration_tests/silos.rs index a46f4dbefd0..a4132adc293 100644 --- a/nexus/tests/integration_tests/silos.rs +++ b/nexus/tests/integration_tests/silos.rs @@ -7,7 +7,7 @@ use omicron_common::api::external::IdentityMetadataCreateParams; use omicron_common::api::external::SamlIdentityProvider; use omicron_nexus::authn::silos::IdentityProviderType; use omicron_nexus::external_api::params; -use omicron_nexus::external_api::views::{Organization, Silo}; +use omicron_nexus::external_api::views::{Organization, Silo, IdentityProvider}; use omicron_nexus::TestInterfaces as _; use http::method::Method; @@ -144,6 +144,94 @@ async fn test_silos(cptestctx: &ControlPlaneTestContext) { .expect_err("unexpected success"); } +// Test listing providers +#[nexus_test] +async fn test_listing_identity_providers(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + + create_silo(&client, "discoverable", true).await; + + // List providers - should be none + let providers = objects_list_page_authz::( + client, + "/silos/discoverable/identity_providers", + ).await.items; + + assert_eq!(providers.len(), 0); + + /* + + // Add some providers + 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_1: SamlIdentityProvider = object_create( + client, + &"/silos/discoverable/saml_identity_providers", + ¶ms::SamlIdentityProviderCreate { + identity: IdentityMetadataCreateParams { + name: "some-totally-real-saml-provider" + .to_string() + .parse() + .unwrap(), + description: "a demo provider".to_string(), + }, + + 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(), + + signing_keypair: None, + }, + ) + .await; + + let silo_saml_idp_2: SamlIdentityProvider = object_create( + client, + &"/silos/discoverable/saml_identity_providers", + ¶ms::SamlIdentityProviderCreate { + identity: IdentityMetadataCreateParams { + name: "another-totally-real-saml-provider" + .to_string() + .parse() + .unwrap(), + description: "a demo provider".to_string(), + }, + + 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(), + + signing_keypair: None, + }, + ) + .await; + + // List providers again - expect 2 + let providers = objects_list_page_authz::( + client, + "/silos/discoverable/identity_providers", + ).await.items; + + assert_eq!(providers.len(), 2); + assert_eq!(providers[0].name, silo_saml_idp_1.identity.name); + assert_eq!(providers[1].name, silo_saml_idp_2.identity.name); + */ +} + // Valid SAML IdP entity descriptor from https://en.wikipedia.org/wiki/SAML_metadata#Identity_provider_metadata // note: no signing keys pub const SAML_IDP_DESCRIPTOR: &str = r#" diff --git a/nexus/tests/output/nexus_tags.txt b/nexus/tests/output/nexus_tags.txt index ca4729ac2c0..b35a99009b4 100644 --- a/nexus/tests/output/nexus_tags.txt +++ b/nexus/tests/output/nexus_tags.txt @@ -112,6 +112,7 @@ silo_saml_idp_create /silos/{silo_name}/saml_identity_provid silo_saml_idp_fetch /silos/{silo_name}/saml_identity_providers/{provider_name} silos_delete_silo /silos/{silo_name} silos_get /silos +silos_get_identity_providers /silos/{silo_name}/identity_providers silos_get_silo /silos/{silo_name} silos_post /silos diff --git a/openapi/nexus.json b/openapi/nexus.json index 6ec2c8312b7..20e0e0cbb5d 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -4889,6 +4889,76 @@ } } }, + "/silos/{silo_name}/identity_providers": { + "get": { + "tags": [ + "silos" + ], + "summary": "List Silo identity providers", + "operationId": "silos_get_identity_providers", + "parameters": [ + { + "in": "path", + "name": "silo_name", + "description": "The silo's unique name.", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + }, + { + "in": "query", + "name": "limit", + "description": "Maximum number of items returned by a single call", + "schema": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 1 + }, + "style": "form" + }, + { + "in": "query", + "name": "page_token", + "description": "Token returned by previous call to retreive the subsequent page", + "schema": { + "nullable": true, + "type": "string" + }, + "style": "form" + }, + { + "in": "query", + "name": "sort_by", + "schema": { + "$ref": "#/components/schemas/NameSortMode" + }, + "style": "form" + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IdentityProviderResultsPage" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + }, + "x-dropshot-pagination": true + } + }, "/silos/{silo_name}/saml_identity_providers": { "post": { "tags": [ @@ -5744,6 +5814,82 @@ "items" ] }, + "IdentityProvider": { + "description": "Client view of an ['IdentityProvider']", + "type": "object", + "properties": { + "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" + }, + "name": { + "description": "unique, mutable, user-controlled identifier for each resource", + "allOf": [ + { + "$ref": "#/components/schemas/Name" + } + ] + }, + "provider_type": { + "description": "Identity provider type", + "allOf": [ + { + "$ref": "#/components/schemas/IdentityProviderType" + } + ] + }, + "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": [ + "description", + "id", + "name", + "provider_type", + "time_created", + "time_modified" + ] + }, + "IdentityProviderResultsPage": { + "description": "A single page of results", + "type": "object", + "properties": { + "items": { + "description": "list of items on this page of results", + "type": "array", + "items": { + "$ref": "#/components/schemas/IdentityProvider" + } + }, + "next_page": { + "nullable": true, + "description": "token used to fetch the next page of results (if any)", + "type": "string" + } + }, + "required": [ + "items" + ] + }, + "IdentityProviderType": { + "type": "string", + "enum": [ + "saml" + ] + }, "IdentityType": { "description": "Describes what kind of identity is described by an id", "type": "string", From fc0522d560cbd6b790087cdae6b8f60c5519cea9 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Thu, 19 May 2022 15:39:02 -0400 Subject: [PATCH 50/54] larger request_body_max_bytes for SAML IdP payload --- smf/nexus/config.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/smf/nexus/config.toml b/smf/nexus/config.toml index a152d398f02..8bec0c2cd7a 100644 --- a/smf/nexus/config.toml +++ b/smf/nexus/config.toml @@ -23,10 +23,12 @@ url = "postgresql://root@[fd00:1122:3344:0101::2]:32221/omicron?sslmode=disable" [dropshot_external] # IP address and TCP port on which to listen for the external API bind_address = "[fd00:1122:3344:0101::3]:12220" +request_body_max_bytes = 1048576 [dropshot_internal] # IP address and TCP port on which to listen for the internal API bind_address = "[fd00:1122:3344:0101::3]:12221" +request_body_max_bytes = 1048576 [log] # Show log messages of this level and more severe From 302f87865e1a61725e44422da49c1cd29e481a7f Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Thu, 19 May 2022 15:39:50 -0400 Subject: [PATCH 51/54] get by with samael patch and "cargo update -p clang-sys" for now --- Cargo.lock | 6 +++--- Cargo.toml | 3 +++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9e2f6c76f4e..9df4e403f96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -413,9 +413,9 @@ dependencies = [ [[package]] name = "clang-sys" -version = "1.3.1" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cc00842eed744b858222c4c9faf7243aafc6d33f92f96935263ef4d8a41ce21" +checksum = "bf6b561dcf059c85bbe388e0a7b0a1469acb3934cc0cfa148613a830629e3049" dependencies = [ "glob", "libc", @@ -3909,7 +3909,7 @@ checksum = "73b4b750c782965c211b42f022f59af1fbceabdd026623714f104152f1ec149f" [[package]] name = "samael" version = "0.0.8" -source = "git+https://github.com/njaremko/samael?branch=master#9fd1fcb99ab2e4c7f5037550680f311b48417d77" +source = "git+https://github.com/oxidecomputer/samael?branch=dynamic#eba1ffd80f76afbf8159c356be296602f14c71e6" dependencies = [ "base64", "bindgen", diff --git a/Cargo.toml b/Cargo.toml index e30d6ce36bc..cfc9a9144ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -80,6 +80,9 @@ panic = "abort" #[patch."https://github.com/oxidecomputer/crucible"] #crucible = { path = "../crucible/upstairs" } +[patch."https://github.com/njaremko/samael"] +samael = { git = "https://github.com/oxidecomputer/samael", branch = "dynamic" } + # # Local client generation during development. # From 140f697279903d46905e390c2293790b6db4aae8 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Tue, 24 May 2022 16:14:50 -0400 Subject: [PATCH 52/54] uncomment test_listing_identity_providers --- nexus/tests/integration_tests/silos.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/nexus/tests/integration_tests/silos.rs b/nexus/tests/integration_tests/silos.rs index a4132adc293..57031d1d9cc 100644 --- a/nexus/tests/integration_tests/silos.rs +++ b/nexus/tests/integration_tests/silos.rs @@ -2,6 +2,7 @@ // 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/. +use std::collections::HashSet; use nexus_test_utils::http_testing::{AuthnMode, NexusRequest, RequestBuilder}; use omicron_common::api::external::IdentityMetadataCreateParams; use omicron_common::api::external::SamlIdentityProvider; @@ -159,14 +160,13 @@ async fn test_listing_identity_providers(cptestctx: &ControlPlaneTestContext) { assert_eq!(providers.len(), 0); - /* - // Add some providers let saml_idp_descriptor = SAML_IDP_DESCRIPTOR; let server = Server::run(); server.expect( Expectation::matching(request::method_path("GET", "/descriptor")) + .times(1..) .respond_with(status_code(200).body(saml_idp_descriptor)), ); @@ -227,9 +227,10 @@ async fn test_listing_identity_providers(cptestctx: &ControlPlaneTestContext) { ).await.items; assert_eq!(providers.len(), 2); - assert_eq!(providers[0].name, silo_saml_idp_1.identity.name); - assert_eq!(providers[1].name, silo_saml_idp_2.identity.name); - */ + + let provider_name_set = providers.into_iter().map(|x| x.identity.name).collect::>(); + assert!(provider_name_set.contains(&silo_saml_idp_1.identity.name)); + assert!(provider_name_set.contains(&silo_saml_idp_2.identity.name)); } // Valid SAML IdP entity descriptor from https://en.wikipedia.org/wiki/SAML_metadata#Identity_provider_metadata From f9e20117ca49827fbd07fe66b2ef7f4133811650 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Tue, 24 May 2022 21:11:39 -0400 Subject: [PATCH 53/54] add list_identity_providers permission --- nexus/src/authz/omicron.polar | 6 +++++- nexus/src/authz/oso_generic.rs | 4 ++++ nexus/src/db/datastore.rs | 2 +- nexus/src/nexus.rs | 4 ++-- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/nexus/src/authz/omicron.polar b/nexus/src/authz/omicron.polar index 82af749b548..e16b558285f 100644 --- a/nexus/src/authz/omicron.polar +++ b/nexus/src/authz/omicron.polar @@ -134,16 +134,19 @@ resource Silo { "modify", "read", "create_child", + "list_identity_providers", ]; roles = [ "admin", "collaborator", "viewer" ]; "list_children" if "viewer"; "read" if "viewer"; + "list_identity_providers" if "viewer"; "viewer" if "collaborator"; "create_child" if "collaborator"; "collaborator" if "admin"; "modify" if "admin"; + relations = { parent_fleet: Fleet }; "admin" if "admin" on "parent_fleet"; "collaborator" if "collaborator" on "parent_fleet"; @@ -151,7 +154,8 @@ 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 +# Users can see their own silo! This includes users without any other +# roles has_role(actor: AuthenticatedActor, "viewer", silo: Silo) if actor.silo = silo; diff --git a/nexus/src/authz/oso_generic.rs b/nexus/src/authz/oso_generic.rs index 667854aadf9..3c814b8ceb3 100644 --- a/nexus/src/authz/oso_generic.rs +++ b/nexus/src/authz/oso_generic.rs @@ -106,6 +106,7 @@ pub enum Action { Delete, ListChildren, CreateChild, + ListIdentityProviders, // only used during [`Nexus::identity_provider_list`] } impl oso::PolarClass for Action { @@ -134,6 +135,7 @@ pub enum Perm { Modify, ListChildren, CreateChild, + ListIdentityProviders, // only used during [`Nexus::identity_provider_list`] } impl From<&Action> for Perm { @@ -147,6 +149,7 @@ impl From<&Action> for Perm { Action::Delete => Perm::Modify, Action::ListChildren => Perm::ListChildren, Action::CreateChild => Perm::CreateChild, + Action::ListIdentityProviders => Perm::ListIdentityProviders, } } } @@ -161,6 +164,7 @@ impl fmt::Display for Perm { Perm::Modify => "modify", Perm::ListChildren => "list_children", Perm::CreateChild => "create_child", + Perm::ListIdentityProviders => "list_identity_providers", }) } } diff --git a/nexus/src/db/datastore.rs b/nexus/src/db/datastore.rs index a6ea8da6317..c4613cb9a87 100644 --- a/nexus/src/db/datastore.rs +++ b/nexus/src/db/datastore.rs @@ -2743,7 +2743,7 @@ impl DataStore { authz_silo: &authz::Silo, pagparams: &DataPageParams<'_, Name>, ) -> ListResultVec { - opctx.authorize(authz::Action::ListChildren, authz_silo).await?; + opctx.authorize(authz::Action::ListIdentityProviders, authz_silo).await?; use db::schema::identity_provider::dsl; paginated(dsl::identity_provider, dsl::name, pagparams) diff --git a/nexus/src/nexus.rs b/nexus/src/nexus.rs index 2e7cc10f06d..eff99f6c217 100644 --- a/nexus/src/nexus.rs +++ b/nexus/src/nexus.rs @@ -3843,9 +3843,9 @@ impl Nexus { silo_name: &Name, pagparams: &DataPageParams<'_, Name>, ) -> ListResultVec { - let (.., authz_silo) = LookupPath::new(opctx, &self.db_datastore) + let (authz_silo, ..) = LookupPath::new(opctx, &self.db_datastore) .silo_name(silo_name) - .lookup_for(authz::Action::ListChildren) + .fetch() .await?; self.db_datastore.identity_provider_list(opctx, &authz_silo, pagparams).await } From 609a7e1a74abe587cbc35d29f9d776f256c699c7 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Wed, 25 May 2022 08:42:16 -0400 Subject: [PATCH 54/54] use lazy_static for saml identity provider stuff --- nexus/tests/integration_tests/endpoints.rs | 25 +++++++++++-------- nexus/tests/integration_tests/unauthorized.rs | 2 +- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index 895d6e05ce9..8d5dfcbd0cc 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -248,12 +248,20 @@ lazy_static! { }, disk: DEMO_DISK_NAME.clone(), }; +} + +lazy_static! { + // Identity providers + pub static ref IDENTITY_PROVIDERS_URL: String = format!("{}/identity_providers", *DEMO_SILO_URL); + + pub static ref SAML_IDENTITY_PROVIDERS_URL: String = format!("{}/saml_identity_providers", *DEMO_SILO_URL); + pub static ref DEMO_SAML_IDENTITY_PROVIDER_NAME: Name = "demo-saml-provider".parse().unwrap(); + pub static ref SPECIFIC_SAML_IDENTITY_PROVIDER_URL: String = format!("{}/{}", *SAML_IDENTITY_PROVIDERS_URL, *DEMO_SAML_IDENTITY_PROVIDER_NAME); - // SAML identity provider pub static ref SAML_IDENTITY_PROVIDER: params::SamlIdentityProviderCreate = params::SamlIdentityProviderCreate { identity: IdentityMetadataCreateParams { - name: "demo-saml-provider".to_string().parse().unwrap(), + name: DEMO_SAML_IDENTITY_PROVIDER_NAME.clone(), description: "a demo provider".to_string(), }, @@ -912,24 +920,21 @@ lazy_static! { /* Silo identity providers */ VerifyEndpoint { - url: "/silos/default-silo/identity_providers", - visibility: Visibility::Public, // Users can see their own silo! This includes USER_TEST_UNPRIVILEGED + url: &*IDENTITY_PROVIDERS_URL, + visibility: Visibility::Protected, allowed_methods: vec![ AllowedMethod::Get, ], }, - VerifyEndpoint { - url: "/silos/default-silo/saml_identity_providers", - visibility: Visibility::Public, // Users can see their own silo! This includes USER_TEST_UNPRIVILEGED + url: &*SAML_IDENTITY_PROVIDERS_URL, + visibility: Visibility::Protected, allowed_methods: vec![AllowedMethod::Post( serde_json::to_value(&*SAML_IDENTITY_PROVIDER).unwrap(), )], }, - VerifyEndpoint { - url: - "/silos/default-silo/saml_identity_providers/demo-saml-provider", + url: &*SPECIFIC_SAML_IDENTITY_PROVIDER_URL, visibility: Visibility::Protected, allowed_methods: vec![AllowedMethod::Get], }, diff --git a/nexus/tests/integration_tests/unauthorized.rs b/nexus/tests/integration_tests/unauthorized.rs index 95dd0d1fd63..566801c0734 100644 --- a/nexus/tests/integration_tests/unauthorized.rs +++ b/nexus/tests/integration_tests/unauthorized.rs @@ -203,7 +203,7 @@ lazy_static! { }, // Create a SAML identity provider SetupReq { - url: "/silos/default-silo/saml_identity_providers", + url: &*SAML_IDENTITY_PROVIDERS_URL, body: serde_json::to_value(&*SAML_IDENTITY_PROVIDER).unwrap(), }, ];