From 09706a9e652de70ff2e44458d141513a94681d1e Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Mon, 30 May 2022 16:12:52 -0400 Subject: [PATCH 01/10] Support for SAML as a Silo IdP, part 1 Add the db schemas, models, and some endpoints to support configuring a SAML IdP for a Silo. Enough functionality is here to support the first step of SP-initiated SAML login flow: concretely, created a signed SAML request, and sending that to the IdP. More work is required to support receiving the SAML IdP's response, and actually creating and logging in the user. Two tables were added here: one that relates a silo to a list of typed identity providers, and one for SAML configuration. The order of columns in the silo table was corrected to match the DB model's field order. Support for serializing and deserializing SAML XML is provided by the samael crate, but for now use Cargo patch to get a specific branch from an oxidecomputer fork. A PR was made upstream so follow up will be required after that is merged. --- Cargo.lock | 313 ++++++- Cargo.toml | 3 + common/src/api/external/mod.rs | 33 + common/src/sql/dbinit.sql | 72 +- nexus/Cargo.toml | 5 + nexus/src/app/image.rs | 25 +- nexus/src/app/silo.rs | 125 ++- nexus/src/authn/mod.rs | 1 + nexus/src/authn/silos.rs | 208 +++++ nexus/src/authz/api_resources.rs | 16 + nexus/src/authz/omicron.polar | 59 ++ nexus/src/authz/oso_generic.rs | 6 + nexus/src/db/datastore.rs | 112 ++- nexus/src/db/lookup.rs | 25 +- nexus/src/db/model/identity_provider.rs | 73 ++ nexus/src/db/model/mod.rs | 2 + nexus/src/db/schema.rs | 43 +- nexus/src/external_api/console_api.rs | 101 ++- nexus/src/external_api/http_entrypoints.rs | 118 ++- nexus/src/external_api/params.rs | 206 ++++- nexus/src/external_api/tag-config.json | 6 + nexus/src/external_api/views.rs | 86 ++ .../data/rsa-key-1-private.b64 | 1 + .../data/rsa-key-1-public.b64 | 1 + .../data/rsa-key-2-private.b64 | 1 + .../data/rsa-key-2-public.b64 | 1 + nexus/tests/integration_tests/endpoints.rs | 51 ++ nexus/tests/integration_tests/silos.rs | 784 +++++++++++++++++- nexus/tests/integration_tests/unauthorized.rs | 12 + nexus/tests/output/nexus_tags.txt | 8 + .../output/uncovered-authz-endpoints.txt | 3 + openapi/nexus.json | 480 +++++++++++ smf/nexus/config.toml | 2 + tools/install_prerequisites.sh | 8 + 34 files changed, 2932 insertions(+), 58 deletions(-) create mode 100644 nexus/src/authn/silos.rs create mode 100644 nexus/src/db/model/identity_provider.rs create mode 100644 nexus/tests/integration_tests/data/rsa-key-1-private.b64 create mode 100644 nexus/tests/integration_tests/data/rsa-key-1-public.b64 create mode 100644 nexus/tests/integration_tests/data/rsa-key-2-private.b64 create mode 100644 nexus/tests/integration_tests/data/rsa-key-2-public.b64 diff --git a/Cargo.lock b/Cargo.lock index 34695142945..567fb4dff9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -274,6 +274,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" @@ -412,6 +435,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" @@ -447,6 +479,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" @@ -770,7 +813,7 @@ dependencies = [ "toml", "tracing", "usdt", - "uuid", + "uuid 1.0.0", "version_check", ] @@ -804,7 +847,7 @@ dependencies = [ "tokio-rustls", "toml", "twox-hash", - "uuid", + "uuid 1.0.0", ] [[package]] @@ -818,7 +861,7 @@ dependencies = [ "crucible-common", "serde", "tokio-util 0.7.2", - "uuid", + "uuid 1.0.0", ] [[package]] @@ -931,14 +974,38 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b365fabc795046672053e29c954733ec3b05e4be654ab130fe8f1f94d7051f35" +[[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]] @@ -955,13 +1022,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", ] @@ -994,6 +1072,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" @@ -1023,7 +1132,7 @@ dependencies = [ "pq-sys", "r2d2", "serde_json", - "uuid", + "uuid 1.0.0", ] [[package]] @@ -1035,7 +1144,7 @@ dependencies = [ "lock_api", "serde", "usdt", - "uuid", + "uuid 1.0.0", ] [[package]] @@ -1180,7 +1289,7 @@ dependencies = [ "tokio-rustls", "toml", "usdt", - "uuid", + "uuid 1.0.0", "version_check", ] @@ -1640,7 +1749,7 @@ dependencies = [ "serde", "serde_json", "slog", - "uuid", + "uuid 1.0.0", ] [[package]] @@ -1674,7 +1783,7 @@ dependencies = [ "tokio-stream", "tokio-tungstenite", "usdt", - "uuid", + "uuid 1.0.0", ] [[package]] @@ -2269,12 +2378,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" @@ -2302,6 +2427,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" @@ -2409,6 +2545,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" @@ -2533,7 +2675,7 @@ dependencies = [ "serde", "serde_json", "slog", - "uuid", + "uuid 1.0.0", ] [[package]] @@ -2560,7 +2702,7 @@ dependencies = [ "serde_json", "slog", "tokio", - "uuid", + "uuid 1.0.0", ] [[package]] @@ -2580,6 +2722,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" @@ -2719,7 +2871,7 @@ dependencies = [ "thiserror", "tokio", "tokio-postgres", - "uuid", + "uuid 1.0.0", ] [[package]] @@ -2766,7 +2918,7 @@ dependencies = [ "tokio", "tokio-tungstenite", "toml", - "uuid", + "uuid 1.0.0", ] [[package]] @@ -2811,6 +2963,9 @@ dependencies = [ "omicron-test-utils", "openapi-lint", "openapiv3", + "openssl", + "openssl-probe", + "openssl-sys", "oso", "oximeter", "oximeter-client", @@ -2824,6 +2979,7 @@ dependencies = [ "regex", "reqwest", "ring", + "samael", "schemars", "serde", "serde_json", @@ -2844,7 +3000,7 @@ dependencies = [ "toml", "tough", "usdt", - "uuid", + "uuid 1.0.0", ] [[package]] @@ -2929,7 +3085,7 @@ dependencies = [ "tokio", "tokio-util 0.7.2", "toml", - "uuid", + "uuid 1.0.0", "vsss-rs", "zone", ] @@ -3128,7 +3284,7 @@ dependencies = [ "serde", "thiserror", "trybuild", - "uuid", + "uuid 1.0.0", ] [[package]] @@ -3141,7 +3297,7 @@ dependencies = [ "reqwest", "serde", "slog", - "uuid", + "uuid 1.0.0", ] [[package]] @@ -3167,7 +3323,7 @@ dependencies = [ "thiserror", "tokio", "toml", - "uuid", + "uuid 1.0.0", ] [[package]] @@ -3194,7 +3350,7 @@ dependencies = [ "structopt", "thiserror", "tokio", - "uuid", + "uuid 1.0.0", ] [[package]] @@ -3206,7 +3362,7 @@ dependencies = [ "futures", "http", "oximeter", - "uuid", + "uuid 1.0.0", ] [[package]] @@ -3235,7 +3391,7 @@ dependencies = [ "slog-dtrace", "thiserror", "tokio", - "uuid", + "uuid 1.0.0", ] [[package]] @@ -3347,6 +3503,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" @@ -3583,7 +3745,7 @@ dependencies = [ "postgres-protocol", "serde", "serde_json", - "uuid", + "uuid 1.0.0", ] [[package]] @@ -3776,7 +3938,7 @@ dependencies = [ "slog", "structopt", "thiserror", - "uuid", + "uuid 1.0.0", ] [[package]] @@ -3785,6 +3947,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" @@ -4096,6 +4268,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" @@ -4205,6 +4383,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" @@ -4245,7 +4449,7 @@ dependencies = [ "schemars_derive", "serde", "serde_json", - "uuid", + "uuid 1.0.0", ] [[package]] @@ -4474,7 +4678,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", @@ -4560,6 +4764,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" @@ -4646,7 +4856,7 @@ dependencies = [ "reqwest", "serde", "slog", - "uuid", + "uuid 1.0.0", ] [[package]] @@ -4781,6 +4991,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" @@ -4788,7 +5008,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]] @@ -4925,7 +5156,7 @@ dependencies = [ "slog", "thiserror", "tokio", - "uuid", + "uuid 1.0.0", ] [[package]] @@ -5453,7 +5684,7 @@ dependencies = [ "serde", "serde_json", "serde_plain", - "snafu", + "snafu 0.7.0", "tempfile", "untrusted", "url", @@ -5856,6 +6087,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", +] + [[package]] name = "uuid" version = "1.0.0" @@ -6056,6 +6296,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 3a5890ecd3b..2307f13f4bf 100644 --- a/common/src/api/external/mod.rs +++ b/common/src/api/external/mod.rs @@ -489,6 +489,8 @@ pub enum ResourceType { Fleet, Silo, SiloUser, + IdentityProvider, + SamlIdentityProvider, SshKey, ConsoleSession, GlobalImage, @@ -1864,6 +1866,37 @@ impl std::fmt::Display for Digest { } } +/// A SAML configuration specifies both identity provider and service provider +/// details +#[derive(Clone, Debug, Serialize, JsonSchema, Deserialize)] +pub struct SamlIdentityProvider { + #[serde(flatten)] + pub identity: IdentityMetadata, + + /// url where identity provider metadata descriptor is + pub idp_metadata_url: String, + + /// identity provider's entity id + pub idp_entity_id: String, + + /// service provider's client id + pub sp_client_id: String, + + /// service provider endpoint where the response will be sent + pub acs_url: String, + + /// service provider endpoint where the identity provider should send log out requests + pub slo_url: String, + + /// customer's technical contact for SAML configuration + pub technical_contact_email: String, + + /// optional request signing key pair (base64 encoded der files) + pub public_cert: Option, + #[serde(skip_serializing)] + pub private_key: Option, +} + #[cfg(test)] mod test { use super::IpNet; diff --git a/common/src/sql/dbinit.sql b/common/src/sql/dbinit.sql index 23c98529fab..6eac46fbc15 100644 --- a/common/src/sql/dbinit.sql +++ b/common/src/sql/dbinit.sql @@ -183,16 +183,14 @@ CREATE TABLE omicron.public.volume ( CREATE TABLE omicron.public.silo ( /* Identity metadata */ id UUID PRIMARY KEY, - name STRING(128) NOT NULL, description STRING(512) NOT NULL, - - discoverable BOOL NOT NULL, - time_created TIMESTAMPTZ NOT NULL, time_modified TIMESTAMPTZ NOT NULL, time_deleted TIMESTAMPTZ, + discoverable BOOL NOT NULL, + /* child resource generation number, per RFD 192 */ rcgen INT NOT NULL ); @@ -206,7 +204,6 @@ CREATE UNIQUE INDEX ON omicron.public.silo ( * Silo users */ CREATE TABLE omicron.public.silo_user ( - /* silo user id */ id UUID PRIMARY KEY, silo_id UUID NOT NULL, @@ -223,6 +220,71 @@ CREATE INDEX ON omicron.public.silo_user ( ) WHERE time_deleted IS NULL; +CREATE TYPE omicron.public.provider_type AS ENUM ( + 'saml' +); + +/* + * Silo identity provider list + */ +CREATE TABLE omicron.public.identity_provider ( + /* Identity metadata */ + id UUID PRIMARY KEY, + name STRING(128) NOT NULL, + description STRING(512) NOT NULL, + time_created TIMESTAMPTZ NOT NULL, + time_modified TIMESTAMPTZ NOT NULL, + time_deleted TIMESTAMPTZ, + + silo_id UUID NOT NULL, + provider_type omicron.public.provider_type NOT NULL +); + +CREATE INDEX ON omicron.public.identity_provider ( + id, + silo_id +) WHERE + time_deleted IS NULL; + +CREATE INDEX ON omicron.public.identity_provider ( + name, + silo_id +) WHERE + time_deleted IS NULL; + +/* + * Silo SAML identity provider + */ +CREATE TABLE omicron.public.saml_identity_provider ( + /* Identity metadata */ + id UUID PRIMARY KEY, + name STRING(128) NOT NULL, + description STRING(512) NOT NULL, + time_created TIMESTAMPTZ NOT NULL, + time_modified TIMESTAMPTZ NOT NULL, + time_deleted TIMESTAMPTZ, + + silo_id UUID NOT NULL, + + idp_metadata_url TEXT NOT NULL, + idp_metadata_document_string TEXT NOT NULL, + + idp_entity_id TEXT NOT NULL, + sp_client_id TEXT NOT NULL, + acs_url TEXT NOT NULL, + slo_url TEXT NOT NULL, + technical_contact_email TEXT NOT NULL, + + public_cert TEXT, + private_key TEXT +); + +CREATE INDEX ON omicron.public.saml_identity_provider ( + id, + silo_id +) WHERE + time_deleted IS NULL; + /* * Users' public SSH keys, per RFD 44 */ diff --git a/nexus/Cargo.toml b/nexus/Cargo.toml index fd809a0acac..7fa730e206a 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.13.0" diff --git a/nexus/src/app/image.rs b/nexus/src/app/image.rs index ada0b3bb601..64eea34d2e3 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..dea2b636641 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,127 @@ 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?; + + // Download the SAML IdP descriptor, and write it into the DB. This is + // so that it can be deserialized later. + // + // Importantly, do this only once and store it. It would introduce + // attack surface to download it each time it was required. + let dur = std::time::Duration::from_secs(5); + let client = reqwest::ClientBuilder::new() + .connect_timeout(dur) + .timeout(dur) + .build() + .map_err(|e| { + Error::internal_error(&format!( + "failed to build reqwest client: {}", + e + )) + })?; + + let response = + client.get(¶ms.idp_metadata_url).send().await.map_err(|e| { + Error::InvalidValue { + label: String::from("url"), + message: format!("error querying url: {}", e), + } + })?; + + if !response.status().is_success() { + return Err(Error::InvalidValue { + label: String::from("url"), + message: format!( + "querying url returned: {}", + response.status() + ), + }); + } + + let idp_metadata_document_string = + response.text().await.map_err(|e| Error::InvalidValue { + label: String::from("url"), + message: format!("error getting text from url: {}", e), + })?; + + let provider = db::model::SamlIdentityProvider { + identity: db::model::SamlIdentityProviderIdentity::new( + Uuid::new_v4(), + params.identity, + ), + silo_id: db_silo.id(), + + idp_metadata_url: params.idp_metadata_url, + idp_metadata_document_string, + + idp_entity_id: params.idp_entity_id, + sp_client_id: params.sp_client_id, + acs_url: params.acs_url, + slo_url: params.slo_url, + technical_contact_email: params.technical_contact_email, + public_cert: params + .signing_keypair + .as_ref() + .map(|x| x.public_cert.clone()), + private_key: params + .signing_keypair + .as_ref() + .map(|x| x.private_key.clone()), + }; + + let _authn_provider: authn::silos::SamlIdentityProvider = + provider.clone().try_into().map_err(|e: anyhow::Error| + // If an error is encountered converting from the model to the + // authn type here, this is a request error: something about the + // parameters of this request doesn't work. + Error::invalid_request(&e.to_string()))?; + + self.db_datastore + .saml_identity_provider_create(opctx, &authz_silo, provider) + .await + } + + pub async fn saml_identity_provider_fetch( + &self, + opctx: &OpContext, + silo_name: &Name, + provider_name: &Name, + ) -> LookupResult { + let (.., saml_identity_provider) = + LookupPath::new(opctx, &self.datastore()) + .silo_name(silo_name) + .saml_identity_provider_name(provider_name) + .fetch() + .await?; + Ok(saml_identity_provider) + } } 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 5c844e5806c..c1b58bb4c01 100644 --- a/nexus/src/db/datastore.rs +++ b/nexus/src/db/datastore.rs @@ -50,8 +50,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, @@ -3111,9 +3111,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..ad126f7632a --- /dev/null +++ b/nexus/src/db/model/identity_provider.rs @@ -0,0 +1,73 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use crate::db::identity::Resource; +use crate::db::model::impl_enum_type; +use crate::db::schema::{identity_provider, saml_identity_provider}; +use db_macros::Resource; +use omicron_common::api::external; + +use serde::{Deserialize, Serialize}; +use std::io::Write; +use uuid::Uuid; + +impl_enum_type!( + #[derive(SqlType, Debug, QueryId)] + #[diesel(postgres_type(name = "provider_type"))] + pub struct IdentityProviderTypeEnum; + + #[derive(Copy, Clone, Debug, AsExpression, FromSqlRow, Serialize, Deserialize, PartialEq)] + #[diesel(sql_type = IdentityProviderTypeEnum)] + pub enum IdentityProviderType; + + // Enum values + Saml => b"saml" +); + +#[derive(Queryable, Insertable, Clone, Debug, Selectable, Resource)] +#[diesel(table_name = identity_provider)] +pub struct IdentityProvider { + // Note identity here matches the specific identity provider configuration + #[diesel(embed)] + pub identity: IdentityProviderIdentity, + + pub silo_id: Uuid, + pub provider_type: IdentityProviderType, +} + +#[derive(Queryable, Insertable, Clone, Debug, Selectable, Resource)] +#[diesel(table_name = saml_identity_provider)] +pub struct SamlIdentityProvider { + #[diesel(embed)] + pub identity: SamlIdentityProviderIdentity, + + pub silo_id: Uuid, + + pub idp_metadata_url: String, + pub idp_metadata_document_string: String, + + pub idp_entity_id: String, + pub sp_client_id: String, + pub acs_url: String, + pub slo_url: String, + pub technical_contact_email: String, + pub public_cert: Option, + pub private_key: Option, +} + +impl Into for SamlIdentityProvider { + fn into(self) -> external::SamlIdentityProvider { + external::SamlIdentityProvider { + identity: self.identity(), + idp_metadata_url: self.idp_metadata_url.clone(), + idp_entity_id: self.idp_entity_id.clone(), + sp_client_id: self.sp_client_id.clone(), + acs_url: self.acs_url.clone(), + slo_url: self.slo_url.clone(), + technical_contact_email: self.technical_contact_email.clone(), + public_cert: self.public_cert, + private_key: self.private_key, + } + } +} diff --git a/nexus/src/db/model/mod.rs b/nexus/src/db/model/mod.rs index 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 66d25ea7a8f..3fd96e115b2 100644 --- a/nexus/src/db/schema.rs +++ b/nexus/src/db/schema.rs @@ -138,10 +138,11 @@ table! { id -> Uuid, name -> Text, description -> Text, - discoverable -> Bool, time_created -> Timestamptz, time_modified -> Timestamptz, time_deleted -> Nullable, + + discoverable -> Bool, rcgen -> Int8, } } @@ -157,6 +158,44 @@ table! { } } +table! { + identity_provider (silo_id, id) { + id -> Uuid, + name -> Text, + description -> Text, + time_created -> Timestamptz, + time_modified -> Timestamptz, + time_deleted -> Nullable, + + silo_id -> Uuid, + provider_type -> crate::db::model::IdentityProviderTypeEnum, + } +} + +table! { + saml_identity_provider (id) { + id -> Uuid, + name -> Text, + description -> Text, + time_created -> Timestamptz, + time_modified -> Timestamptz, + time_deleted -> Nullable, + + silo_id -> Uuid, + + idp_metadata_url -> Text, + idp_metadata_document_string -> Text, + + idp_entity_id -> Text, + sp_client_id -> Text, + acs_url -> Text, + slo_url -> Text, + technical_contact_email -> Text, + public_cert -> Nullable, + private_key -> Nullable, + } +} + table! { ssh_key (id) { id -> Uuid, @@ -466,6 +505,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..a9bb7d36bcd 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 ask_user_to_login_to_provider( + rqctx: Arc>>, + path_params: Path, +) -> Result, HttpError> { + let apictx = rqctx.context(); + let handler = async { + let nexus = &apictx.nexus; + let path_params = path_params.into_inner(); + + // Use opctx_external_authn because this request will be + // unauthenticated. + let opctx = nexus.opctx_external_authn(); + + let identity_provider = IdentityProviderType::lookup( + &nexus.datastore(), + &opctx, + &path_params.silo_name, + &path_params.provider_name, + ) + .await?; + + match identity_provider { + IdentityProviderType::Saml(saml_identity_provider) => { + let relay_state = None; + let sign_in_url = + saml_identity_provider.sign_in_url(relay_state).map_err( + |e| HttpError::for_internal_error(e.to_string()), + )?; + + Ok(Response::builder() + .status(StatusCode::FOUND) + .header(http::header::LOCATION, sign_in_url) + .body("".into())?) + } + } + }; + // TODO 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_and_authn_user( + rqctx: Arc>>, + path_params: Path, +) -> Result, HttpError> { + let apictx = rqctx.context(); + let handler = async { + let nexus = &apictx.nexus; + let path_params = path_params.into_inner(); + + // Use opctx_external_authn because this request will be + // unauthenticated. + let opctx = nexus.opctx_external_authn(); + + let identity_provider = IdentityProviderType::lookup( + &nexus.datastore(), + &opctx, + &path_params.silo_name, + &path_params.provider_name, + ) + .await?; + + match identity_provider { + IdentityProviderType::Saml(_saml_identity_provider) => { + todo!() + } + } + }; + // TODO 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 61d358d5edb..19ddd925c73 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::ask_user_to_login_to_provider)?; + api.register(console_api::consume_credentials_and_authn_user)?; + 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 f654b393caf..91f9750a7a1 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 uuid::Uuid; @@ -24,6 +27,207 @@ pub struct SiloCreate { pub discoverable: bool, } +// Silo identity providers + +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct DerEncodedKeyPair { + /// request signing public certificate (base64 encoded der file) + #[serde(deserialize_with = "x509_cert_from_base64_encoded_der")] + pub public_cert: String, + + /// request signing private key (base64 encoded der file) + #[serde(deserialize_with = "key_from_base64_encoded_der")] + pub private_key: String, +} + +struct X509CertVisitor; + +impl<'de> Visitor<'de> for X509CertVisitor { + type Value = String; + + fn expecting( + &self, + formatter: &mut std::fmt::Formatter, + ) -> std::fmt::Result { + formatter.write_str("a DER formatted X509 certificate as a string of base64 encoded bytes") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + let raw_bytes = base64::decode(&value.as_bytes()).map_err(|e| { + de::Error::custom(format!( + "could not base64 decode public_cert: {}", + e + )) + })?; + let _parsed = + openssl::x509::X509::from_der(&raw_bytes).map_err(|e| { + de::Error::custom(format!( + "public_cert is not recognized as a X509 certificate: {}", + e + )) + })?; + + Ok(value.to_string()) + } +} + +fn x509_cert_from_base64_encoded_der<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + deserializer.deserialize_str(X509CertVisitor) +} + +struct KeyVisitor; + +impl<'de> Visitor<'de> for KeyVisitor { + type Value = String; + + fn expecting( + &self, + formatter: &mut std::fmt::Formatter, + ) -> std::fmt::Result { + formatter.write_str( + "a DER formatted key as a string of base64 encoded bytes", + ) + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + let raw_bytes = base64::decode(&value).map_err(|e| { + de::Error::custom(format!( + "could not base64 decode private_key: {}", + e + )) + })?; + + // TODO: samael does not support ECDSA, update to generic PKey type when it does + //let _parsed = openssl::pkey::PKey::private_key_from_der(&raw_bytes) + // .map_err(|e| de::Error::custom(format!("could not base64 decode private_key: {}", e)))?; + + let parsed = openssl::rsa::Rsa::private_key_from_der(&raw_bytes) + .map_err(|e| { + de::Error::custom(format!( + "private_key is not recognized as a RSA private key: {}", + e + )) + })?; + let _parsed = openssl::pkey::PKey::from_rsa(parsed).map_err(|e| { + de::Error::custom(format!( + "private_key is not recognized as a RSA private key: {}", + e + )) + })?; + + Ok(value.to_string()) + } +} + +fn key_from_base64_encoded_der<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + deserializer.deserialize_str(KeyVisitor) +} + +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct SamlIdentityProviderCreate { + #[serde(flatten)] + pub identity: IdentityMetadataCreateParams, + + /// url where identity provider metadata descriptor is + pub idp_metadata_url: String, + + /// idp's entity id + pub idp_entity_id: String, + + /// sp's client id + pub sp_client_id: String, + + /// service provider endpoint where the response will be sent + pub acs_url: String, + + /// service provider endpoint where the idp should send log out requests + pub slo_url: String, + + /// customer's technical contact for saml configuration + pub technical_contact_email: String, + + /// optional request signing key pair + #[serde(deserialize_with = "validate_key_pair")] + pub signing_keypair: Option, +} + +/// sign some junk data and validate it with the key pair +fn sign_junk_data(key_pair: &DerEncodedKeyPair) -> Result<(), anyhow::Error> { + let private_key = { + let raw_bytes = base64::decode(&key_pair.private_key)?; + // TODO: samael does not support ECDSA, update to generic PKey type when it does + //let parsed = openssl::pkey::PKey::private_key_from_der(&raw_bytes)?; + let parsed = openssl::rsa::Rsa::private_key_from_der(&raw_bytes)?; + let parsed = openssl::pkey::PKey::from_rsa(parsed)?; + parsed + }; + + let public_key = { + let raw_bytes = base64::decode(&key_pair.public_cert)?; + let parsed = openssl::x509::X509::from_der(&raw_bytes)?; + parsed.public_key()? + }; + + let mut signer = openssl::sign::Signer::new( + openssl::hash::MessageDigest::sha256(), + &private_key.as_ref(), + )?; + + let some_junk_data = b"this is some junk data"; + + signer.update(some_junk_data)?; + let signature = signer.sign_to_vec()?; + + let mut verifier = openssl::sign::Verifier::new( + openssl::hash::MessageDigest::sha256(), + &public_key, + )?; + + verifier.update(some_junk_data)?; + + if !verifier.verify(&signature)? { + anyhow::bail!("signature validation failed!"); + } + + Ok(()) +} + +fn validate_key_pair<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let v = Option::::deserialize(deserializer)?; + + if let Some(ref key_pair) = v { + if let Err(e) = sign_junk_data(&key_pair) { + return Err(de::Error::custom(format!( + "data signed with key not verified with certificate! {}", + e + ))); + } + } + + Ok(v) +} + // ORGANIZATIONS /// Create-time parameters for an [`Organization`](crate::external_api::views::Organization) diff --git a/nexus/src/external_api/tag-config.json b/nexus/src/external_api/tag-config.json index 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 3c2e1914c16..d9d98e817d6 100644 --- a/nexus/src/external_api/views.rs +++ b/nexus/src/external_api/views.rs @@ -36,6 +36,92 @@ impl Into for model::Silo { } } +// IDENTITY PROVIDER + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum IdentityProviderType { + /// SAML identity provider + Saml, +} + +impl Into for model::IdentityProviderType { + fn into(self) -> IdentityProviderType { + match self { + model::IdentityProviderType::Saml => IdentityProviderType::Saml, + } + } +} + +/// Client view of an ['IdentityProvider'] +#[derive(ObjectIdentity, Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct IdentityProvider { + #[serde(flatten)] + pub identity: IdentityMetadata, + + /// Identity provider type + pub provider_type: IdentityProviderType, +} + +impl Into for model::IdentityProvider { + fn into(self) -> IdentityProvider { + IdentityProvider { + identity: self.identity(), + provider_type: self.provider_type.into(), + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct DerEncodedKeyPair { + /// request signing public certificate (base64 encoded der file) + pub public_cert: String, +} + +#[derive(ObjectIdentity, Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct SamlIdentityProvider { + #[serde(flatten)] + pub identity: IdentityMetadata, + + /// url where identity provider metadata descriptor is + pub idp_metadata_url: String, + + /// idp's entity id + pub idp_entity_id: String, + + /// sp's client id + pub sp_client_id: String, + + /// service provider endpoint where the response will be sent + pub acs_url: String, + + /// service provider endpoint where the idp should send log out requests + pub slo_url: String, + + /// customer's technical contact for saml configuration + pub technical_contact_email: String, + + /// optional request signing key pair + pub signing_keypair: Option, +} + +impl From for SamlIdentityProvider { + fn from(saml_idp: model::SamlIdentityProvider) -> Self { + Self { + identity: saml_idp.identity(), + idp_metadata_url: saml_idp.idp_metadata_url, + idp_entity_id: saml_idp.idp_entity_id, + sp_client_id: saml_idp.sp_client_id, + acs_url: saml_idp.acs_url, + slo_url: saml_idp.slo_url, + technical_contact_email: saml_idp.technical_contact_email, + signing_keypair: saml_idp + .public_cert + .map(|x| DerEncodedKeyPair { public_cert: x }), + } + } +} + // ORGANIZATIONS /// Client view of an [`Organization`] diff --git a/nexus/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 ff547f13c1f..2c55a85d21d 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -257,6 +257,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_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 @@ -938,5 +965,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..3b1645ef5ad 100644 --- a/nexus/tests/integration_tests/silos.rs +++ b/nexus/tests/integration_tests/silos.rs @@ -2,21 +2,30 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. -use nexus_test_utils::http_testing::{AuthnMode, NexusRequest}; -use omicron_common::api::external::{IdentityMetadataCreateParams, Name}; -use omicron_nexus::external_api::views::{self, Organization, Silo}; +use nexus_test_utils::http_testing::{AuthnMode, NexusRequest, RequestBuilder}; +use omicron_common::api::external::{ + IdentityMetadataCreateParams, Name, SamlIdentityProvider, +}; +use omicron_nexus::authn::silos::IdentityProviderType; +use omicron_nexus::external_api::params; +use omicron_nexus::external_api::views::{ + self, IdentityProvider, Organization, 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 +197,768 @@ 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_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_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_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_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_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_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_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_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_url: "htttps://fake.url".to_string(), + + idp_entity_id: "entity_id".to_string(), + sp_client_id: "client_id".to_string(), + acs_url: "http://acs".to_string(), + slo_url: "http://slo".to_string(), + technical_contact_email: "technical@fake".to_string(), + + signing_keypair: None, + })) + .expect_status(Some(StatusCode::BAD_REQUEST)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("unexpected success"); +} + +// TODO samael does not support ECDSA yet, add tests when it does +const RSA_KEY_1_PUBLIC: &str = include_str!("data/rsa-key-1-public.b64"); +const RSA_KEY_1_PRIVATE: &str = include_str!("data/rsa-key-1-private.b64"); +const RSA_KEY_2_PUBLIC: &str = include_str!("data/rsa-key-2-public.b64"); +const RSA_KEY_2_PRIVATE: &str = include_str!("data/rsa-key-2-private.b64"); + +#[nexus_test] +async fn test_saml_idp_reject_keypair(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + + let saml_idp_descriptor = SAML_IDP_DESCRIPTOR; + + // Spin up a server but expect it never to be accessed + let server = Server::run(); + server.expect( + Expectation::matching(request::method_path("GET", "/descriptor")) + .times(0) + .respond_with(status_code(200).body(saml_idp_descriptor)), + ); + + 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_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_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 f71824a69af..0149835987c 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_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..e3b88e1a940 100644 --- a/nexus/tests/output/nexus_tags.txt +++ b/nexus/tests/output/nexus_tags.txt @@ -48,6 +48,11 @@ project_instances_instance_stop /organizations/{organization_name}/proj project_instances_migrate_instance /organizations/{organization_name}/projects/{project_name}/instances/{instance_name}/migrate project_instances_post /organizations/{organization_name}/projects/{project_name}/instances +API operations found with tag "login" +OPERATION ID URL PATH +ask_user_to_login_to_provider /login/{silo_name}/{provider_name} +consume_credentials_and_authn_user /login/{silo_name}/{provider_name} + API operations found with tag "metrics" OPERATION ID URL PATH timeseries_schema_get /timeseries/schema @@ -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..cc3b60a034c 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}") +ask_user_to_login_to_provider (get "/login/{silo_name}/{provider_name}") session_me (get "/session/me") sshkeys_get (get "/session/me/sshkeys") sshkeys_get_key (get "/session/me/sshkeys/{ssh_key_name}") +silos_get_identity_providers (get "/silos/{silo_name}/identity_providers") spoof_login (post "/login") +consume_credentials_and_authn_user (post "/login/{silo_name}/{provider_name}") logout (post "/logout") sshkeys_post (post "/session/me/sshkeys") diff --git a/openapi/nexus.json b/openapi/nexus.json index cbfb4f14d9d..ce73e98a56e 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -404,6 +404,84 @@ } } }, + "/login/{silo_name}/{provider_name}": { + "get": { + "tags": [ + "login" + ], + "summary": "Ask the user to login to their identity provider", + "description": "Either display a page asking a user for their credentials, or redirect them to their identity provider.", + "operationId": "ask_user_to_login_to_provider", + "parameters": [ + { + "in": "path", + "name": "provider_name", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + }, + { + "in": "path", + "name": "silo_name", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + } + ], + "responses": { + "default": { + "description": "", + "content": { + "*/*": { + "schema": {} + } + } + } + } + }, + "post": { + "tags": [ + "login" + ], + "summary": "Consume some sort of credentials, and authenticate a user.", + "description": "Either receive a username and password, or some sort of identity provider data (like a SAMLResponse). Use these to set the user's session cookie.", + "operationId": "consume_credentials_and_authn_user", + "parameters": [ + { + "in": "path", + "name": "provider_name", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + }, + { + "in": "path", + "name": "silo_name", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + } + ], + "responses": { + "default": { + "description": "", + "content": { + "*/*": { + "schema": {} + } + } + } + } + } + }, "/logout": { "post": { "tags": [ @@ -4979,6 +5057,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": [ @@ -5065,6 +5213,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": [ @@ -5281,6 +5527,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": [ { @@ -5856,6 +6119,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", @@ -7460,6 +7799,140 @@ } ] }, + "SamlIdentityProvider": { + "description": "Identity-related metadata that's included in nearly all public API objects", + "type": "object", + "properties": { + "acs_url": { + "description": "service provider endpoint where the response will be sent", + "type": "string" + }, + "description": { + "description": "human-readable free-form text about a resource", + "type": "string" + }, + "id": { + "description": "unique, immutable, system-controlled identifier for each resource", + "type": "string", + "format": "uuid" + }, + "idp_entity_id": { + "description": "idp's entity id", + "type": "string" + }, + "idp_metadata_url": { + "description": "url where identity provider metadata descriptor is", + "type": "string" + }, + "name": { + "description": "unique, mutable, user-controlled identifier for each resource", + "allOf": [ + { + "$ref": "#/components/schemas/Name" + } + ] + }, + "signing_keypair": { + "nullable": true, + "description": "optional request signing key pair", + "allOf": [ + { + "$ref": "#/components/schemas/DerEncodedKeyPair" + } + ] + }, + "slo_url": { + "description": "service provider endpoint where the idp should send log out requests", + "type": "string" + }, + "sp_client_id": { + "description": "sp's client id", + "type": "string" + }, + "technical_contact_email": { + "description": "customer's technical contact for saml configuration", + "type": "string" + }, + "time_created": { + "description": "timestamp when this resource was created", + "type": "string", + "format": "date-time" + }, + "time_modified": { + "description": "timestamp when this resource was last modified", + "type": "string", + "format": "date-time" + } + }, + "required": [ + "acs_url", + "description", + "id", + "idp_entity_id", + "idp_metadata_url", + "name", + "slo_url", + "sp_client_id", + "technical_contact_email", + "time_created", + "time_modified" + ] + }, + "SamlIdentityProviderCreate": { + "description": "Create-time identity-related parameters", + "type": "object", + "properties": { + "acs_url": { + "description": "service provider endpoint where the response will be sent", + "type": "string" + }, + "description": { + "type": "string" + }, + "idp_entity_id": { + "description": "idp's entity id", + "type": "string" + }, + "idp_metadata_url": { + "description": "url where identity provider metadata descriptor is", + "type": "string" + }, + "name": { + "$ref": "#/components/schemas/Name" + }, + "signing_keypair": { + "nullable": true, + "description": "optional request signing key pair", + "allOf": [ + { + "$ref": "#/components/schemas/DerEncodedKeyPair" + } + ] + }, + "slo_url": { + "description": "service provider endpoint where the idp should send log out requests", + "type": "string" + }, + "sp_client_id": { + "description": "sp's client id", + "type": "string" + }, + "technical_contact_email": { + "description": "customer's technical contact for saml configuration", + "type": "string" + } + }, + "required": [ + "acs_url", + "description", + "idp_entity_id", + "idp_metadata_url", + "name", + "slo_url", + "sp_client_id", + "technical_contact_email" + ] + }, "SessionUser": { "description": "Client view of currently authed user.", "type": "object", @@ -8903,6 +9376,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 From 5258e0a6ca717b6c440456e1eb6017b4cadca40c Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Mon, 30 May 2022 19:17:15 -0400 Subject: [PATCH 02/10] install pre-reqs in more jobs --- .github/workflows/rust.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 From 27b743a639241948f49e348549f1fa9dab50c99a Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Fri, 3 Jun 2022 17:00:44 -0400 Subject: [PATCH 03/10] accept SAML IDP descriptor doc as base64 string accept a SAML IDP descriptor document as a base64 encoded string, in addition to fetching it from a URL. don't store the url in the database anymore. --- common/src/api/external/mod.rs | 3 - common/src/sql/dbinit.sql | 1 - nexus/src/app/silo.rs | 93 +++++----- nexus/src/db/model/identity_provider.rs | 2 - nexus/src/db/schema.rs | 1 - nexus/src/external_api/params.rs | 11 +- nexus/src/external_api/views.rs | 4 - nexus/tests/integration_tests/endpoints.rs | 2 +- nexus/tests/integration_tests/silos.rs | 196 +++++++++++++++++++-- openapi/nexus.json | 57 +++++- 10 files changed, 296 insertions(+), 74 deletions(-) diff --git a/common/src/api/external/mod.rs b/common/src/api/external/mod.rs index 2307f13f4bf..f94e5b3925c 100644 --- a/common/src/api/external/mod.rs +++ b/common/src/api/external/mod.rs @@ -1873,9 +1873,6 @@ pub struct SamlIdentityProvider { #[serde(flatten)] pub identity: IdentityMetadata, - /// url where identity provider metadata descriptor is - pub idp_metadata_url: String, - /// identity provider's entity id pub idp_entity_id: String, diff --git a/common/src/sql/dbinit.sql b/common/src/sql/dbinit.sql index 6eac46fbc15..f23b0bc5a92 100644 --- a/common/src/sql/dbinit.sql +++ b/common/src/sql/dbinit.sql @@ -266,7 +266,6 @@ CREATE TABLE omicron.public.saml_identity_provider ( silo_id UUID NOT NULL, - idp_metadata_url TEXT NOT NULL, idp_metadata_document_string TEXT NOT NULL, idp_entity_id TEXT NOT NULL, diff --git a/nexus/src/app/silo.rs b/nexus/src/app/silo.rs index dea2b636641..940481c4944 100644 --- a/nexus/src/app/silo.rs +++ b/nexus/src/app/silo.rs @@ -229,46 +229,60 @@ impl super::Nexus { .fetch_for(authz::Action::CreateChild) .await?; - // Download the SAML IdP descriptor, and write it into the DB. This is - // so that it can be deserialized later. - // - // Importantly, do this only once and store it. It would introduce - // attack surface to download it each time it was required. - let dur = std::time::Duration::from_secs(5); - let client = reqwest::ClientBuilder::new() - .connect_timeout(dur) - .timeout(dur) - .build() - .map_err(|e| { - Error::internal_error(&format!( - "failed to build reqwest client: {}", - e - )) - })?; - - let response = - client.get(¶ms.idp_metadata_url).send().await.map_err(|e| { - Error::InvalidValue { - label: String::from("url"), - message: format!("error querying url: {}", e), + 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() + ), + }); } - })?; - - if !response.status().is_success() { - return Err(Error::InvalidValue { - label: String::from("url"), - message: format!( - "querying url returned: {}", - response.status() - ), - }); - } - - let idp_metadata_document_string = - response.text().await.map_err(|e| Error::InvalidValue { - label: String::from("url"), - message: format!("error getting text from url: {}", e), - })?; + + 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( @@ -277,7 +291,6 @@ impl super::Nexus { ), silo_id: db_silo.id(), - idp_metadata_url: params.idp_metadata_url, idp_metadata_document_string, idp_entity_id: params.idp_entity_id, diff --git a/nexus/src/db/model/identity_provider.rs b/nexus/src/db/model/identity_provider.rs index ad126f7632a..002dd386a79 100644 --- a/nexus/src/db/model/identity_provider.rs +++ b/nexus/src/db/model/identity_provider.rs @@ -44,7 +44,6 @@ pub struct SamlIdentityProvider { pub silo_id: Uuid, - pub idp_metadata_url: String, pub idp_metadata_document_string: String, pub idp_entity_id: String, @@ -60,7 +59,6 @@ impl Into for SamlIdentityProvider { fn into(self) -> external::SamlIdentityProvider { external::SamlIdentityProvider { identity: self.identity(), - idp_metadata_url: self.idp_metadata_url.clone(), idp_entity_id: self.idp_entity_id.clone(), sp_client_id: self.sp_client_id.clone(), acs_url: self.acs_url.clone(), diff --git a/nexus/src/db/schema.rs b/nexus/src/db/schema.rs index 3fd96e115b2..ffa887027a9 100644 --- a/nexus/src/db/schema.rs +++ b/nexus/src/db/schema.rs @@ -183,7 +183,6 @@ table! { silo_id -> Uuid, - idp_metadata_url -> Text, idp_metadata_document_string -> Text, idp_entity_id -> Text, diff --git a/nexus/src/external_api/params.rs b/nexus/src/external_api/params.rs index 91f9750a7a1..5193f5b55c7 100644 --- a/nexus/src/external_api/params.rs +++ b/nexus/src/external_api/params.rs @@ -139,13 +139,20 @@ where 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, - /// url where identity provider metadata descriptor is - pub idp_metadata_url: String, + /// the source of an identity provider metadata descriptor + pub idp_metadata_source: IdpMetadataSource, /// idp's entity id pub idp_entity_id: String, diff --git a/nexus/src/external_api/views.rs b/nexus/src/external_api/views.rs index d9d98e817d6..42a1fc9dfa9 100644 --- a/nexus/src/external_api/views.rs +++ b/nexus/src/external_api/views.rs @@ -83,9 +83,6 @@ pub struct SamlIdentityProvider { #[serde(flatten)] pub identity: IdentityMetadata, - /// url where identity provider metadata descriptor is - pub idp_metadata_url: String, - /// idp's entity id pub idp_entity_id: String, @@ -109,7 +106,6 @@ impl From for SamlIdentityProvider { fn from(saml_idp: model::SamlIdentityProvider) -> Self { Self { identity: saml_idp.identity(), - idp_metadata_url: saml_idp.idp_metadata_url, idp_entity_id: saml_idp.idp_entity_id, sp_client_id: saml_idp.sp_client_id, acs_url: saml_idp.acs_url, diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index 2c55a85d21d..c5dfa8bfc4b 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -272,7 +272,7 @@ lazy_static! { description: "a demo provider".to_string(), }, - idp_metadata_url: HTTP_SERVER.url("/descriptor").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(), diff --git a/nexus/tests/integration_tests/silos.rs b/nexus/tests/integration_tests/silos.rs index 3b1645ef5ad..83d6bb0eea4 100644 --- a/nexus/tests/integration_tests/silos.rs +++ b/nexus/tests/integration_tests/silos.rs @@ -235,7 +235,9 @@ async fn test_listing_identity_providers(cptestctx: &ControlPlaneTestContext) { description: "a demo provider".to_string(), }, - idp_metadata_url: server.url("/descriptor").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(), @@ -260,7 +262,9 @@ async fn test_listing_identity_providers(cptestctx: &ControlPlaneTestContext) { description: "a demo provider".to_string(), }, - idp_metadata_url: server.url("/descriptor").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(), @@ -367,7 +371,9 @@ async fn test_create_a_saml_idp(cptestctx: &ControlPlaneTestContext) { description: "a demo provider".to_string(), }, - idp_metadata_url: server.url("/descriptor").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(), @@ -470,7 +476,9 @@ async fn test_deleting_a_silo_deletes_the_idp( description: "a demo provider".to_string(), }, - idp_metadata_url: server.url("/descriptor").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(), @@ -575,7 +583,9 @@ async fn test_create_a_saml_idp_invalid_descriptor_truncated( description: "a demo provider".to_string(), }, - idp_metadata_url: server.url("/descriptor").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(), @@ -641,7 +651,9 @@ async fn test_create_a_saml_idp_invalid_descriptor_no_redirect_binding( description: "a demo provider".to_string(), }, - idp_metadata_url: server.url("/descriptor").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(), @@ -689,7 +701,9 @@ async fn test_create_a_hidden_silo_saml_idp( description: "a demo provider".to_string(), }, - idp_metadata_url: server.url("/descriptor").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(), @@ -753,7 +767,9 @@ async fn test_saml_idp_metadata_url_404(cptestctx: &ControlPlaneTestContext) { description: "a demo provider".to_string(), }, - idp_metadata_url: server.url("/descriptor").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(), @@ -796,7 +812,161 @@ async fn test_saml_idp_metadata_url_invalid( description: "a demo provider".to_string(), }, - idp_metadata_url: "htttps://fake.url".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(), @@ -890,7 +1060,9 @@ async fn test_saml_idp_reject_keypair(cptestctx: &ControlPlaneTestContext) { description: "a demo provider".to_string(), }, - idp_metadata_url: server.url("/descriptor").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(), @@ -942,7 +1114,9 @@ async fn test_saml_idp_rsa_keypair_ok(cptestctx: &ControlPlaneTestContext) { description: "a demo provider".to_string(), }, - idp_metadata_url: server.url("/descriptor").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(), diff --git a/openapi/nexus.json b/openapi/nexus.json index ce73e98a56e..98f7468bfb5 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -6202,6 +6202,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_x_m_l" + ] + } + }, + "required": [ + "data", + "type" + ] + } + ] + }, "Image": { "description": "Client view of project Images", "type": "object", @@ -7820,10 +7860,6 @@ "description": "idp's entity id", "type": "string" }, - "idp_metadata_url": { - "description": "url where identity provider metadata descriptor is", - "type": "string" - }, "name": { "description": "unique, mutable, user-controlled identifier for each resource", "allOf": [ @@ -7869,7 +7905,6 @@ "description", "id", "idp_entity_id", - "idp_metadata_url", "name", "slo_url", "sp_client_id", @@ -7893,9 +7928,13 @@ "description": "idp's entity id", "type": "string" }, - "idp_metadata_url": { - "description": "url where identity provider metadata descriptor is", - "type": "string" + "idp_metadata_source": { + "description": "the source of an identity provider metadata descriptor", + "allOf": [ + { + "$ref": "#/components/schemas/IdpMetadataSource" + } + ] }, "name": { "$ref": "#/components/schemas/Name" @@ -7926,7 +7965,7 @@ "acs_url", "description", "idp_entity_id", - "idp_metadata_url", + "idp_metadata_source", "name", "slo_url", "sp_client_id", From 9ac157298892d029b9a6c760356df1c55d3db02f Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Wed, 8 Jun 2022 13:52:14 -0400 Subject: [PATCH 04/10] backticks instead of quotes --- nexus/src/external_api/views.rs | 2 +- openapi/nexus.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nexus/src/external_api/views.rs b/nexus/src/external_api/views.rs index d9a4b6d5aef..68f1b7c38d5 100644 --- a/nexus/src/external_api/views.rs +++ b/nexus/src/external_api/views.rs @@ -53,7 +53,7 @@ impl Into for model::IdentityProviderType { } } -/// Client view of an ['IdentityProvider'] +/// Client view of an [`IdentityProvider`] #[derive(ObjectIdentity, Clone, Debug, Deserialize, Serialize, JsonSchema)] pub struct IdentityProvider { #[serde(flatten)] diff --git a/openapi/nexus.json b/openapi/nexus.json index c869d609dc5..cfa74b795b4 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -6181,7 +6181,7 @@ ] }, "IdentityProvider": { - "description": "Client view of an ['IdentityProvider']", + "description": "Client view of an [`IdentityProvider`]", "type": "object", "properties": { "description": { From 32a46ae574c14b183ee31c2d9b22723385ff1837 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Wed, 8 Jun 2022 13:55:16 -0400 Subject: [PATCH 05/10] avoid snake case silliness --- nexus/src/app/silo.rs | 2 +- nexus/src/external_api/params.rs | 2 +- nexus/tests/integration_tests/silos.rs | 6 +++--- openapi/nexus.json | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/nexus/src/app/silo.rs b/nexus/src/app/silo.rs index 940481c4944..86ec5175023 100644 --- a/nexus/src/app/silo.rs +++ b/nexus/src/app/silo.rs @@ -271,7 +271,7 @@ impl super::Nexus { })? } - params::IdpMetadataSource::Base64EncodedXML { data } => { + params::IdpMetadataSource::Base64EncodedXml { data } => { let bytes = base64::decode(data).map_err(|e| Error::InvalidValue { label: String::from("data"), diff --git a/nexus/src/external_api/params.rs b/nexus/src/external_api/params.rs index 973275a6ff4..2a6cdd4e154 100644 --- a/nexus/src/external_api/params.rs +++ b/nexus/src/external_api/params.rs @@ -144,7 +144,7 @@ where #[serde(tag = "type", rename_all = "snake_case")] pub enum IdpMetadataSource { Url { url: String }, - Base64EncodedXML { data: String }, + Base64EncodedXml { data: String }, } #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] diff --git a/nexus/tests/integration_tests/silos.rs b/nexus/tests/integration_tests/silos.rs index 83d6bb0eea4..0571732d0fb 100644 --- a/nexus/tests/integration_tests/silos.rs +++ b/nexus/tests/integration_tests/silos.rs @@ -853,7 +853,7 @@ async fn test_saml_idp_metadata_data_valid( description: "a demo provider".to_string(), }, - idp_metadata_source: params::IdpMetadataSource::Base64EncodedXML { + idp_metadata_source: params::IdpMetadataSource::Base64EncodedXml { data: base64::encode(SAML_IDP_DESCRIPTOR.to_string()), }, @@ -914,7 +914,7 @@ async fn test_saml_idp_metadata_data_truncated( description: "a demo provider".to_string(), }, - idp_metadata_source: params::IdpMetadataSource::Base64EncodedXML { + idp_metadata_source: params::IdpMetadataSource::Base64EncodedXml { data: base64::encode({ let mut saml_idp_descriptor = SAML_IDP_DESCRIPTOR.to_string(); @@ -964,7 +964,7 @@ async fn test_saml_idp_metadata_data_invalid( description: "a demo provider".to_string(), }, - idp_metadata_source: params::IdpMetadataSource::Base64EncodedXML { + idp_metadata_source: params::IdpMetadataSource::Base64EncodedXml { data: "bad data".to_string(), }, diff --git a/openapi/nexus.json b/openapi/nexus.json index cfa74b795b4..7ee144cf080 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -6292,7 +6292,7 @@ "type": { "type": "string", "enum": [ - "base64_encoded_x_m_l" + "base64_encoded_xml" ] } }, From 20fdc1119bff115e067f69542f1aa2fdfd2babcd Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Wed, 8 Jun 2022 14:21:00 -0400 Subject: [PATCH 06/10] rename ask_user_to_login_to_provider and consume_credentials_and_authn_user --- nexus/src/external_api/console_api.rs | 4 ++-- nexus/src/external_api/http_entrypoints.rs | 4 ++-- nexus/tests/output/nexus_tags.txt | 4 ++-- nexus/tests/output/uncovered-authz-endpoints.txt | 4 ++-- openapi/nexus.json | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/nexus/src/external_api/console_api.rs b/nexus/src/external_api/console_api.rs index a9bb7d36bcd..6b128495a71 100644 --- a/nexus/src/external_api/console_api.rs +++ b/nexus/src/external_api/console_api.rs @@ -106,7 +106,7 @@ pub struct LoginToProviderPathParam { path = "/login/{silo_name}/{provider_name}", tags = ["login"], }] -pub async fn ask_user_to_login_to_provider( +pub async fn login( rqctx: Arc>>, path_params: Path, ) -> Result, HttpError> { @@ -156,7 +156,7 @@ pub async fn ask_user_to_login_to_provider( path = "/login/{silo_name}/{provider_name}", tags = ["login"], }] -pub async fn consume_credentials_and_authn_user( +pub async fn consume_credentials( rqctx: Arc>>, path_params: Path, ) -> Result, HttpError> { diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index ec56c0a5b20..bbc1f9a517f 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -203,8 +203,8 @@ pub fn external_api() -> NexusApiDescription { api.register(console_api::console_page)?; api.register(console_api::asset)?; - api.register(console_api::ask_user_to_login_to_provider)?; - api.register(console_api::consume_credentials_and_authn_user)?; + api.register(console_api::login)?; + api.register(console_api::consume_credentials)?; Ok(()) } diff --git a/nexus/tests/output/nexus_tags.txt b/nexus/tests/output/nexus_tags.txt index e3b88e1a940..dca62c02611 100644 --- a/nexus/tests/output/nexus_tags.txt +++ b/nexus/tests/output/nexus_tags.txt @@ -50,8 +50,8 @@ project_instances_post /organizations/{organization_name}/proj API operations found with tag "login" OPERATION ID URL PATH -ask_user_to_login_to_provider /login/{silo_name}/{provider_name} -consume_credentials_and_authn_user /login/{silo_name}/{provider_name} +consume_credentials /login/{silo_name}/{provider_name} +login /login/{silo_name}/{provider_name} API operations found with tag "metrics" OPERATION ID URL PATH diff --git a/nexus/tests/output/uncovered-authz-endpoints.txt b/nexus/tests/output/uncovered-authz-endpoints.txt index cc3b60a034c..929f0054e84 100644 --- a/nexus/tests/output/uncovered-authz-endpoints.txt +++ b/nexus/tests/output/uncovered-authz-endpoints.txt @@ -1,11 +1,11 @@ API endpoints with no coverage in authz tests: sshkeys_delete_key (delete "/session/me/sshkeys/{ssh_key_name}") -ask_user_to_login_to_provider (get "/login/{silo_name}/{provider_name}") +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_and_authn_user (post "/login/{silo_name}/{provider_name}") +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 7ee144cf080..3dd70233a7f 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -411,7 +411,7 @@ ], "summary": "Ask the user to login to their identity provider", "description": "Either display a page asking a user for their credentials, or redirect them to their identity provider.", - "operationId": "ask_user_to_login_to_provider", + "operationId": "login", "parameters": [ { "in": "path", @@ -449,7 +449,7 @@ ], "summary": "Consume some sort of credentials, and authenticate a user.", "description": "Either receive a username and password, or some sort of identity provider data (like a SAMLResponse). Use these to set the user's session cookie.", - "operationId": "consume_credentials_and_authn_user", + "operationId": "consume_credentials", "parameters": [ { "in": "path", From 147a4887eb7d6b5c0570dea34cb7ff7d9911a9c2 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Wed, 8 Jun 2022 14:39:53 -0400 Subject: [PATCH 07/10] remove unused external::SamlIdentityProvider --- common/src/api/external/mod.rs | 28 ------------------------- nexus/src/db/model/identity_provider.rs | 16 -------------- nexus/tests/integration_tests/silos.rs | 4 ++-- 3 files changed, 2 insertions(+), 46 deletions(-) diff --git a/common/src/api/external/mod.rs b/common/src/api/external/mod.rs index 4f577f27137..63d57179000 100644 --- a/common/src/api/external/mod.rs +++ b/common/src/api/external/mod.rs @@ -1869,34 +1869,6 @@ impl std::fmt::Display for Digest { } } -/// A SAML configuration specifies both identity provider and service provider -/// details -#[derive(Clone, Debug, Serialize, JsonSchema, Deserialize)] -pub struct SamlIdentityProvider { - #[serde(flatten)] - pub identity: IdentityMetadata, - - /// identity provider's entity id - pub idp_entity_id: String, - - /// service provider's client id - pub sp_client_id: String, - - /// service provider endpoint where the response will be sent - pub acs_url: String, - - /// service provider endpoint where the identity provider should send log out requests - pub slo_url: String, - - /// customer's technical contact for SAML configuration - pub technical_contact_email: String, - - /// optional request signing key pair (base64 encoded der files) - pub public_cert: Option, - #[serde(skip_serializing)] - pub private_key: Option, -} - #[cfg(test)] mod test { use super::IpNet; diff --git a/nexus/src/db/model/identity_provider.rs b/nexus/src/db/model/identity_provider.rs index 002dd386a79..267040e9d7f 100644 --- a/nexus/src/db/model/identity_provider.rs +++ b/nexus/src/db/model/identity_provider.rs @@ -2,11 +2,9 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. -use crate::db::identity::Resource; use crate::db::model::impl_enum_type; use crate::db::schema::{identity_provider, saml_identity_provider}; use db_macros::Resource; -use omicron_common::api::external; use serde::{Deserialize, Serialize}; use std::io::Write; @@ -55,17 +53,3 @@ pub struct SamlIdentityProvider { pub private_key: Option, } -impl Into for SamlIdentityProvider { - fn into(self) -> external::SamlIdentityProvider { - external::SamlIdentityProvider { - identity: self.identity(), - idp_entity_id: self.idp_entity_id.clone(), - sp_client_id: self.sp_client_id.clone(), - acs_url: self.acs_url.clone(), - slo_url: self.slo_url.clone(), - technical_contact_email: self.technical_contact_email.clone(), - public_cert: self.public_cert, - private_key: self.private_key, - } - } -} diff --git a/nexus/tests/integration_tests/silos.rs b/nexus/tests/integration_tests/silos.rs index 0571732d0fb..40ba036cd70 100644 --- a/nexus/tests/integration_tests/silos.rs +++ b/nexus/tests/integration_tests/silos.rs @@ -4,12 +4,12 @@ use nexus_test_utils::http_testing::{AuthnMode, NexusRequest, RequestBuilder}; use omicron_common::api::external::{ - IdentityMetadataCreateParams, Name, SamlIdentityProvider, + IdentityMetadataCreateParams, Name, }; use omicron_nexus::authn::silos::IdentityProviderType; use omicron_nexus::external_api::params; use omicron_nexus::external_api::views::{ - self, IdentityProvider, Organization, Silo, + self, IdentityProvider, SamlIdentityProvider, Organization, Silo, }; use omicron_nexus::TestInterfaces as _; use std::collections::HashSet; From 72a02726c33fb1eff53159426fa9f497b73613fd Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Wed, 8 Jun 2022 15:22:52 -0400 Subject: [PATCH 08/10] DerEncodedKeyPair -> just public cert --- nexus/src/external_api/views.rs | 14 +++----------- openapi/nexus.json | 10 +++------- 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/nexus/src/external_api/views.rs b/nexus/src/external_api/views.rs index 68f1b7c38d5..f758e8f4e16 100644 --- a/nexus/src/external_api/views.rs +++ b/nexus/src/external_api/views.rs @@ -72,12 +72,6 @@ impl Into for model::IdentityProvider { } } -#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] -pub struct DerEncodedKeyPair { - /// request signing public certificate (base64 encoded der file) - pub public_cert: String, -} - #[derive(ObjectIdentity, Clone, Debug, Deserialize, Serialize, JsonSchema)] pub struct SamlIdentityProvider { #[serde(flatten)] @@ -98,8 +92,8 @@ pub struct SamlIdentityProvider { /// customer's technical contact for saml configuration pub technical_contact_email: String, - /// optional request signing key pair - pub signing_keypair: Option, + /// optional request signing public certificate (base64 encoded der file) + pub public_cert: Option, } impl From for SamlIdentityProvider { @@ -111,9 +105,7 @@ impl From for SamlIdentityProvider { acs_url: saml_idp.acs_url, slo_url: saml_idp.slo_url, technical_contact_email: saml_idp.technical_contact_email, - signing_keypair: saml_idp - .public_cert - .map(|x| DerEncodedKeyPair { public_cert: x }), + public_cert: saml_idp.public_cert, } } } diff --git a/openapi/nexus.json b/openapi/nexus.json index 3dd70233a7f..e0d43f29792 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -7949,14 +7949,10 @@ } ] }, - "signing_keypair": { + "public_cert": { "nullable": true, - "description": "optional request signing key pair", - "allOf": [ - { - "$ref": "#/components/schemas/DerEncodedKeyPair" - } - ] + "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", From dbdf1a92c865f1291a0789ab82f8455219d6d513 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Wed, 8 Jun 2022 15:43:23 -0400 Subject: [PATCH 09/10] fmt --- nexus/src/db/model/identity_provider.rs | 1 - nexus/tests/integration_tests/silos.rs | 6 ++---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/nexus/src/db/model/identity_provider.rs b/nexus/src/db/model/identity_provider.rs index 267040e9d7f..04f5a7b02ea 100644 --- a/nexus/src/db/model/identity_provider.rs +++ b/nexus/src/db/model/identity_provider.rs @@ -52,4 +52,3 @@ pub struct SamlIdentityProvider { pub public_cert: Option, pub private_key: Option, } - diff --git a/nexus/tests/integration_tests/silos.rs b/nexus/tests/integration_tests/silos.rs index 40ba036cd70..de8999ebb66 100644 --- a/nexus/tests/integration_tests/silos.rs +++ b/nexus/tests/integration_tests/silos.rs @@ -3,13 +3,11 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. use nexus_test_utils::http_testing::{AuthnMode, NexusRequest, RequestBuilder}; -use omicron_common::api::external::{ - IdentityMetadataCreateParams, Name, -}; +use omicron_common::api::external::{IdentityMetadataCreateParams, Name}; use omicron_nexus::authn::silos::IdentityProviderType; use omicron_nexus::external_api::params; use omicron_nexus::external_api::views::{ - self, IdentityProvider, SamlIdentityProvider, Organization, Silo, + self, IdentityProvider, Organization, SamlIdentityProvider, Silo, }; use omicron_nexus::TestInterfaces as _; use std::collections::HashSet; From 028f40a32931effd8ec91b6fad44097192a4d2d3 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Wed, 8 Jun 2022 16:11:31 -0400 Subject: [PATCH 10/10] Cargo.lock update after merge --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 8ed321bc010..3bbaf263363 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6309,7 +6309,7 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" dependencies = [ - "getrandom", + "getrandom 0.2.6", ] [[package]]