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/.github/workflows/rust.yml b/.github/workflows/rust.yml index d8af3b92e4d..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 @@ -45,6 +49,10 @@ 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 # # Clippy's style nits are useful, but not worth keeping in CI. This @@ -64,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 diff --git a/Cargo.lock b/Cargo.lock index 2db98f030ed..9df4e403f96 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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf6b561dcf059c85bbe388e0a7b0a1469acb3934cc0cfa148613a830629e3049" +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-rc.0" @@ -1999,12 +2108,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" @@ -2015,6 +2140,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" @@ -2115,6 +2251,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" @@ -2272,6 +2414,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" @@ -2467,6 +2619,9 @@ dependencies = [ "omicron-test-utils", "openapi-lint", "openapiv3", + "openssl", + "openssl-probe", + "openssl-sys", "oso", "oximeter", "oximeter-client", @@ -2480,6 +2635,7 @@ dependencies = [ "regex", "reqwest", "ring", + "samael", "schemars", "serde", "serde_json", @@ -2968,6 +3124,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" @@ -3367,6 +3529,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" @@ -3657,6 +3829,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" @@ -3728,6 +3906,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/oxidecomputer/samael?branch=dynamic#eba1ffd80f76afbf8159c356be296602f14c71e6" +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" @@ -3973,7 +4177,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", @@ -4059,6 +4263,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" @@ -4269,6 +4479,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" @@ -4276,7 +4496,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]] @@ -4876,7 +5107,7 @@ dependencies = [ "serde", "serde_json", "serde_plain", - "snafu", + "snafu 0.7.0", "tempfile", "untrusted", "url", @@ -5449,6 +5680,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/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. # diff --git a/Dockerfile b/Dockerfile index 4afcf1f7a99..f77c98e6630 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,6 +29,9 @@ RUN apt-get update && apt-get install -y \ libpq5 \ libssl1.1 \ libsqlite3-0 \ + xmlsec1 \ + libxmlsec1-dev \ + libxmlsec1-openssl \ --no-install-recommends \ && rm -rf /var/lib/apt/lists/* diff --git a/common/src/api/external/mod.rs b/common/src/api/external/mod.rs index 29c9824cf55..7fd339b737c 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, + IdentityProvider, + SamlIdentityProvider, SshKey, ConsoleSession, GlobalImage, @@ -1743,6 +1745,37 @@ 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 SamlIdentityProvider { + #[serde(flatten)] + pub identity: IdentityMetadata, + + /// url where identity provider metadata descriptor is + pub idp_metadata_url: String, + + /// identity provider's entity id + pub idp_entity_id: String, + + /// 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 identity provider 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 aabafc84eac..f668dd2e069 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, @@ -223,6 +220,71 @@ CREATE INDEX ON omicron.public.silo_user ( ) WHERE time_deleted IS NULL; +CREATE TYPE omicron.public.provider_type AS ENUM ( + 'saml' +); + +/* + * Silo identity provider list + */ +CREATE TABLE omicron.public.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, + 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 + */ +CREATE TABLE omicron.public.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 +); + +CREATE INDEX ON omicron.public.saml_identity_provider ( + id, + silo_id +) WHERE + time_deleted IS NULL; + /* * Users' public SSH keys, per RFD 44 */ diff --git a/nexus/Cargo.toml b/nexus/Cargo.toml index f72e450c5be..08ecf563fa6 100644 --- a/nexus/Cargo.toml +++ b/nexus/Cargo.toml @@ -31,6 +31,10 @@ 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" oso = "0.26" oximeter-client = { path = "../oximeter-client" } oximeter-db = { path = "../oximeter/db/" } @@ -41,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"], branch = "master" } 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 013ab12cc21..903f5edf5cf 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..8dc358fc6d4 --- /dev/null +++ b/nexus/src/authn/silos.rs @@ -0,0 +1,208 @@ +// 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::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; +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 struct SamlIdentityProvider { + 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 TryFrom for SamlIdentityProvider { + type Error = anyhow::Error; + fn try_from( + model: model::SamlIdentityProvider, + ) -> Result { + let provider = SamlIdentityProvider { + 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 = + provider.idp_metadata_document_string.parse()?; + + // check that there is a valid sign in url + let _sign_in_url = provider.sign_in_url(None)?; + + Ok(provider) + } +} + +pub enum IdentityProviderType { + Saml(SamlIdentityProvider), +} + +impl IdentityProviderType { + /// 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 (.., identity_provider) = LookupPath::new(opctx, datastore) + .silo_name(silo_name) + .identity_provider_name(provider_name) + .fetch() + .await?; + + match identity_provider.provider_type { + model::IdentityProviderType::Saml => { + let (.., saml_identity_provider) = + LookupPath::new(opctx, datastore) + .silo_name(silo_name) + .saml_identity_provider_name(provider_name) + .fetch() + .await?; + + 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 + // error: it was validated before it went into the + // DB. + omicron_common::api::external::Error::internal_error( + &format!( + "saml_identity_provider.try_into() failed! {}", + &e.to_string() + ) + ) + )? + )) + } + } + } +} + +impl SamlIdentityProvider { + 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.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_bytes()? { + if let Ok(parsed) = openssl::x509::X509::from_der(&cert) { + sp_builder.certificate(Some(parsed)); + } + } + + Ok(sp_builder.build()?) + } + + fn public_cert_bytes(&self) -> Result>> { + if let Some(cert) = &self.public_cert { + Ok(Some(base64::decode(cert.as_bytes())?)) + } else { + Ok(None) + } + } + + fn 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 0874e05d1a6..cff0da9208e 100644 --- a/nexus/src/authz/api_resources.rs +++ b/nexus/src/authz/api_resources.rs @@ -467,6 +467,22 @@ authz_resource! { polar_snippet = Custom, } +authz_resource! { + name = "IdentityProvider", + parent = "Silo", + primary_key = Uuid, + roles_allowed = false, + polar_snippet = Custom, +} + +authz_resource! { + name = "SamlIdentityProvider", + 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..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,6 +154,8 @@ resource Silo { } has_relation(fleet: Fleet, "parent_fleet", silo: Silo) if silo.fleet = fleet; +# Users can see their own silo! This includes users without any other +# roles has_role(actor: AuthenticatedActor, "viewer", silo: Silo) if actor.silo = silo; @@ -253,6 +258,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", 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 = [ "list_children", @@ -279,3 +295,42 @@ resource SshKey { } has_relation(user: SiloUser, "silo_user", ssh_key: SshKey) if ssh_key.silo_user = user; + +resource IdentityProvider { + 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", identity_provider: IdentityProvider) + if identity_provider.silo = silo; + +resource SamlIdentityProvider { + 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", 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 0147dfc0f14..3c814b8ceb3 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(), + IdentityProvider::init(), + SamlIdentityProvider::init(), Sled::init(), UpdateAvailableArtifact::init(), UserBuiltin::init(), @@ -104,6 +106,7 @@ pub enum Action { Delete, ListChildren, CreateChild, + ListIdentityProviders, // only used during [`Nexus::identity_provider_list`] } impl oso::PolarClass for Action { @@ -132,6 +135,7 @@ pub enum Perm { Modify, ListChildren, CreateChild, + ListIdentityProviders, // only used during [`Nexus::identity_provider_list`] } impl From<&Action> for Perm { @@ -145,6 +149,7 @@ impl From<&Action> for Perm { Action::Delete => Perm::Modify, Action::ListChildren => Perm::ListChildren, Action::CreateChild => Perm::CreateChild, + Action::ListIdentityProviders => Perm::ListIdentityProviders, } } } @@ -159,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 2362f8cebf0..c4613cb9a87 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, @@ -2696,9 +2696,115 @@ impl DataStore { info!(opctx.log, "deleted {} silo users for silo {}", updated_rows, id); + // delete all silo identity providers + use db::schema::identity_provider::dsl as idp_dsl; + + 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| { + public_error_from_diesel_pool( + e, + ErrorHandler::NotFoundByResource(authz_silo), + ) + })?; + + info!(opctx.log, "deleted {} silo IdPs for silo {}", updated_rows, id); + + use db::schema::saml_identity_provider::dsl as saml_idp_dsl; + + 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, + "deleted {} silo saml IdPs for silo {}", updated_rows, id + ); + Ok(()) } + pub async fn identity_provider_list( + &self, + opctx: &OpContext, + authz_silo: &authz::Silo, + pagparams: &DataPageParams<'_, Name>, + ) -> ListResultVec { + opctx.authorize(authz::Action::ListIdentityProviders, 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, + authz_silo: &authz::Silo, + provider: db::model::SamlIdentityProvider, + ) -> CreateResult { + opctx.authorize(authz::Action::CreateChild, authz_silo).await?; + + let name = provider.identity().name.to_string(); + self.pool_authorized(opctx) + .await? + .transaction(move |conn| { + // insert silo identity provider record with type Saml + 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, + provider_type: db::model::IdentityProviderType::Saml, + }) + .execute(conn)?; + + // insert silo saml identity provider record + 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) + }) + .await + .map_err(|e| { + public_error_from_diesel_pool( + e, + ErrorHandler::Conflict( + ResourceType::SamlIdentityProvider, + &name, + ), + ) + }) + } + /// 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 17ace87be17..34360826c81 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", "IdentityProvider", "SamlIdentityProvider" ], lookup_by_name = true, soft_deletes = true, primary_key_columns = [ { column_name = "id", rust_type = Uuid } ] @@ -412,6 +412,29 @@ lookup_resource! { primary_key_columns = [ { column_name = "id", rust_type = Uuid } ] } +lookup_resource! { + name = "IdentityProvider", + ancestors = [ "Silo" ], + children = [], + lookup_by_name = true, + soft_deletes = true, + primary_key_columns = [ + { column_name = "silo_id", rust_type = Uuid }, + { column_name = "id", rust_type = Uuid } + ] +} + +lookup_resource! { + name = "SamlIdentityProvider", + 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/identity_provider.rs b/nexus/src/db/model/identity_provider.rs new file mode 100644 index 00000000000..ad126f7632a --- /dev/null +++ b/nexus/src/db/model/identity_provider.rs @@ -0,0 +1,73 @@ +// 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 crate::db::identity::Resource; +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; + +use serde::{Deserialize, Serialize}; +use std::io::Write; +use uuid::Uuid; + +impl_enum_type!( + #[derive(SqlType, Debug, QueryId)] + #[diesel(postgres_type(name = "provider_type"))] + pub struct IdentityProviderTypeEnum; + + #[derive(Copy, Clone, Debug, AsExpression, FromSqlRow, Serialize, Deserialize, PartialEq)] + #[diesel(sql_type = IdentityProviderTypeEnum)] + pub enum IdentityProviderType; + + // Enum values + Saml => b"saml" +); + +#[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, +} + +#[derive(Queryable, Insertable, Clone, Debug, Selectable, Resource)] +#[diesel(table_name = saml_identity_provider)] +pub struct SamlIdentityProvider { + #[diesel(embed)] + pub identity: SamlIdentityProviderIdentity, + + 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 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(), + 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, + } + } +} diff --git a/nexus/src/db/model/mod.rs b/nexus/src/db/model/mod.rs index fcae80caaa5..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; @@ -60,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::*; @@ -211,6 +213,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_lossy($sql_value).to_string(), + )* + } + } + } } } @@ -339,4 +351,81 @@ 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 => 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 + //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(), + "\u{6}".to_string() + ); + assert_eq!( + TestType::NonUtf8ByteStringLiteral.to_string(), + "�".to_string() + ); + assert_eq!(TestType::Rocketship.to_string(), "🚀".to_string(),); + } } diff --git a/nexus/src/db/schema.rs b/nexus/src/db/schema.rs index 6b7e05df824..7d4825ab854 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,44 @@ table! { } } +table! { + 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, + } +} + +table! { + 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, @@ -465,6 +504,8 @@ allow_tables_to_appear_in_same_query!( region, saga, saga_node_event, + 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..f26a51c1321 100644 --- a/nexus/src/external_api/console_api.rs +++ b/nexus/src/external_api/console_api.rs @@ -15,7 +15,9 @@ use crate::authn::external::{ SessionStore, SESSION_COOKIE_COOKIE_NAME, }, }; -use crate::authn::{USER_TEST_PRIVILEGED, USER_TEST_UNPRIVILEGED}; +use crate::authn::{ + silos::IdentityProviderType, USER_TEST_PRIVILEGED, USER_TEST_UNPRIVILEGED, +}; use crate::context::OpContext; use crate::ServerContext; use dropshot::{ @@ -86,6 +88,103 @@ 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 = IdentityProviderType::lookup( + &nexus.datastore(), + &opctx, + &path_params.silo_name, + &path_params.provider_name, + ) + .await?; + + match identity_provider { + IdentityProviderType::Saml(saml_identity_provider) => { + let relay_state = None; + 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) + .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 = IdentityProviderType::lookup( + &nexus.datastore(), + &opctx, + &path_params.silo_name, + &path_params.provider_name, + ) + .await?; + + match identity_provider { + IdentityProviderType::Saml(_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 96df2f9b58c..a4c99eac2b2 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -5,9 +5,9 @@ //! Handler functions (entrypoints) for external HTTP APIs use super::{ - console_api, params, + 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,8 +75,12 @@ 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)?; + api.register(silo_saml_idp_fetch)?; + api.register(organizations_get)?; api.register(organizations_post)?; api.register(organizations_get_organization)?; @@ -191,6 +195,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(()) } @@ -351,6 +357,108 @@ async fn silos_delete_silo( apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await } +// 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. +#[endpoint { + method = POST, + path = "/silos/{silo_name}/saml_identity_providers", + 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 + .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_providers/{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 + .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..f8e120d5757 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::{Deserialize, Serialize}; +use serde::{ + de::{self, Visitor}, + Deserialize, Deserializer, Serialize, +}; use std::collections::BTreeMap; use std::net::IpAddr; use uuid::Uuid; @@ -25,6 +28,207 @@ pub struct SiloCreate { pub discoverable: bool, } +// Silo identity providers + +#[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 SamlIdentityProviderCreate { + #[serde(flatten)] + pub identity: IdentityMetadataCreateParams, + + /// 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 + #[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/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 3c2e1914c16..d9d98e817d6 100644 --- a/nexus/src/external_api/views.rs +++ b/nexus/src/external_api/views.rs @@ -36,6 +36,92 @@ 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) + pub public_cert: String, +} + +#[derive(ObjectIdentity, Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct SamlIdentityProvider { + #[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 SamlIdentityProvider { + fn from(saml_idp: model::SamlIdentityProvider) -> 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 }), + } + } +} + // ORGANIZATIONS /// Client view of an [`Organization`] diff --git a/nexus/src/nexus.rs b/nexus/src/nexus.rs index 33987abde25..eff99f6c217 100644 --- a/nexus/src/nexus.rs +++ b/nexus/src/nexus.rs @@ -1147,13 +1147,24 @@ impl Nexus { serde_json::to_string(&volume_construction_request)?; // use reqwest to query url for size - let response = - reqwest::Client::new().head(url).send().await.map_err( - |e| Error::InvalidValue { - label: String::from("url"), - message: format!("error querying url: {}", e), - }, - )?; + 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.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 { @@ -3824,6 +3835,127 @@ 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) + .fetch() + .await?; + self.db_datastore.identity_provider_list(opctx, &authz_silo, pagparams).await + } + + // Silo authn identity providers + + pub async fn saml_identity_provider_create( + &self, + opctx: &OpContext, + silo_name: &Name, + params: params::SamlIdentityProviderCreate, + ) -> CreateResult { + let (authz_silo, 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 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::SamlIdentityProvider { + identity: db::model::SamlIdentityProviderIdentity::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 + .signing_keypair + .as_ref() + .map(|x| x.public_cert.clone()), + private_key: params + .signing_keypair + .as_ref() + .map(|x| x.private_key.clone()), + }; + + 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 + // parameters of this request doesn't work. + Error::invalid_request(&e.to_string()))?; + + self.db_datastore + .saml_identity_provider_create(opctx, &authz_silo, provider) + .await + } + + pub async fn saml_identity_provider_fetch( + &self, + opctx: &OpContext, + silo_name: &Name, + provider_name: &Name, + ) -> LookupResult { + let (.., saml_identity_provider) = + LookupPath::new(opctx, &self.datastore()) + .silo_name(silo_name) + .saml_identity_provider_name(provider_name) + .fetch() + .await?; + Ok(saml_identity_provider) + } + // SSH public keys pub async fn ssh_keys_list( 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/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index 3c3132168b4..8d5dfcbd0cc 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; @@ -227,7 +228,7 @@ lazy_static! { name: DEMO_IMAGE_NAME.clone(), description: String::from(""), }, - source: params::ImageSource::Url(String::from("http://127.0.0.1:5555/image.raw")), + source: params::ImageSource::Url(HTTP_SERVER.url("/image.raw").to_string()), block_size: params::BlockSize::try_from(4096).unwrap(), }; @@ -249,10 +250,38 @@ lazy_static! { }; } +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); + + pub static ref SAML_IDENTITY_PROVIDER: params::SamlIdentityProviderCreate = + params::SamlIdentityProviderCreate { + identity: IdentityMetadataCreateParams { + name: DEMO_SAML_IDENTITY_PROVIDER_NAME.clone(), + 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(), + + signing_keypair: None, + }; +} + /// Describes an API endpoint to be verified by the "unauthorized" test /// /// 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 /// @@ -285,6 +314,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) @@ -299,6 +329,7 @@ pub enum Visibility { } /// Describes an HTTP method supported by a particular API endpoint +#[derive(Clone)] pub enum AllowedMethod { /// HTTP "DELETE" method Delete, @@ -387,7 +418,6 @@ lazy_static! { ], }, - /* Organizations */ VerifyEndpoint { @@ -886,5 +916,27 @@ lazy_static! { AllowedMethod::Delete, ], }, + + /* Silo identity providers */ + + VerifyEndpoint { + url: &*IDENTITY_PROVIDERS_URL, + visibility: Visibility::Protected, + allowed_methods: vec![ + AllowedMethod::Get, + ], + }, + VerifyEndpoint { + url: &*SAML_IDENTITY_PROVIDERS_URL, + visibility: Visibility::Protected, + allowed_methods: vec![AllowedMethod::Post( + serde_json::to_value(&*SAML_IDENTITY_PROVIDER).unwrap(), + )], + }, + VerifyEndpoint { + url: &*SPECIFIC_SAML_IDENTITY_PROVIDER_URL, + 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 bad2be1294a..57031d1d9cc 100644 --- a/nexus/tests/integration_tests/silos.rs +++ b/nexus/tests/integration_tests/silos.rs @@ -2,19 +2,26 @@ // 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 nexus_test_utils::http_testing::{AuthnMode, NexusRequest}; -use omicron_nexus::external_api::views::{Organization, Silo}; +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; +use omicron_nexus::authn::silos::IdentityProviderType; +use omicron_nexus::external_api::params; +use omicron_nexus::external_api::views::{Organization, Silo, IdentityProvider}; use omicron_nexus::TestInterfaces as _; use http::method::Method; use http::StatusCode; use nexus_test_utils::resource_helpers::{ - create_organization, create_silo, objects_list_page_authz, + create_organization, create_silo, object_create, objects_list_page_authz, }; use nexus_test_utils::ControlPlaneTestContext; use nexus_test_utils_macros::nexus_test; +use httptest::{matchers::*, responders::*, Expectation, Server}; + #[nexus_test] async fn test_silos(cptestctx: &ControlPlaneTestContext) { let client = &cptestctx.external_client; @@ -137,3 +144,763 @@ async fn test_silos(cptestctx: &ControlPlaneTestContext) { .await .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")) + .times(1..) + .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); + + 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 +// 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: SamlIdentityProvider = object_create( + client, + &format!("/silos/{}/saml_identity_providers", SILO_NAME), + ¶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; + + // 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 = IdentityProviderType::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(), + ) + .unwrap() + .into(), + ) + .await + .unwrap(); + + match retrieved_silo_idp_from_nexus { + IdentityProviderType::Saml(_) => { + // ok + } + } + + // Check that created identity provider exists + let _retrieved_silo_saml_idp: SamlIdentityProvider = + NexusRequest::object_get( + &client, + &format!( + "/silos/{}/saml_identity_providers/{}", + 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: SamlIdentityProvider = object_create( + client, + &format!("/silos/{}/saml_identity_providers", SILO_NAME), + ¶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; + + // 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 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_providers", SILO_NAME), + ) + .body(Some(¶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, + })) + .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 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_providers", SILO_NAME), + ) + .body(Some(¶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, + })) + .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: SamlIdentityProvider = object_create( + client, + "/silos/hidden/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; + + // 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 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_providers", SILO_NAME), + ) + .body(Some(¶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, + })) + .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"; + + NexusRequest::new( + RequestBuilder::new( + client, + Method::POST, + &format!("/silos/{}/saml_identity_providers", SILO_NAME), + ) + .body(Some(¶ms::SamlIdentityProviderCreate { + identity: IdentityMetadataCreateParams { + name: "some-totally-real-saml-provider" + .to_string() + .parse() + .unwrap(), + description: "a demo provider".to_string(), + }, + + 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(), + + signing_keypair: None, + })) + .expect_status(Some(StatusCode::BAD_REQUEST)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .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 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::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: 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"; + + NexusRequest::new( + RequestBuilder::new( + client, + Method::POST, + &format!("/silos/{}/saml_identity_providers", SILO_NAME), + ) + .body(Some(¶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: 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"); +} diff --git a/nexus/tests/integration_tests/unauthorized.rs b/nexus/tests/integration_tests/unauthorized.rs index b1eca1fa743..566801c0734 100644 --- a/nexus/tests/integration_tests/unauthorized.rs +++ b/nexus/tests/integration_tests/unauthorized.rs @@ -6,6 +6,7 @@ //! unauthorized users use super::endpoints::*; +use crate::integration_tests::silos::SAML_IDP_DESCRIPTOR; use dropshot::test_util::ClientTestContext; use dropshot::HttpErrorResponseBody; use headers::authorization::Credentials; @@ -55,24 +56,6 @@ async fn test_unauthorized(cptestctx: &ControlPlaneTestContext) { let client = &cptestctx.external_client; let log = &cptestctx.logctx.log; - // Run a httptest server - let server = ServerBuilder::new() - .bind_addr("127.0.0.1:5555".parse().unwrap()) - .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), - ), - ), - ); - // Create test data. info!(log, "setting up resource hierarchy"); for request in &*SETUP_REQUESTS { @@ -83,7 +66,7 @@ async fn test_unauthorized(cptestctx: &ControlPlaneTestContext) { .unwrap(); } - // Verify the hardcoded endpoints. + // Verify the endpoints. info!(log, "verifying endpoints"); print!("{}", VERIFY_HEADER); for endpoint in &*VERIFY_ENDPOINTS { @@ -118,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 @@ -141,6 +124,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) @@ -193,6 +201,11 @@ lazy_static! { url: "/images", body: serde_json::to_value(&*DEMO_IMAGE_CREATE).unwrap(), }, + // Create a SAML identity provider + SetupReq { + url: &*SAML_IDENTITY_PROVIDERS_URL, + body: serde_json::to_value(&*SAML_IDENTITY_PROVIDER).unwrap(), + }, ]; } diff --git a/nexus/tests/output/nexus_tags.txt b/nexus/tests/output/nexus_tags.txt index 742d1e454bd..b35a99009b4 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 @@ -103,8 +108,11 @@ 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_providers +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/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 72437a40c9b..20e0e0cbb5d 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": [ @@ -4811,6 +4889,174 @@ } } }, + "/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": [ + "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/SamlIdentityProviderCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "successful creation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SamlIdentityProvider" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/silos/{silo_name}/saml_identity_providers/{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/SamlIdentityProvider" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/timeseries/schema": { "get": { "tags": [ @@ -5027,6 +5273,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": [ { @@ -5551,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", @@ -7102,6 +7441,140 @@ } ] }, + "SamlIdentityProvider": { + "description": "Identity-related metadata that's included in nearly all public API objects", + "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" + } + ] + }, + "signing_keypair": { + "nullable": true, + "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", + "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" + ] + }, + "SamlIdentityProviderCreate": { + "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" + }, + "signing_keypair": { + "nullable": true, + "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", + "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", + "slo_url", + "sp_client_id", + "technical_contact_email" + ] + }, "SessionUser": { "description": "Client view of currently authed user.", "type": "object", @@ -8516,6 +8989,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.", 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 diff --git a/tools/install_prerequisites.sh b/tools/install_prerequisites.sh index 056e4f7af0b..4ab8dce76ea 100755 --- a/tools/install_prerequisites.sh +++ b/tools/install_prerequisites.sh @@ -74,7 +74,11 @@ if [[ "${HOST_OS}" == "Linux" ]]; then packages=( 'libpq-dev' 'pkg-config' + 'xmlsec1' + 'libxmlsec1-dev' + 'libxmlsec1-openssl' ) + sudo apt-get update confirm "Install (or update) [${packages[*]}]?" && sudo apt-get install ${packages[@]} elif [[ "${HOST_OS}" == "SunOS" ]]; then packages=( @@ -83,6 +87,9 @@ elif [[ "${HOST_OS}" == "SunOS" ]]; then 'library/postgresql-13' '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' ) # Install/update the set of packages. @@ -102,6 +109,7 @@ elif [[ "${HOST_OS}" == "Darwin" ]]; then packages=( 'postgresql' 'pkg-config' + 'libxmlsec1' ) confirm "Install (or update) [${packages[*]}]?" && brew install ${packages[@]} else