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 268e855769e..3bbaf263363 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -275,6 +275,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" @@ -413,6 +436,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 = "0.1.10" @@ -473,6 +505,17 @@ dependencies = [ "generic-array 0.14.5", ] +[[package]] +name = "clang-sys" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a050e2153c5be08febd6734e29298e844fdb0fa21aeddd63b4eb7baa106c69b" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "2.34.0" @@ -796,7 +839,7 @@ dependencies = [ "toml", "tracing", "usdt", - "uuid", + "uuid 1.1.0", "version_check", ] @@ -830,7 +873,7 @@ dependencies = [ "tokio-rustls", "toml", "twox-hash", - "uuid", + "uuid 1.1.0", ] [[package]] @@ -844,7 +887,7 @@ dependencies = [ "crucible-common", "serde", "tokio-util 0.7.2", - "uuid", + "uuid 1.1.0", ] [[package]] @@ -970,14 +1013,38 @@ dependencies = [ "zeroize", ] +[[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.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.13.4", + "darling_macro 0.13.4", +] + +[[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]] @@ -994,13 +1061,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.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835" dependencies = [ - "darling_core", + "darling_core 0.13.4", "quote", "syn", ] @@ -1033,6 +1111,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 = "derive_more" version = "0.99.17" @@ -1062,7 +1171,7 @@ dependencies = [ "pq-sys", "r2d2", "serde_json", - "uuid", + "uuid 1.1.0", ] [[package]] @@ -1074,7 +1183,7 @@ dependencies = [ "lock_api", "serde", "usdt", - "uuid", + "uuid 1.1.0", ] [[package]] @@ -1219,7 +1328,7 @@ dependencies = [ "tokio-rustls", "toml", "usdt", - "uuid", + "uuid 1.1.0", "version_check", ] @@ -1693,7 +1802,7 @@ dependencies = [ "serde", "serde_json", "slog", - "uuid", + "uuid 1.1.0", ] [[package]] @@ -1727,7 +1836,7 @@ dependencies = [ "tokio-stream", "tokio-tungstenite", "usdt", - "uuid", + "uuid 1.1.0", ] [[package]] @@ -2348,12 +2457,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.126" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349d5a591cd28b49e1d1037471617a32ddcda5731b99419008085f72d5a53836" +[[package]] +name = "libloading" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbc0f03f9a775e9f6aed295c6a1ba2253c5757a9e03d55c6caa46a681abcddd" +dependencies = [ + "cfg-if 1.0.0", + "winapi", +] + [[package]] name = "libnet" version = "0.1.0" @@ -2381,6 +2506,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" @@ -2488,6 +2624,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.5.1" @@ -2612,7 +2754,7 @@ dependencies = [ "serde", "serde_json", "slog", - "uuid", + "uuid 1.1.0", ] [[package]] @@ -2639,7 +2781,7 @@ dependencies = [ "serde_json", "slog", "tokio", - "uuid", + "uuid 1.1.0", ] [[package]] @@ -2659,6 +2801,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" @@ -2798,7 +2950,7 @@ dependencies = [ "thiserror", "tokio", "tokio-postgres", - "uuid", + "uuid 1.1.0", ] [[package]] @@ -2847,7 +2999,7 @@ dependencies = [ "tokio", "tokio-tungstenite", "toml", - "uuid", + "uuid 1.1.0", ] [[package]] @@ -2891,6 +3043,9 @@ dependencies = [ "omicron-test-utils", "openapi-lint", "openapiv3", + "openssl", + "openssl-probe", + "openssl-sys", "oso", "oximeter", "oximeter-client", @@ -2904,6 +3059,7 @@ dependencies = [ "regex", "reqwest", "ring", + "samael", "schemars", "serde", "serde_json", @@ -2924,7 +3080,7 @@ dependencies = [ "toml", "tough", "usdt", - "uuid", + "uuid 1.1.0", ] [[package]] @@ -3012,7 +3168,7 @@ dependencies = [ "tokio", "tokio-util 0.7.2", "toml", - "uuid", + "uuid 1.1.0", "vsss-rs", "zone", ] @@ -3211,7 +3367,7 @@ dependencies = [ "serde", "thiserror", "trybuild", - "uuid", + "uuid 1.1.0", ] [[package]] @@ -3224,7 +3380,7 @@ dependencies = [ "reqwest", "serde", "slog", - "uuid", + "uuid 1.1.0", ] [[package]] @@ -3250,7 +3406,7 @@ dependencies = [ "thiserror", "tokio", "toml", - "uuid", + "uuid 1.1.0", ] [[package]] @@ -3277,7 +3433,7 @@ dependencies = [ "structopt", "thiserror", "tokio", - "uuid", + "uuid 1.1.0", ] [[package]] @@ -3289,7 +3445,7 @@ dependencies = [ "futures", "http", "oximeter", - "uuid", + "uuid 1.1.0", ] [[package]] @@ -3318,7 +3474,7 @@ dependencies = [ "slog-dtrace", "thiserror", "tokio", - "uuid", + "uuid 1.1.0", ] [[package]] @@ -3430,6 +3586,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" @@ -3697,7 +3859,7 @@ dependencies = [ "postgres-protocol", "serde", "serde_json", - "uuid", + "uuid 1.1.0", ] [[package]] @@ -3890,7 +4052,7 @@ dependencies = [ "slog", "structopt", "thiserror", - "uuid", + "uuid 1.1.0", ] [[package]] @@ -3899,6 +4061,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" @@ -4251,6 +4423,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" @@ -4360,6 +4538,32 @@ dependencies = [ "zeroize", ] +[[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 0.8.2", +] + [[package]] name = "same-file" version = "1.0.6" @@ -4400,7 +4604,7 @@ dependencies = [ "schemars_derive", "serde", "serde_json", - "uuid", + "uuid 1.1.0", ] [[package]] @@ -4628,7 +4832,7 @@ version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e182d6ec6f05393cc0e5ed1bf81ad6db3a8feedf8ee515ecdd369809bcce8082" dependencies = [ - "darling", + "darling 0.13.4", "proc-macro2", "quote", "syn", @@ -4724,6 +4928,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.14" @@ -4810,7 +5020,7 @@ dependencies = [ "reqwest", "serde", "slog", - "uuid", + "uuid 1.1.0", ] [[package]] @@ -4945,6 +5155,16 @@ dependencies = [ "managed", ] +[[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" @@ -4952,7 +5172,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]] @@ -5141,7 +5372,7 @@ dependencies = [ "slog", "thiserror", "tokio", - "uuid", + "uuid 1.1.0", ] [[package]] @@ -5669,7 +5900,7 @@ dependencies = [ "serde", "serde_json", "serde_plain", - "snafu", + "snafu 0.7.0", "tempfile", "untrusted", "url", @@ -6072,6 +6303,15 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "uuid" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" +dependencies = [ + "getrandom 0.2.6", +] + [[package]] name = "uuid" version = "1.1.0" @@ -6278,6 +6518,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 48cd55595c8..fc2e036dd5e 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/common/src/api/external/mod.rs b/common/src/api/external/mod.rs index bd64e786306..de46b013672 100644 --- a/common/src/api/external/mod.rs +++ b/common/src/api/external/mod.rs @@ -514,6 +514,8 @@ pub enum ResourceType { Fleet, Silo, SiloUser, + IdentityProvider, + SamlIdentityProvider, SshKey, ConsoleSession, GlobalImage, diff --git a/common/src/sql/dbinit.sql b/common/src/sql/dbinit.sql index 5d592073635..da57f335cd5 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,70 @@ 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_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 d4147586332..85cbbaa72b9 100644 --- a/nexus/Cargo.toml +++ b/nexus/Cargo.toml @@ -32,6 +32,10 @@ macaddr = { version = "1.0.1", features = [ "serde_std" ]} mime_guess = "2.0.4" newtype_derive = "0.1.6" num-integer = "0.1.45" +# 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/" } @@ -42,6 +46,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.14.0" diff --git a/nexus/src/app/image.rs b/nexus/src/app/image.rs index c0a609f3d03..40d6e1380d0 100644 --- a/nexus/src/app/image.rs +++ b/nexus/src/app/image.rs @@ -116,13 +116,24 @@ impl super::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 { diff --git a/nexus/src/app/silo.rs b/nexus/src/app/silo.rs index fd2373af1bb..86ec5175023 100644 --- a/nexus/src/app/silo.rs +++ b/nexus/src/app/silo.rs @@ -4,7 +4,6 @@ //! Silos, Users, and SSH Keys. -use crate::authz; use crate::context::OpContext; use crate::db; use crate::db::identity::Resource; @@ -13,6 +12,7 @@ use crate::db::model::Name; use crate::db::model::SshKey; use crate::external_api::params; use crate::external_api::shared; +use crate::{authn, authz}; use anyhow::Context; use omicron_common::api::external::CreateResult; use omicron_common::api::external::DataPageParams; @@ -198,4 +198,140 @@ impl super::Nexus { assert_eq!(authz_user.id(), silo_user_id); self.db_datastore.ssh_key_delete(opctx, &authz_ssh_key).await } + + // 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?; + + let idp_metadata_document_string = match ¶ms.idp_metadata_source { + params::IdpMetadataSource::Url { url } => { + // 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(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() + ), + }); + } + + response.text().await.map_err(|e| Error::InvalidValue { + label: String::from("url"), + message: format!("error getting text from url: {}", e), + })? + } + + params::IdpMetadataSource::Base64EncodedXml { data } => { + let bytes = + base64::decode(data).map_err(|e| Error::InvalidValue { + label: String::from("data"), + message: format!( + "error getting decoding base64 data: {}", + e + ), + })?; + String::from_utf8_lossy(&bytes).into_owned() + } + }; + + let provider = db::model::SamlIdentityProvider { + identity: db::model::SamlIdentityProviderIdentity::new( + Uuid::new_v4(), + params.identity, + ), + silo_id: db_silo.id(), + + 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) + } } diff --git a/nexus/src/authn/mod.rs b/nexus/src/authn/mod.rs index 2803bd07e3a..59e5bc7a889 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::silo_user::USER_TEST_PRIVILEGED; pub use crate::db::fixed_data::silo_user::USER_TEST_UNPRIVILEGED; 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 2f86a7425a1..45d2a16d30f 100644 --- a/nexus/src/authz/api_resources.rs +++ b/nexus/src/authz/api_resources.rs @@ -626,6 +626,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 98afd891bdf..5c862517aa4 100644 --- a/nexus/src/authz/omicron.polar +++ b/nexus/src/authz/omicron.polar @@ -117,6 +117,7 @@ resource Silo { "modify", "read", "create_child", + "list_identity_providers", ]; roles = [ "admin", "collaborator", "viewer" ]; @@ -127,6 +128,7 @@ resource Silo { # Permissions granted directly by roles on this resource "list_children" if "viewer"; "read" if "viewer"; + "create_child" if "collaborator"; "modify" if "admin"; @@ -158,6 +160,13 @@ has_permission(actor: AuthenticatedActor, "read", silo: Silo) # syntax. if silo in actor.silo; +# Any authenticated user should be allowed to list the identity providers of +# their silo. +has_permission(actor: AuthenticatedActor, "list_identity_providers", silo: Silo) + # TODO-security TODO-coverage We should have a test that exercises this + # syntax. + if silo in actor.silo; + resource Organization { permissions = [ "list_children", @@ -246,6 +255,44 @@ 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; + # # SYNTHETIC RESOURCES OUTSIDE THE SILO HIERARCHY # @@ -288,6 +335,8 @@ has_relation(fleet: Fleet, "parent_fleet", collection: ConsoleSessionList) # These rules grants the external authenticator role the permissions it needs to # read silo users and modify their sessions. This is necessary for login to # work. +has_permission(actor: AuthenticatedActor, "read", silo: Silo) + if has_role(actor, "external-authenticator", silo.fleet); has_permission(actor: AuthenticatedActor, "read", user: SiloUser) if has_role(actor, "external-authenticator", user.silo.fleet); has_permission(actor: AuthenticatedActor, "read", session: ConsoleSession) @@ -295,6 +344,16 @@ 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", identity_provider: IdentityProvider) + if has_role(actor, "external-authenticator", identity_provider.silo.fleet); +has_permission(actor: AuthenticatedActor, "list_identity_providers", 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_identity_providers", saml_identity_provider: SamlIdentityProvider) + if has_role(actor, "external-authenticator", saml_identity_provider.silo.fleet); + # Describes the policy for who can access the internal database. resource Database { 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 dc30987277a..2a4b112c484 100644 --- a/nexus/src/db/datastore.rs +++ b/nexus/src/db/datastore.rs @@ -49,8 +49,8 @@ use crate::db::{ error::{public_error_from_diesel_pool, ErrorHandler, TransactionError}, model::{ ConsoleSession, Dataset, DatasetKind, Disk, DiskRuntimeState, - Generation, GlobalImage, IncompleteNetworkInterface, Instance, - InstanceRuntimeState, Name, NetworkInterface, Organization, + Generation, GlobalImage, IdentityProvider, IncompleteNetworkInterface, + Instance, InstanceRuntimeState, Name, NetworkInterface, Organization, OrganizationUpdate, OximeterInfo, ProducerEndpoint, Project, ProjectUpdate, Region, RoleAssignment, RoleBuiltin, RouterRoute, RouterRouteUpdate, Silo, SiloUser, Sled, SshKey, @@ -3118,9 +3118,117 @@ 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/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..04f5a7b02ea --- /dev/null +++ b/nexus/src/db/model/identity_provider.rs @@ -0,0 +1,54 @@ +// 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::model::impl_enum_type; +use crate::db::schema::{identity_provider, saml_identity_provider}; +use db_macros::Resource; + +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_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, +} diff --git a/nexus/src/db/model/mod.rs b/nexus/src/db/model/mod.rs index 552e39b549c..6284d83e925 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; @@ -61,6 +62,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::*; diff --git a/nexus/src/db/schema.rs b/nexus/src/db/schema.rs index 37a9164228c..fae786da328 100644 --- a/nexus/src/db/schema.rs +++ b/nexus/src/db/schema.rs @@ -140,10 +140,11 @@ table! { id -> Uuid, name -> Text, description -> Text, - discoverable -> Bool, time_created -> Timestamptz, time_modified -> Timestamptz, time_deleted -> Nullable, + + discoverable -> Bool, rcgen -> Int8, } } @@ -159,6 +160,43 @@ 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_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, @@ -468,6 +506,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 20715b3fd5a..6b128495a71 100644 --- a/nexus/src/external_api/console_api.rs +++ b/nexus/src/external_api/console_api.rs @@ -8,7 +8,9 @@ //! external API, but in order to avoid CORS issues for now, we are serving //! these routes directly from the external API. use super::views; -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 crate::{ @@ -89,6 +91,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 login( + 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 this doesn't work because the response is Response + //apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await + handler.await +} + +/// Consume some sort of credentials, and authenticate a user. +/// +/// Either receive a username and password, or some sort of identity provider +/// data (like a SAMLResponse). Use these to set the user's session cookie. +#[endpoint { + method = POST, + path = "/login/{silo_name}/{provider_name}", + tags = ["login"], +}] +pub async fn consume_credentials( + 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 this doesn't work because the response is Response + //apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await + handler.await +} + // Log user out of web console by deleting session in both server and browser #[endpoint { // important for security that this be a POST despite the empty req body diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index e3c86717fdc..bbc1f9a517f 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -5,10 +5,10 @@ //! 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, - Snapshot, SshKey, User, Vpc, VpcRouter, VpcSubnet, + GlobalImage, IdentityProvider, Image, Organization, Project, Rack, + Role, Silo, Sled, Snapshot, SshKey, User, Vpc, VpcRouter, VpcSubnet, }, }; use crate::authz; @@ -80,9 +80,13 @@ pub fn external_api() -> NexusApiDescription { api.register(silos_post)?; api.register(silos_get_silo)?; api.register(silos_delete_silo)?; + api.register(silos_get_identity_providers)?; api.register(silos_get_silo_policy)?; api.register(silos_put_silo_policy)?; + 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)?; @@ -199,6 +203,9 @@ pub fn external_api() -> NexusApiDescription { api.register(console_api::console_page)?; api.register(console_api::asset)?; + api.register(console_api::login)?; + api.register(console_api::consume_credentials)?; + Ok(()) } @@ -455,6 +462,111 @@ async fn silos_put_silo_policy( 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 d6e1aa114ee..55c29131540 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::net::IpAddr; use std::str::FromStr; use uuid::Uuid; @@ -25,6 +28,214 @@ 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)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum IdpMetadataSource { + Url { url: String }, + Base64EncodedXml { data: String }, +} + +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct SamlIdentityProviderCreate { + #[serde(flatten)] + pub identity: IdentityMetadataCreateParams, + + /// the source of an identity provider metadata descriptor + pub idp_metadata_source: IdpMetadataSource, + + /// 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 4f39d55d5f8..592b7095ff1 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 2a4805315f9..f758e8f4e16 100644 --- a/nexus/src/external_api/views.rs +++ b/nexus/src/external_api/views.rs @@ -36,6 +36,80 @@ 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(ObjectIdentity, Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct SamlIdentityProvider { + #[serde(flatten)] + pub identity: IdentityMetadata, + + /// idp's entity id + pub idp_entity_id: String, + + /// sp's client id + pub sp_client_id: String, + + /// service provider endpoint where the response will be sent + pub acs_url: String, + + /// service provider endpoint where the idp should send log out requests + pub slo_url: String, + + /// customer's technical contact for saml configuration + pub technical_contact_email: String, + + /// optional request signing public certificate (base64 encoded der file) + pub public_cert: Option, +} + +impl From for SamlIdentityProvider { + fn from(saml_idp: model::SamlIdentityProvider) -> Self { + Self { + identity: saml_idp.identity(), + 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, + public_cert: saml_idp.public_cert, + } + } +} + // ORGANIZATIONS /// Client view of an [`Organization`] diff --git a/nexus/tests/integration_tests/data/rsa-key-1-private.b64 b/nexus/tests/integration_tests/data/rsa-key-1-private.b64 new file mode 100644 index 00000000000..2cea76b087b --- /dev/null +++ b/nexus/tests/integration_tests/data/rsa-key-1-private.b64 @@ -0,0 +1 @@ +MIIEpAIBAAKCAQEAp6RueynV/RybX8qsfh+DgUtFXIB3hNJaWNzpMkAGXPsmfycz5eBRpKr1kalTVde0HImBHMDH4ye/BXb7+KVUOxJSAzPOlXe5BLUvSBJ3h9zIrIAns9IFd785PJJJsFlEfaH6fb6TGlrfQSmrXCVKb17YIgw7miQNhoZFZG0+qEE6bmSu2zjtppX8/k4d5fCi/b5tdBvSy/GZAnBa6gweQeIH4Akt0jLZrUrpUY3GOLlZ/nVFhYwwaDMh2GMtZow44U2G1YhAkTTzBADk60+FXgrtPdzV2w2hnD5TU3pQAjVZikiRQw8fg8PVKCI8AZ9NFnsaDm1hpG/W7r1fste2dwIDAQABAoIBAQCYU+NH6qXUzk+oZSMDn2MA8wJdoSX4/KK3qFQFIwQlLNi4JUkVEhVdiTKGXtOoZs30OEWneMyobY83Sfx+3MuCuYzn+AU474ag7nm+BXmzbDyz8echkC8DtjAuB8cJhLOlbK+N3sMP6Y5/SXu5yPCv7gB6P59Q2n2nxQ38yP9sJhBmKqEJULDYvtgSz9dwwoymIF9XI1fnB+XnxMYrXpAfna9cJr4vgrB+xV+gtNJmNz+5+8UfDcWhkL9H622cOfFFgFc80ncFjpjHGueDef36uciQIdU6buvY15p3vgwYHC+KXkMGlciNSo1wKYRhk3+nSPYBenZPQz5cCXX1aisxAoGBAMjRtCGwbVBtmAKpkvOTvONwrfPWiSDT9GQqPUOT12GaxNPeCc/mt2sEUFfUlBtGt6P6KeLuGzY+m0veejwkIiPbNTsO8ocdneGSP7NTzD5hKkKcU7wVFKaxJgXB8yhl5wHYpcUUfiR+YJ0Oe2VsaHMxVgpA+veHGs6AiuJbfNQlAoGBANW09PWHGBdOMhXwpyPRjZSt7e5rQXMvkygsO1HNILtjXTWvYXMGwl9xutKHw3rEbmEKDG+NtGpgHDitIN1ZLu5p1MzKIOF5aZfE8tFSCTU0VaSUGHAIubLycJjRwpD8F9V6QhvwsWXlWOymuqvJbegCZFPYAMcv58YD8jiQQ29rAoGAZ2YSKYZ9wnurWTOWxnO7PiA2cOZ1lMGNhEV7ZeApdcgKsEwTIUjaB/Agrhh2adTvmS6lgoK24Cc8LsROi8jPC0dDETWRCqDlOc/jnKH49+VvrPxw4Na521o7CZvjZ1mQqBK0x9TVXlTzyeo6/u3ime09L+plTi3yT4FAAWy5yUECgYEAmbNXRsuN4R0lWrBFlbZebKOXb5WGcjCyVv9Q/qlYtE1nuXfUz6T54Slr44Uva7mhZXuTrBuvuZ48Ter+qxQ8c8579XoeoevvrO9CcJfe9XwZaI/274TnAjPqFY8vr5UQE0KmD3BSNmX4SeQ0d98cg/RMchz1mkzzFnC6IkJnrdcCgYBHx123AEMwDOBp5iiGFyGuNI7+7p8tVLoEQkW8fpZ4B4IDwmu88ccOVMZyacrcmhysVDFby2xlQC1aL9avOESmjyJcKmfgfBkTCQTMhSgrn5rPfkZlPp2TtEkxXWmgCaZz1Rvn6ZoAgqVA/bnhpgUfyTTNlEN76IaXIm1A8YnP/w== \ No newline at end of file diff --git a/nexus/tests/integration_tests/data/rsa-key-1-public.b64 b/nexus/tests/integration_tests/data/rsa-key-1-public.b64 new file mode 100644 index 00000000000..d69091bf6f3 --- /dev/null +++ b/nexus/tests/integration_tests/data/rsa-key-1-public.b64 @@ -0,0 +1 @@ +MIIDXDCCAkSgAwIBAgIUHf/dNzLiRs3w3l0/Wlc/mBfH5/cwDQYJKoZIhvcNAQELBQAwRjELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1TYW4gRnJhbmNpc2NvMRIwEAYDVQQDEwlzYW1sLnRlc3QwHhcNMjIwNTEwMTk0OTAwWhcNMjIwNTEwMTk1MDAwWjBGMQswCQYDVQQGEwJVUzELMAkGA1UECBMCQ0ExFjAUBgNVBAcTDVNhbiBGcmFuY2lzY28xEjAQBgNVBAMTCXNhbWwudGVzdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKekbnsp1f0cm1/KrH4fg4FLRVyAd4TSWljc6TJABlz7Jn8nM+XgUaSq9ZGpU1XXtByJgRzAx+MnvwV2+/ilVDsSUgMzzpV3uQS1L0gSd4fcyKyAJ7PSBXe/OTySSbBZRH2h+n2+kxpa30Epq1wlSm9e2CIMO5okDYaGRWRtPqhBOm5krts47aaV/P5OHeXwov2+bXQb0svxmQJwWuoMHkHiB+AJLdIy2a1K6VGNxji5Wf51RYWMMGgzIdhjLWaMOOFNhtWIQJE08wQA5OtPhV4K7T3c1dsNoZw+U1N6UAI1WYpIkUMPH4PD1SgiPAGfTRZ7Gg5tYaRv1u69X7LXtncCAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFKjukf8rhxLMo3MYYu5sfN1axY8SMA0GCSqGSIb3DQEBCwUAA4IBAQA/p2O/CGHp5EWEd3Yd47a24NLuX6ZZXNS+TlRmP0CSsq/vFKMXUiY9Q3p3GcuyHUux3/wPtZCLxgPcmE/m/3SWzHHKB+yEyqEzDJqHVKUZ8Y5tfXAHdXmEHkALpvLsWBNvde4HGFCZBQBiA4gTsi2qT6VfGM5OSa4HHWX8RURiMxjiE7Hz7KM+ZipJGsXfIKqMeeBx0Ke1Q7X3aM/ugIdBkY+tJd2MtqyPU5yqDJFZvrb0yV6uRdyS4AYIJ0x7pfMQWxz9S1LqQn2Cl2pab+EDiJtsmrjZTBlgG2rJ1p4PDBUbi8dChUjJnigFgwhTS5SI3iUMOWjsA3CXEFnlpgXM \ No newline at end of file diff --git a/nexus/tests/integration_tests/data/rsa-key-2-private.b64 b/nexus/tests/integration_tests/data/rsa-key-2-private.b64 new file mode 100644 index 00000000000..555c3d9ed32 --- /dev/null +++ b/nexus/tests/integration_tests/data/rsa-key-2-private.b64 @@ -0,0 +1 @@ +MIIJJwIBAAKCAgEApYGDHUgwhA3K7X1zjdBdbZbE98nCUjyOw0lbCH6RvTaWQI7kriMJlb6ped5C0Q/RvORM+rbkZEh3eu1x1dncaU9z0CDT7bux65HHf5M127mrDZ51LAMhWI2tv+KraZUNS7aCYv2nLXuF2p7z7z2yEW46CRmhtxiwjvY0FjN2gRgjcHT6eweFLzOo8y3b5YR3zFsoi1wusDVwoJ00WCdu4eELOhldjPP+vWuHbKV2sjRC9LdApEF+PzF325nthBUmrOqh21sOltthdw8wmM6Lf77UZqUKUy5UMPJ3adpZQETP2Ak6ISKJ3aqrDaPuDdOgozs9EZK1D6y7OGwpl1uBt2gEmGAQsQ9lH1e+aVxsWASVdi+5E8GJSCS5YEhigIUWh/VyYrBntVDJDs5gk/5vY+XVQ0lw2acukpbeW6GFWYo3zziBCHm+SdQ/0OhVgRUeEWqjPF1+CGFmdOimUKPp9oYP40v72NPbRa8WB2kVZcVXqxcqG4RQK+VpwcKeWTtI2lnvfKOwofePjNzc1JMrtBV7iflQWB6RD+qgvDceUWX5P3VGdQfcZEr+shBtkcnPOgDsRKTxJ5cCeQXFLL0Z2RI0lB22dqs/1fj/7wfd62JBhJBI8xcthfzgs8CNKLIgj1OtS/2+xiNUo7Zv6l3UQCbxo8dhpzAAb3MHqIJ6QAUCAwEAAQKCAgBwV3HtPWQZLteQzvfRyh6w1YdLfrsVYR+ytSdCo88/NT9WAOh+vy+xYmLdYx3NlMRUSE9sWxq6a2oWmfgMJb50CUdeffn8w8voT+Kv2PfU9rmCHA4C2vkWh8zpk+2wVElbHD5y/SQuPktEc2K3ARTOuhhQtwJLK0olMD941mPZCs57dhvTyO4BdTp4HqfFql4666Ggvui+GPgjPbIbKGEel8gsHq2ekLxYTRX2jHX+TnUocP9Cv2X3dRebi2dqoYTIGNfW8n77rVwCGeBtyL1t79Vy+xIAFlF1jA+8XUb51fuS8+huN2iHe2JydtSOtBi00/AG7qNSSXgnu1ub7rQj98ULk4vsX9NAQcIm/Ds5NOAPDZ0UkDOKeE1kg1sSDPZulbwKTQUuJ3xejcLFFOi6YM9C86NzW9gv34U/EbNiSpPEQ2NMQUflkTP38xeYAV3lTRRLVKgQKEBCTjc8xA42HbGrbsZFMYU/fosYJSz4XTthW2mDrSGDb4CUcS/l5p5p0AG3gs0DTJ7BCrivSkjOIIv45riLJ4uV2ZMFpaMOkO4bBZhBglbNiUqw2JucDD4F2Fcw2GftX06PMjq5QD71dUCsIRJMpkC7f0edzwdtxhexMWG6OOSfbzNpcFO+UjDRIqTkseUcjzI8qTDIuUSdpYm4u9zbOP6ECUqEYYM/PQKCAQEAyQFHNQ643kurP77iw8eBFwxZLXF917GSlQmvpMWa4EcYIGdR4wenuCaH3PSt0tJKz8RbwzySF53t6CHYhsbN4BHeYQN1PiTLuEFcqR7WWXUsOHS/pPsZJZekPGyQozNWMC9ohVT5HQgMQ56ntT9QspNynjnmVaGlnm/fHUNcLFeHGzLhNZr2/mUeLd0NxBXF+aeRnFyFq7P8MOBYKgsPdpCyNRR7j6lLdOrX9UxQFvpTyuBWoJKFF5UXttQ3kz4etQ1IFuRo+PBvqTKJCfa91CYZ2EiSvj+rwoihDr70wM5WbC7ox8WRwDoRjOqr5kEJwdRo2bjRE1DTeiOKxwSJewKCAQEA0snRwp0ZyKE/cYivmL2ToxRB/x6X7//lgsAaha3XqlIRBZHFyHJFikGpt99D9RUvumCwicaNwuNzMvxOThF9IlX3D88cR2DxCukpH8Q3RpdqDllehSXngNEBJeUmSTe0T4xCE+BkjV8AVXlZeWtMwsbmH4cLgi0hqT/3jGsLM1hsve/JcqC8jmFdBnkO0AGBD1hrPENNNtfSGPbu3QNldB1jpsKwRX4iR3wt1IFISGieDlU3JR2RLLQBV+hYhnH8djKKIKsbYkbTlq1a/xw0DhKgsqfgWMBpPAOINrpFPeeSynGNcL336KBr5XrCZPfOVLpxTq/bTzhNODgKHrJkfwKCAQBdT9KWtvbre4VMWnk7Geq7oGflyMH619yMg6qee32ikF6K7Gv/URZzTq/Ty2LGdAl22lkfEYdgn1hKYyv5pWD9nE34C3rqFnrcVruFZ2NqtBKLQueU11ydLwB3bI7YtIRWaivDeecLqyjGW2jPo0z7Gagj/A0Jw7j3DEgvdY3cp+V4ou4ZzI7NGnQgJna1iMYXV8spI2qKg0uYBQ3otqm/CP0x1whlcNoutLb8kSi9AgjULcEJWfufLv+LSIlkOXpX4oqM1gxFRJkRmvwzO/B0BBwLY+V7nGNIM9VQ2yUUPLWyEzTNSNKYwlxTZr3WbmrxKIJkUH/+z47dLJLIQTrxAoIBAAudOSSK+W+3isJbsKku0OKsbBJ9ggukQuYYZZ21/WsSCIQRCx/HRBOhGJPcBmeLmkyfpTqCKS9yztchVcMxbX6l0+4YEEvSiJV8UVrBufX2w840mGOnugC8A18uKBTir9muNbnYpFGxyVfsTsTE577XrLhR/Y1XpUIpFx+yijRzC9LPUn8xYhJKRRDlPK6zVoQc8BOq9acu7xGXEYQ1+rISKHp4wbOihor/yZqq4Ou0b/kEMvyli2k2JdjNIYuO3kU49allJCYfFut3c8sYp7maxyXw4Aij2WiIHUo+qzAFAW6MISn0HaPAqxFC2VEs4j6C41ldkSzlQkP1uoEEfUsCggEAQsTxmDsZGGTVH+SR2uSpjJtzOku1yyVvWLrGoh90EA8oAByfQhyisJhV0k/RxzUX9EdlWkiz56TdXh3JCVQdgyIY0QdtX0WoSzY8Dzkmgl+zk9Q3CwkIVWdEDrUV3oHVk44OEaTkdpxAjGDorf6nLDcZEUwGZlvTTp+QlOFX/VpeVp6sRHpx+8X5INZHthL0FMjM52t9lQ0cQivCmV8P0qbnoSAoq4I7FAYZ91QAlTjlCNzsxKbrhYYOxmT1f2vqUfRddyLWtNze/fJ4rmD8LpRCisPnkhPqYNpJHrQKKq3dNBp9eIUkrKvHUoTSe5iH1xm+Ei0PynUGAIfkLqFUNA== \ No newline at end of file diff --git a/nexus/tests/integration_tests/data/rsa-key-2-public.b64 b/nexus/tests/integration_tests/data/rsa-key-2-public.b64 new file mode 100644 index 00000000000..2179b594a3f --- /dev/null +++ b/nexus/tests/integration_tests/data/rsa-key-2-public.b64 @@ -0,0 +1 @@ +MIIFXDCCA0SgAwIBAgIUVLhqPsB0pkEG1OklqGpKYobV7WQwDQYJKoZIhvcNAQENBQAwRjELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1TYW4gRnJhbmNpc2NvMRIwEAYDVQQDEwlzYW1sLnRlc3QwHhcNMjIwNTExMTQ0NjAwWhcNMjcwNTEwMTQ0NjAwWjBGMQswCQYDVQQGEwJVUzELMAkGA1UECBMCQ0ExFjAUBgNVBAcTDVNhbiBGcmFuY2lzY28xEjAQBgNVBAMTCXNhbWwudGVzdDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAKWBgx1IMIQNyu19c43QXW2WxPfJwlI8jsNJWwh+kb02lkCO5K4jCZW+qXneQtEP0bzkTPq25GRId3rtcdXZ3GlPc9Ag0+27seuRx3+TNdu5qw2edSwDIViNrb/iq2mVDUu2gmL9py17hdqe8+89shFuOgkZobcYsI72NBYzdoEYI3B0+nsHhS8zqPMt2+WEd8xbKItcLrA1cKCdNFgnbuHhCzoZXYzz/r1rh2yldrI0QvS3QKRBfj8xd9uZ7YQVJqzqodtbDpbbYXcPMJjOi3++1GalClMuVDDyd2naWUBEz9gJOiEiid2qqw2j7g3ToKM7PRGStQ+suzhsKZdbgbdoBJhgELEPZR9XvmlcbFgElXYvuRPBiUgkuWBIYoCFFof1cmKwZ7VQyQ7OYJP+b2Pl1UNJcNmnLpKW3luhhVmKN884gQh5vknUP9DoVYEVHhFqozxdfghhZnToplCj6faGD+NL+9jT20WvFgdpFWXFV6sXKhuEUCvlacHCnlk7SNpZ73yjsKH3j4zc3NSTK7QVe4n5UFgekQ/qoLw3HlFl+T91RnUH3GRK/rIQbZHJzzoA7ESk8SeXAnkFxSy9GdkSNJQdtnarP9X4/+8H3etiQYSQSPMXLYX84LPAjSiyII9TrUv9vsYjVKO2b+pd1EAm8aPHYacwAG9zB6iCekAFAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTR+MrnWfeQAPfMauQIbEGxkfZirjANBgkqhkiG9w0BAQ0FAAOCAgEATC0sjis5aQNZBYSS5TDHG/RHHHxuvBgErcpiNwjlh+J/s1fbBRdK8zTuxYulMvKi5PMOtwSKWhcDR1xX7gx+1Ldfh4ss0VO9JJBxKLz3B8y7EybdVMJioZ7eeYGUmpJXNqdtiuRzqUDADIiQRcyLymwyMyXpFG+tW26m5jSUYhsnYMJFYKUQo8wENrrETbQ7oJjEfDjAOQNiCKv4kCjP3ImFcXNFGqItzGvEZGUL7n6IiZvPE/ML2+CVgWTKSq7uoyvMtkHETaGq1uElxxT2Wi/zbIHltx6KOkugUJeeGhiEKztyMOFs1Lw712MYhzz8wG06j7bsZ8gDdiAlizqeSGU65NouSWzv+y7QHbxeWQB9CzC63SDVL3Ky2auB8WkbIUcZTM8N+71WRSAaco/vJW0meZLiOlwz+XWKi6f71MVZW1/8Lhv8goqKxVcALuTXziIg5lPhLaIiwsoMO/n2nyGlkr/lpnWd8Nhj6d/QB250zvj8x3SHEUdCAQws6ZYDohhm1WIcp3MA+OMUYObtGS7BtN+eP+LvFkO8046dUtMJzCPf4HW28rcUhQToK8Gmc3qRvxsRxpUi9ATItLsm1Y/UQ2QHCpWCtOQc58aHw/LERffVU9y/8xf14pKPlwgw3T9dMNNvrh+KrJ+MRJ7UHmu+TTuWFo4/Mbn0Ka3qny8= \ No newline at end of file diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index 209efa8cd21..b65bcde2131 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -273,6 +273,33 @@ lazy_static! { }; } +lazy_static! { + // Identity providers + pub static ref IDENTITY_PROVIDERS_URL: String = format!("/silos/default-silo/identity_providers"); + pub static ref SAML_IDENTITY_PROVIDERS_URL: String = format!("/silos/default-silo/saml_identity_providers"); + + 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_source: params::IdpMetadataSource::Url { 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 @@ -954,5 +981,29 @@ lazy_static! { AllowedMethod::Delete, ], }, + + /* Silo identity providers */ + + /* + VerifyEndpoint { + url: &*IDENTITY_PROVIDERS_URL, // in ignore list + visibility: Visibility::Public, + allowed_methods: vec![ + AllowedMethod::Get, + ], + }, + */ + VerifyEndpoint { + url: &*SAML_IDENTITY_PROVIDERS_URL, + visibility: Visibility::Public, + 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 99a6cee8d5f..de8999ebb66 100644 --- a/nexus/tests/integration_tests/silos.rs +++ b/nexus/tests/integration_tests/silos.rs @@ -2,21 +2,28 @@ // 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 nexus_test_utils::http_testing::{AuthnMode, NexusRequest, RequestBuilder}; use omicron_common::api::external::{IdentityMetadataCreateParams, Name}; -use omicron_nexus::external_api::views::{self, Organization, Silo}; +use omicron_nexus::authn::silos::IdentityProviderType; +use omicron_nexus::external_api::params; +use omicron_nexus::external_api::views::{ + self, IdentityProvider, Organization, SamlIdentityProvider, Silo, +}; use omicron_nexus::TestInterfaces as _; +use std::collections::HashSet; use http::method::Method; use http::StatusCode; use nexus_test_utils::resource_helpers::{ - create_organization, create_silo, grant_iam, objects_list_page_authz, + create_organization, create_silo, grant_iam, object_create, + objects_list_page_authz, }; use nexus_test_utils::ControlPlaneTestContext; use nexus_test_utils_macros::nexus_test; use omicron_nexus::authz::SiloRoles; -use omicron_nexus::external_api::params; + +use httptest::{matchers::*, responders::*, Expectation, Server}; #[nexus_test] async fn test_silos(cptestctx: &ControlPlaneTestContext) { @@ -188,3 +195,942 @@ 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; + + // List providers - should be none + let providers = objects_list_page_authz::( + client, + "/silos/default-silo/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/default-silo/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_source: params::IdpMetadataSource::Url { + 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/default-silo/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_source: params::IdpMetadataSource::Url { + 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/default-silo/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; + + const SILO_NAME: &str = "saml-silo"; + create_silo(&client, SILO_NAME, true).await; + + 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_source: params::IdpMetadataSource::Url { + 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 + } + } + + // 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 +#[nexus_test] +async fn test_deleting_a_silo_deletes_the_idp( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + const SILO_NAME: &str = "default-silo"; + + 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_source: params::IdpMetadataSource::Url { + 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 silo is gone + let nexus = &cptestctx.server.apictx.nexus; + + let response = 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; + + assert!(response.is_err()); + match response.err().unwrap() { + omicron_common::api::external::Error::ObjectNotFound { + type_name, + lookup_type: _, + } => { + assert_eq!( + type_name, + omicron_common::api::external::ResourceType::Silo + ); + } + + _ => { + assert!(false); + } + } + + // No SSO redirect expected + NexusRequest::new( + RequestBuilder::new( + client, + Method::GET, + &format!("/login/{}/{}", SILO_NAME, silo_saml_idp.identity.name), + ) + .expect_status(Some(StatusCode::NOT_FOUND)), + ) + .execute() + .await + .expect("expected success"); +} + +// 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; + + const SILO_NAME: &str = "saml-silo"; + create_silo(&client, SILO_NAME, true).await; + + let saml_idp_descriptor = { + let mut saml_idp_descriptor = SAML_IDP_DESCRIPTOR.to_string(); + saml_idp_descriptor.truncate(100); + saml_idp_descriptor + }; + + let server = Server::run(); + server.expect( + Expectation::matching(request::method_path("GET", "/descriptor")) + .respond_with(status_code(200).body(saml_idp_descriptor)), + ); + + NexusRequest::new( + RequestBuilder::new( + client, + Method::POST, + &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_source: params::IdpMetadataSource::Url { + 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; + + const SILO_NAME: &str = "saml-silo"; + create_silo(&client, SILO_NAME, true).await; + + 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_source: params::IdpMetadataSource::Url { + 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 +#[nexus_test] +async fn test_create_a_hidden_silo_saml_idp( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + 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_source: params::IdpMetadataSource::Url { + 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/hidden/{}", 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=", + )); +} + +// 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; + + const SILO_NAME: &str = "saml-silo"; + create_silo(&client, SILO_NAME, true).await; + + let server = Server::run(); + server.expect( + Expectation::matching(request::method_path("GET", "/descriptor")) + .respond_with(status_code(404).body("no descriptor found")), + ); + + NexusRequest::new( + RequestBuilder::new( + client, + Method::POST, + &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_source: params::IdpMetadataSource::Url { + 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; + + const SILO_NAME: &str = "saml-silo"; + create_silo(&client, SILO_NAME, true).await; + + 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_source: params::IdpMetadataSource::Url { + 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"); +} + +// Create a Silo with a SAML IdP document string +#[nexus_test] +async fn test_saml_idp_metadata_data_valid( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + create_silo(&client, "blahblah", true).await; + + let silo_saml_idp: SamlIdentityProvider = object_create( + client, + "/silos/blahblah/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_source: params::IdpMetadataSource::Base64EncodedXml { + data: base64::encode(SAML_IDP_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/blahblah/{}", 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=", + )); +} + +// Fail to create a Silo with a SAML IdP document string that isn't valid +#[nexus_test] +async fn test_saml_idp_metadata_data_truncated( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + create_silo(&client, "blahblah", true).await; + + NexusRequest::new( + RequestBuilder::new( + client, + Method::POST, + &"/silos/blahblah/saml_identity_providers", + ) + .body(Some(¶ms::SamlIdentityProviderCreate { + identity: IdentityMetadataCreateParams { + name: "some-totally-real-saml-provider" + .to_string() + .parse() + .unwrap(), + description: "a demo provider".to_string(), + }, + + idp_metadata_source: params::IdpMetadataSource::Base64EncodedXml { + data: base64::encode({ + let mut saml_idp_descriptor = + SAML_IDP_DESCRIPTOR.to_string(); + saml_idp_descriptor.truncate(100); + saml_idp_descriptor + }), + }, + + 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 from bad base64 data +#[nexus_test] +async fn test_saml_idp_metadata_data_invalid( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + const SILO_NAME: &str = "saml-silo"; + create_silo(&client, SILO_NAME, true).await; + + 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_source: params::IdpMetadataSource::Base64EncodedXml { + data: "bad data".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)), + ); + + const SILO_NAME: &str = "saml-silo"; + create_silo(&client, SILO_NAME, true).await; + + 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_source: params::IdpMetadataSource::Url { + 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)), + ); + + const SILO_NAME: &str = "saml-silo"; + create_silo(&client, SILO_NAME, true).await; + + 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_source: params::IdpMetadataSource::Url { + 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 58912093fc8..45e8d670732 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; @@ -139,6 +140,12 @@ lazy_static! { ), ); + server.expect( + Expectation::matching(request::method_path("GET", "/descriptor")) + .times(1..) + .respond_with(status_code(200).body(SAML_IDP_DESCRIPTOR)), + ); + server }; @@ -194,6 +201,11 @@ lazy_static! { url: "/images", body: serde_json::to_value(&*DEMO_GLOBAL_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 045e59a1080..dca62c02611 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 +consume_credentials /login/{silo_name}/{provider_name} +login /login/{silo_name}/{provider_name} + API operations found with tag "metrics" OPERATION ID URL PATH timeseries_schema_get /timeseries/schema @@ -110,8 +115,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_get_silo_policy /silos/{silo_name}/policy silos_post /silos diff --git a/nexus/tests/output/uncovered-authz-endpoints.txt b/nexus/tests/output/uncovered-authz-endpoints.txt index 89a0dcc71ed..929f0054e84 100644 --- a/nexus/tests/output/uncovered-authz-endpoints.txt +++ b/nexus/tests/output/uncovered-authz-endpoints.txt @@ -1,8 +1,11 @@ API endpoints with no coverage in authz tests: sshkeys_delete_key (delete "/session/me/sshkeys/{ssh_key_name}") +login (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}") +silos_get_identity_providers (get "/silos/{silo_name}/identity_providers") spoof_login (post "/login") +consume_credentials (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 8959a31f474..e87bdcc7d3b 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": "login", + "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", + "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": [ @@ -4980,6 +5058,76 @@ } } }, + "/silos/{silo_name}/identity_providers": { + "get": { + "tags": [ + "silos" + ], + "summary": "List Silo identity providers", + "operationId": "silos_get_identity_providers", + "parameters": [ + { + "in": "path", + "name": "silo_name", + "description": "The silo's unique name.", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + }, + { + "in": "query", + "name": "limit", + "description": "Maximum number of items returned by a single call", + "schema": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 1 + }, + "style": "form" + }, + { + "in": "query", + "name": "page_token", + "description": "Token returned by previous call to retrieve 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}/policy": { "get": { "tags": [ @@ -5066,6 +5214,104 @@ } } }, + "/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": [ @@ -5282,6 +5528,23 @@ "histogram_f64" ] }, + "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": [ { @@ -5917,6 +6180,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", @@ -5924,6 +6263,46 @@ "silo_user" ] }, + "IdpMetadataSource": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "url" + ] + }, + "url": { + "type": "string" + } + }, + "required": [ + "type", + "url" + ] + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "base64_encoded_xml" + ] + } + }, + "required": [ + "data", + "type" + ] + } + ] + }, "Image": { "description": "Client view of project Images", "type": "object", @@ -7541,6 +7920,135 @@ } ] }, + "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" + }, + "name": { + "description": "unique, mutable, user-controlled identifier for each resource", + "allOf": [ + { + "$ref": "#/components/schemas/Name" + } + ] + }, + "public_cert": { + "nullable": true, + "description": "optional request signing public certificate (base64 encoded der file)", + "type": "string" + }, + "slo_url": { + "description": "service provider endpoint where the idp should send log out requests", + "type": "string" + }, + "sp_client_id": { + "description": "sp's client id", + "type": "string" + }, + "technical_contact_email": { + "description": "customer's technical contact for saml configuration", + "type": "string" + }, + "time_created": { + "description": "timestamp when this resource was created", + "type": "string", + "format": "date-time" + }, + "time_modified": { + "description": "timestamp when this resource was last modified", + "type": "string", + "format": "date-time" + } + }, + "required": [ + "acs_url", + "description", + "id", + "idp_entity_id", + "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_source": { + "description": "the source of an identity provider metadata descriptor", + "allOf": [ + { + "$ref": "#/components/schemas/IdpMetadataSource" + } + ] + }, + "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_source", + "name", + "slo_url", + "sp_client_id", + "technical_contact_email" + ] + }, "SessionUser": { "description": "Client view of currently authed user.", "type": "object", @@ -8984,6 +9492,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 d73d7a90cfc..1972c187876 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