diff --git a/Cargo.lock b/Cargo.lock index 3848089b02..80f35f3e00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -759,7 +759,7 @@ dependencies = [ "bitflags 2.9.4", "cexpr", "clang-sys", - "itertools 0.12.1", + "itertools 0.10.5", "lazy_static", "lazycell", "log", @@ -8361,6 +8361,7 @@ dependencies = [ "samael", "schemars 0.8.22", "scim2-rs", + "scim2-test-client", "semver 1.0.27", "serde", "serde_json", @@ -8955,7 +8956,6 @@ dependencies = [ "ipnet", "ipnetwork", "itertools 0.10.5", - "itertools 0.12.1", "itertools 0.13.0", "lalrpop-util", "lazy_static", @@ -11539,9 +11539,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.22" +version = "0.12.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" dependencies = [ "base64 0.22.1", "bytes", @@ -12187,10 +12187,9 @@ dependencies = [ [[package]] name = "scim2-rs" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/scim2-rs#dbe1cb7e42f7322f5cd532015e7ab430d04113b7" +source = "git+https://github.com/oxidecomputer/scim2-rs?rev=c78005db837a71f94c3b8efac9a64cfbdb2d527f#c78005db837a71f94c3b8efac9a64cfbdb2d527f" dependencies = [ "anyhow", - "async-trait", "chrono", "dropshot", "http", @@ -12199,6 +12198,23 @@ dependencies = [ "serde", "serde_json", "slog", + "trait-variant", + "unicase", + "uuid", +] + +[[package]] +name = "scim2-test-client" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/scim2-rs?rev=c78005db837a71f94c3b8efac9a64cfbdb2d527f#c78005db837a71f94c3b8efac9a64cfbdb2d527f" +dependencies = [ + "anyhow", + "clap", + "reqwest", + "scim2-rs", + "serde", + "serde_json", + "tokio", "unicase", "uuid", ] @@ -14709,6 +14725,17 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "trait-variant" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "transceiver-controller" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index cfd808dce5..1dcc09ad17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -803,7 +803,8 @@ newtype-uuid = { version = "1.3.1", default-features = false } newtype-uuid-macros = "0.1.0" omicron-uuid-kinds = { path = "uuid-kinds", features = ["serde", "schemars08", "uuid-v4"] } -scim2-rs = { git = "https://github.com/oxidecomputer/scim2-rs" } +scim2-rs = { git = "https://github.com/oxidecomputer/scim2-rs", rev = "c78005db837a71f94c3b8efac9a64cfbdb2d527f" } +scim2-test-client = { git = "https://github.com/oxidecomputer/scim2-rs", rev = "c78005db837a71f94c3b8efac9a64cfbdb2d527f" } # NOTE: The test profile inherits from the dev profile, so settings under # profile.dev get inherited. AVOID setting anything under profile.test: that diff --git a/nexus/Cargo.toml b/nexus/Cargo.toml index 3f4180bebb..98a8a1c21a 100644 --- a/nexus/Cargo.toml +++ b/nexus/Cargo.toml @@ -177,6 +177,7 @@ pretty_assertions.workspace = true rcgen.workspace = true regex.workspace = true rustls.workspace = true +scim2-test-client.workspace = true similar-asserts.workspace = true sp-sim.workspace = true strum.workspace = true diff --git a/nexus/auth/src/authz/actor.rs b/nexus/auth/src/authz/actor.rs index 440064532a..c9a8bd1a11 100644 --- a/nexus/auth/src/authz/actor.rs +++ b/nexus/auth/src/authz/actor.rs @@ -134,6 +134,18 @@ impl oso::PolarClass for AuthenticatedActor { authn::Actor::Scim { .. } => false, } }) + // Like the "is_user" guard above but reversed, this guard is used + // in the Polar file to grant permissions to a SCIM IdP actor + // without the need for a role. + .add_attribute_getter("is_scim_idp", |a: &AuthenticatedActor| { + match a.actor { + authn::Actor::SiloUser { .. } => false, + + authn::Actor::UserBuiltin { .. } => false, + + authn::Actor::Scim { .. } => true, + } + }) .add_attribute_getter("silo", |a: &AuthenticatedActor| { match a.actor { authn::Actor::SiloUser { silo_id, .. } diff --git a/nexus/auth/src/authz/api_resources.rs b/nexus/auth/src/authz/api_resources.rs index 415b5507ae..d09ce3af9c 100644 --- a/nexus/auth/src/authz/api_resources.rs +++ b/nexus/auth/src/authz/api_resources.rs @@ -770,6 +770,55 @@ impl AuthorizedResource for SiloUserList { } } +/// Synthetic resource describing the list of Groups in a Silo +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SiloGroupList(Silo); + +impl SiloGroupList { + pub fn new(silo: Silo) -> Self { + SiloGroupList(silo) + } + + pub fn silo(&self) -> &Silo { + &self.0 + } +} + +impl oso::PolarClass for SiloGroupList { + fn get_polar_class_builder() -> oso::ClassBuilder { + oso::Class::builder() + .with_equality_check() + .add_attribute_getter("silo", |list: &SiloGroupList| list.0.clone()) + } +} + +impl AuthorizedResource for SiloGroupList { + fn load_roles<'fut>( + &'fut self, + opctx: &'fut OpContext, + authn: &'fut authn::Context, + roleset: &'fut mut RoleSet, + ) -> futures::future::BoxFuture<'fut, Result<(), Error>> { + // There are no roles on this resource, but we still need to load the + // Silo-related roles. + self.silo().load_roles(opctx, authn, roleset) + } + + fn on_unauthorized( + &self, + _: &Authz, + error: Error, + _: AnyActor, + _: Action, + ) -> Error { + error + } + + fn polar_class(&self) -> oso::Class { + Self::get_polar_class() + } +} + // Note the session list and the token list have exactly the same behavior /// Synthetic resource for managing a user's sessions diff --git a/nexus/auth/src/authz/omicron.polar b/nexus/auth/src/authz/omicron.polar index b1384587b1..3864c14d1d 100644 --- a/nexus/auth/src/authz/omicron.polar +++ b/nexus/auth/src/authz/omicron.polar @@ -281,8 +281,11 @@ resource SshKey { relations = { silo_user: SiloUser }; "read" if "read" on "silo_user"; - "modify" if "modify" on "silo_user"; } +# We want to allow the user to modify the ssh key but disallow a SCIM IdP token +# from doing the same. +has_permission(actor: AuthenticatedActor, "modify", ssh_key: SshKey) + if actor.is_user and has_permission(actor, "modify", ssh_key.silo_user); has_relation(user: SiloUser, "silo_user", ssh_key: SshKey) if ssh_key.silo_user = user; @@ -630,6 +633,52 @@ has_relation(silo: Silo, "parent_silo", collection: SiloUserList) has_relation(fleet: Fleet, "parent_fleet", collection: SiloUserList) if collection.silo.fleet = fleet; +# Grant SCIM IdP actors the permissions they need on users. +has_permission(actor: AuthenticatedActor, "read", silo_user: SiloUser) + if actor.is_scim_idp and silo_user.silo in actor.silo; +has_permission(actor: AuthenticatedActor, "create_child", silo_user_list: SiloUserList) + if actor.is_scim_idp and silo_user_list.silo in actor.silo; +has_permission(actor: AuthenticatedActor, "modify", silo_user: SiloUser) + if actor.is_scim_idp and silo_user.silo in actor.silo; +has_permission(actor: AuthenticatedActor, "list_children", silo_user_list: SiloUserList) + if actor.is_scim_idp and silo_user_list.silo in actor.silo; + +# Describes the policy for creating and managing Silo groups (mostly intended +# for API-managed groups) +resource SiloGroupList { + permissions = [ "list_children", "create_child" ]; + + relations = { parent_silo: Silo, parent_fleet: Fleet }; + + # Everyone who can read the Silo (which includes all the groups in the + # Silo) can see the groups in it. + "list_children" if "read" on "parent_silo"; + + # Fleet and Silo administrators can manage the Silo's groups. This is + # one of the only areas of Silo configuration that Fleet Administrators + # have permissions on. This is also one of the few cases (so far) where + # we need to look two levels up the hierarchy to see if somebody has the + # right permission. For most other things, permissions cascade down the + # hierarchy so we only need to look at the parent. + "create_child" if "admin" on "parent_silo"; + "list_children" if "admin" on "parent_fleet"; + "create_child" if "admin" on "parent_fleet"; +} +has_relation(silo: Silo, "parent_silo", collection: SiloGroupList) + if collection.silo = silo; +has_relation(fleet: Fleet, "parent_fleet", collection: SiloGroupList) + if collection.silo.fleet = fleet; + +# Grant SCIM IdP actors the permissions they need on groups. +has_permission(actor: AuthenticatedActor, "read", silo_group: SiloGroup) + if actor.is_scim_idp and silo_group.silo in actor.silo; +has_permission(actor: AuthenticatedActor, "create_child", silo_group_list: SiloGroupList) + if actor.is_scim_idp and silo_group_list.silo in actor.silo; +has_permission(actor: AuthenticatedActor, "modify", silo_group: SiloGroup) + if actor.is_scim_idp and silo_group.silo in actor.silo; +has_permission(actor: AuthenticatedActor, "list_children", silo_group_list: SiloGroupList) + if actor.is_scim_idp and silo_group_list.silo in actor.silo; + # 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. diff --git a/nexus/auth/src/authz/oso_generic.rs b/nexus/auth/src/authz/oso_generic.rs index 86e94a224e..1ca7bfb36e 100644 --- a/nexus/auth/src/authz/oso_generic.rs +++ b/nexus/auth/src/authz/oso_generic.rs @@ -114,6 +114,7 @@ pub fn make_omicron_oso(log: &slog::Logger) -> Result { DeviceAuthRequestList::get_polar_class(), QuiesceState::get_polar_class(), SiloCertificateList::get_polar_class(), + SiloGroupList::get_polar_class(), SiloIdentityProviderList::get_polar_class(), SiloUserList::get_polar_class(), SiloUserSessionList::get_polar_class(), diff --git a/nexus/db-model/src/schema_versions.rs b/nexus/db-model/src/schema_versions.rs index f679308080..aaca96cf57 100644 --- a/nexus/db-model/src/schema_versions.rs +++ b/nexus/db-model/src/schema_versions.rs @@ -16,7 +16,7 @@ use std::{collections::BTreeMap, sync::LazyLock}; /// /// This must be updated when you change the database schema. Refer to /// schema/crdb/README.adoc in the root of this repository for details. -pub const SCHEMA_VERSION: Version = Version::new(202, 0, 0); +pub const SCHEMA_VERSION: Version = Version::new(203, 0, 0); /// List of all past database schema versions, in *reverse* order /// @@ -28,6 +28,7 @@ static KNOWN_VERSIONS: LazyLock> = LazyLock::new(|| { // | leaving the first copy as an example for the next person. // v // KnownVersion::new(next_int, "unique-dirname-with-the-sql-files"), + KnownVersion::new(203, "scim-actor-audit-log"), KnownVersion::new(202, "add-ip-to-external-ip-index"), KnownVersion::new(201, "scim-client-bearer-token"), KnownVersion::new(200, "dual-stack-network-interfaces"), diff --git a/nexus/db-queries/src/db/datastore/scim_provider_store.rs b/nexus/db-queries/src/db/datastore/scim_provider_store.rs index e07b4c4b67..b9414c4fbf 100644 --- a/nexus/db-queries/src/db/datastore/scim_provider_store.rs +++ b/nexus/db-queries/src/db/datastore/scim_provider_store.rs @@ -7,7 +7,33 @@ //! related information. Nexus uses cockroachdb as the provider store. use super::DataStore; +use crate::authz; +use crate::context::OpContext; +use crate::db::datastore::silo_group::SiloGroup; +use crate::db::datastore::silo_group::SiloGroupScim; +use crate::db::datastore::silo_user::SiloUser; +use crate::db::datastore::silo_user::SiloUserScim; +use crate::db::model; +use crate::db::model::to_db_typed_uuid; use anyhow::anyhow; +use async_bb8_diesel::AsyncRunQueryDsl; +use chrono::Utc; +use diesel::prelude::*; +use iddqd::IdOrdMap; +use nexus_auth::authz::ApiResource; +use nexus_auth::authz::ApiResourceWithRoles; +use nexus_auth::authz::SiloGroupList; +use nexus_auth::authz::SiloUserList; +use nexus_db_errors::OptionalError; +use nexus_db_lookup::DbConnection; +use nexus_db_model::DatabaseString; +use nexus_db_model::IdentityType; +use nexus_types::external_api::shared::SiloRole; +use omicron_common::api::external::LookupType; +use omicron_uuid_kinds::GenericUuid; +use omicron_uuid_kinds::SiloGroupUuid; +use omicron_uuid_kinds::SiloUserUuid; +use std::str::FromStr; use std::sync::Arc; use uuid::Uuid; @@ -15,116 +41,1760 @@ use scim2_rs::CreateGroupRequest; use scim2_rs::CreateUserRequest; use scim2_rs::FilterOp; use scim2_rs::Group; +use scim2_rs::GroupMember; use scim2_rs::ProviderStore; use scim2_rs::ProviderStoreDeleteResult; use scim2_rs::ProviderStoreError; +use scim2_rs::ResourceType; +use scim2_rs::StoredMeta; use scim2_rs::StoredParts; use scim2_rs::User; +use scim2_rs::UserGroup; +use scim2_rs::UserGroupType; -// XXX temporary until SCIM impl PR -#[allow(dead_code)] -pub struct CrdbScimProviderStore { - silo_id: Uuid, +pub struct CrdbScimProviderStore<'a> { + authz_silo: authz::Silo, datastore: Arc, + opctx: &'a OpContext, } -impl CrdbScimProviderStore { - pub fn new(silo_id: Uuid, datastore: Arc) -> Self { - CrdbScimProviderStore { silo_id, datastore } +// Define the lower case function here: some SCIM attributes like user name are +// case insensitive! +define_sql_function!( + fn lower( + a: diesel::sql_types::Nullable, + ) -> diesel::sql_types::Text +); + +fn external_error_to_provider_error( + error: omicron_common::api::external::Error, +) -> ProviderStoreError { + match error { + omicron_common::api::external::Error::Unauthenticated { .. } => { + ProviderStoreError::Scim(scim2_rs::Error::unauthorized()) + } + omicron_common::api::external::Error::Forbidden => { + ProviderStoreError::Scim(scim2_rs::Error::forbidden()) + } + err => ProviderStoreError::StoreError(err.into()), + } +} + +impl<'a> CrdbScimProviderStore<'a> { + pub fn new( + authz_silo: authz::Silo, + datastore: Arc, + opctx: &'a OpContext, + ) -> Self { + CrdbScimProviderStore { authz_silo, datastore, opctx } + } + + /// Remove a users sessions and tokens + async fn remove_user_sessions_and_tokens_in_txn( + &self, + conn: &async_bb8_diesel::Connection, + user_id: SiloUserUuid, + ) -> Result<(), diesel::result::Error> { + // Delete console sessions. + { + use nexus_db_schema::schema::console_session::dsl; + diesel::delete(dsl::console_session) + .filter(dsl::silo_user_id.eq(to_db_typed_uuid(user_id))) + .execute_async(conn) + .await?; + } + + // Delete device authentication tokens. + { + use nexus_db_schema::schema::device_access_token::dsl; + diesel::delete(dsl::device_access_token) + .filter(dsl::silo_user_id.eq(to_db_typed_uuid(user_id))) + .execute_async(conn) + .await?; + } + + Ok(()) + } + + /// Nuke sessions, tokens, etc, for deactivated users + async fn on_user_active_to_false_in_txn( + &self, + conn: &async_bb8_diesel::Connection, + user_id: SiloUserUuid, + ) -> Result<(), diesel::result::Error> { + self.remove_user_sessions_and_tokens_in_txn(conn, user_id).await + } + + async fn get_user_groups_for_user_in_txn( + &self, + conn: &async_bb8_diesel::Connection, + user_id: SiloUserUuid, + ) -> Result>, diesel::result::Error> { + use nexus_db_schema::schema::silo_group::dsl as group_dsl; + use nexus_db_schema::schema::silo_group_membership::dsl; + + struct Columns { + group_id: SiloGroupUuid, + display_name: String, + } + + let tuples: Vec = dsl::silo_group_membership + .inner_join( + group_dsl::silo_group.on(dsl::silo_group_id.eq(group_dsl::id)), + ) + .filter(group_dsl::silo_id.eq(self.authz_silo.id())) + .filter( + group_dsl::user_provision_type + .eq(model::UserProvisionType::Scim), + ) + .filter(dsl::silo_user_id.eq(to_db_typed_uuid(user_id))) + .filter(group_dsl::time_deleted.is_null()) + .select((group_dsl::id, group_dsl::display_name)) + .load_async(conn) + .await? + .into_iter() + .map(|(group_id, display_name): (Uuid, Option)| Columns { + group_id: SiloGroupUuid::from_untyped_uuid(group_id), + display_name: display_name.expect( + "the constraint `display_name_consistency` prevents a \ + group with provision type 'scim' from having a null \ + display_name", + ), + }) + .collect(); + + if tuples.is_empty() { + Ok(None) + } else { + let groups = tuples + .into_iter() + .map(|column| UserGroup { + // Note neither the scim2-rs crate or Nexus supports nested + // groups + member_type: Some(UserGroupType::Direct), + value: Some(column.group_id.to_string()), + display: Some(column.display_name), + }) + .collect(); + + Ok(Some(groups)) + } + } + + async fn get_group_members_for_group_in_txn( + &self, + conn: &async_bb8_diesel::Connection, + group_id: SiloGroupUuid, + ) -> Result>, diesel::result::Error> { + use nexus_db_schema::schema::silo_group_membership::dsl; + + let users: Vec = dsl::silo_group_membership + .filter(dsl::silo_group_id.eq(to_db_typed_uuid(group_id))) + .select(dsl::silo_user_id) + .load_async(conn) + .await?; + + if users.is_empty() { + Ok(None) + } else { + let members = users + .into_iter() + .map(|user_id| GroupMember { + // Note neither the scim2-rs crate or Nexus support nested + // groups + resource_type: Some(ResourceType::User.to_string()), + value: Some(user_id.to_string()), + }) + .collect(); + + Ok(Some(members)) + } + } + + async fn get_user_by_id_in_txn( + &self, + conn: &async_bb8_diesel::Connection, + _err: OptionalError, + user_id: SiloUserUuid, + ) -> Result>, diesel::result::Error> { + let maybe_user = { + use nexus_db_schema::schema::silo_user::dsl; + + dsl::silo_user + .filter(dsl::silo_id.eq(self.authz_silo.id())) + .filter( + dsl::user_provision_type.eq(model::UserProvisionType::Scim), + ) + .filter(dsl::id.eq(to_db_typed_uuid(user_id))) + .filter(dsl::time_deleted.is_null()) + .select(model::SiloUser::as_returning()) + .first_async(conn) + .await + .optional()? + }; + + let Some(user) = maybe_user else { + return Ok(None); + }; + + let groups: Option> = + self.get_user_groups_for_user_in_txn(conn, user_id).await?; + + let SiloUser::Scim(user) = user.into() else { + // With the user provision type filter, this should never be another + // type. + unreachable!(); + }; + + Ok(Some(convert_to_scim_user(user, groups))) + } + + async fn create_user_in_txn( + &self, + conn: &async_bb8_diesel::Connection, + err: OptionalError, + user_request: CreateUserRequest, + ) -> Result, diesel::result::Error> { + use nexus_db_schema::schema::silo_user::dsl; + + // userName is meant to be unique: If the user request is adding a + // userName that already exists, reject it + + let maybe_other_user = dsl::silo_user + .filter(dsl::silo_id.eq(self.authz_silo.id())) + .filter(dsl::user_provision_type.eq(model::UserProvisionType::Scim)) + .filter(lower(dsl::user_name).eq(lower(user_request.name.clone()))) + .filter(dsl::time_deleted.is_null()) + .select(model::SiloUser::as_returning()) + .first_async(conn) + .await + .optional()?; + + if maybe_other_user.is_some() { + return Err(err.bail( + scim2_rs::Error::conflict(format!( + "username {}", + user_request.name + )) + .into(), + )); + } + + let new_user = SiloUserScim::new( + self.authz_silo.id(), + SiloUserUuid::new_v4(), + user_request.name, + user_request.active, + user_request.external_id, + ); + + let model: model::SiloUser = new_user.clone().into(); + + diesel::insert_into(dsl::silo_user) + .values(model) + .execute_async(conn) + .await?; + + Ok(convert_to_scim_user(new_user, None)) + } + + async fn list_users_in_txn( + &self, + conn: &async_bb8_diesel::Connection, + err: OptionalError, + filter: Option, + ) -> Result>, diesel::result::Error> { + use nexus_db_schema::schema::silo_user::dsl; + + let mut query = dsl::silo_user + .filter(dsl::silo_id.eq(self.authz_silo.id())) + .filter(dsl::user_provision_type.eq(model::UserProvisionType::Scim)) + .filter(dsl::time_deleted.is_null()) + .into_boxed(); + + match filter { + Some(FilterOp::UserNameEq(username)) => { + // userName is defined as `"caseExact" : false` in RFC 7643, + // section 8.7.1 + query = query + .filter(lower(dsl::user_name).eq(lower(username.clone()))); + } + + None => { + // ok + } + + Some(_) => { + return Err(err.bail( + scim2_rs::Error::invalid_filter( + "invalid or unsupported filter".to_string(), + ) + .into(), + )); + } + } + + let users = query + .select(model::SiloUser::as_returning()) + .load_async(conn) + .await?; + + let mut returned_users = Vec::with_capacity(users.len()); + + for user in users { + let groups = self + .get_user_groups_for_user_in_txn(conn, user.identity.id.into()) + .await?; + + let SiloUser::Scim(user) = user.into() else { + // With the user provision type filter, this should never be + // another type. + unreachable!(); + }; + + returned_users.push(convert_to_scim_user(user, groups)); + } + + Ok(returned_users) + } + + async fn replace_user_in_txn( + &self, + conn: &async_bb8_diesel::Connection, + err: OptionalError, + user_id: SiloUserUuid, + user_request: CreateUserRequest, + ) -> Result, diesel::result::Error> { + use nexus_db_schema::schema::silo_user::dsl; + + let maybe_user = dsl::silo_user + .filter(dsl::silo_id.eq(self.authz_silo.id())) + .filter(dsl::user_provision_type.eq(model::UserProvisionType::Scim)) + .filter(dsl::id.eq(to_db_typed_uuid(user_id))) + .filter(dsl::time_deleted.is_null()) + .select(model::SiloUser::as_returning()) + .first_async(conn) + .await + .optional()?; + + if maybe_user.is_none() { + return Err(err + .bail(scim2_rs::Error::not_found(user_id.to_string()).into())); + } + + let CreateUserRequest { name, active, external_id, groups: _ } = + user_request; + + // userName is meant to be unique: If the user request is changing the + // userName to one that already exists, reject it + + let maybe_other_user = dsl::silo_user + .filter(dsl::silo_id.eq(self.authz_silo.id())) + .filter(dsl::user_provision_type.eq(model::UserProvisionType::Scim)) + .filter(lower(dsl::user_name).eq(lower(name.clone()))) + .filter(dsl::id.ne(to_db_typed_uuid(user_id))) + .filter(dsl::time_deleted.is_null()) + .select(model::SiloUser::as_returning()) + .first_async(conn) + .await + .optional()?; + + if maybe_other_user.is_some() { + return Err(err.bail( + scim2_rs::Error::conflict(format!("username {}", name)).into(), + )); + } + + // Overwrite all fields based on CreateUserRequest, except groups: it's + // invalid to change group memberships in a Users PUT. + + if let Some(active) = active { + if !active { + self.on_user_active_to_false_in_txn(conn, user_id).await?; + } + } + + let updated = diesel::update(dsl::silo_user) + .filter(dsl::silo_id.eq(self.authz_silo.id())) + .filter(dsl::user_provision_type.eq(model::UserProvisionType::Scim)) + .filter(dsl::id.eq(to_db_typed_uuid(user_id))) + .filter(dsl::time_deleted.is_null()) + .set(( + dsl::time_modified.eq(Utc::now()), + dsl::user_name.eq(name), + dsl::active.eq(active), + dsl::external_id.eq(external_id), + )) + .execute_async(conn) + .await?; + + if updated != 1 { + return Err(err.bail(ProviderStoreError::StoreError(anyhow!( + "expected 1 row to be updated, not {updated}" + )))); + } + + let user = dsl::silo_user + .filter(dsl::silo_id.eq(self.authz_silo.id())) + .filter(dsl::user_provision_type.eq(model::UserProvisionType::Scim)) + .filter(dsl::id.eq(to_db_typed_uuid(user_id))) + .filter(dsl::time_deleted.is_null()) + .select(model::SiloUser::as_select()) + .first_async(conn) + .await?; + + // Groups don't change, so query for what was previously there + + let groups = self + .get_user_groups_for_user_in_txn(conn, user.identity.id.into()) + .await?; + + let SiloUser::Scim(user) = user.into() else { + // With the user provision type filter, this should never be another + // type. + unreachable!(); + }; + + Ok(convert_to_scim_user(user, groups)) + } + + async fn delete_user_by_id_in_txn( + &self, + conn: &async_bb8_diesel::Connection, + err: OptionalError, + user_id: SiloUserUuid, + ) -> Result { + use nexus_db_schema::schema::silo_user::dsl; + + let maybe_user = dsl::silo_user + .filter(dsl::silo_id.eq(self.authz_silo.id())) + .filter(dsl::user_provision_type.eq(model::UserProvisionType::Scim)) + .filter(dsl::id.eq(to_db_typed_uuid(user_id))) + .filter(dsl::time_deleted.is_null()) + .select(model::SiloUser::as_select()) + .first_async(conn) + .await + .optional()?; + + if maybe_user.is_none() { + return Ok(false); + } + + let updated = diesel::update(dsl::silo_user) + .filter(dsl::silo_id.eq(self.authz_silo.id())) + .filter(dsl::user_provision_type.eq(model::UserProvisionType::Scim)) + .filter(dsl::id.eq(to_db_typed_uuid(user_id))) + .filter(dsl::time_deleted.is_null()) + .set(dsl::time_deleted.eq(Utc::now())) + .execute_async(conn) + .await?; + + if updated != 1 { + return Err(err.bail(ProviderStoreError::StoreError(anyhow!( + "expected 1 row to be updated, not {updated}" + )))); + } + + { + use nexus_db_schema::schema::silo_group_membership::dsl; + + diesel::delete(dsl::silo_group_membership) + .filter(dsl::silo_user_id.eq(to_db_typed_uuid(user_id))) + .execute_async(conn) + .await?; + } + + // Cleanup role assignment records for the deleted user + { + use nexus_db_schema::schema::role_assignment::dsl; + + diesel::delete(dsl::role_assignment) + .filter(dsl::identity_type.eq(IdentityType::SiloUser)) + .filter(dsl::identity_id.eq(to_db_typed_uuid(user_id))) + .execute_async(conn) + .await?; + } + + // Finally we should cleanup all auth related things like sessions and + // tokens. + self.remove_user_sessions_and_tokens_in_txn(conn, user_id).await?; + + Ok(true) + } + + async fn get_group_by_id_in_txn( + &self, + conn: &async_bb8_diesel::Connection, + _err: OptionalError, + group_id: SiloGroupUuid, + ) -> Result>, diesel::result::Error> { + use nexus_db_schema::schema::silo_group::dsl; + + let maybe_group = dsl::silo_group + .filter(dsl::silo_id.eq(self.authz_silo.id())) + .filter(dsl::user_provision_type.eq(model::UserProvisionType::Scim)) + .filter(dsl::id.eq(to_db_typed_uuid(group_id))) + .filter(dsl::time_deleted.is_null()) + .select(model::SiloGroup::as_returning()) + .first_async(conn) + .await + .optional()?; + + let Some(group) = maybe_group else { + return Ok(None); + }; + + let members: Option> = + self.get_group_members_for_group_in_txn(conn, group_id).await?; + + let SiloGroup::Scim(group) = group.into() else { + // With the user provision type filter, this should never be another + // type. + unreachable!(); + }; + + Ok(Some(convert_to_scim_group(group, members))) + } + + /// Returns User id, and the GroupMember object, if this member is valid + async fn validate_group_member_in_txn( + &self, + conn: &async_bb8_diesel::Connection, + err: OptionalError, + member: &GroupMember, + ) -> Result<(SiloUserUuid, GroupMember), diesel::result::Error> { + let GroupMember { resource_type, value } = member; + + let Some(value) = value else { + // The minimum that this code needs is the value field so complain + // about that. + return Err(err.bail( + scim2_rs::Error::invalid_syntax(String::from( + "group member missing value field", + )) + .into(), + )); + }; + + let id: Uuid = match value.parse() { + Ok(v) => v, + + Err(_) => { + return Err(err.bail(ProviderStoreError::StoreError(anyhow!( + "id must be uuid" + )))); + } + }; + + // Find the ID that this request is talking about, or 404 + let resource_type = if let Some(resource_type) = resource_type { + let resource_type = match ResourceType::from_str(resource_type) { + Ok(v) => v, + Err(e) => Err(err.bail( + scim2_rs::Error::invalid_syntax(e.to_string()).into(), + ))?, + }; + + match resource_type { + ResourceType::User => { + use nexus_db_schema::schema::silo_user::dsl; + + let maybe_user: Option = dsl::silo_user + .filter(dsl::silo_id.eq(self.authz_silo.id())) + .filter( + dsl::user_provision_type + .eq(model::UserProvisionType::Scim), + ) + .filter(dsl::id.eq(id)) + .filter(dsl::time_deleted.is_null()) + .select(dsl::id) + .first_async(conn) + .await + .optional()?; + + if maybe_user.is_none() { + return Err(err.bail( + scim2_rs::Error::not_found(value.to_string()) + .into(), + )); + } + } + + ResourceType::Group => { + return Err(err.bail( + scim2_rs::Error::internal_error( + "nested groups not supported".to_string(), + ) + .into(), + )); + } + } + + resource_type + } else { + // If no resource type is supplied, then search for a matching user + // or group. + + let maybe_user: Option = { + use nexus_db_schema::schema::silo_user::dsl; + + dsl::silo_user + .filter(dsl::silo_id.eq(self.authz_silo.id())) + .filter( + dsl::user_provision_type + .eq(model::UserProvisionType::Scim), + ) + .filter(dsl::id.eq(id)) + .filter(dsl::time_deleted.is_null()) + .select(dsl::id) + .first_async(conn) + .await + .optional()? + }; + + let maybe_group: Option = { + use nexus_db_schema::schema::silo_group::dsl; + + dsl::silo_group + .filter(dsl::silo_id.eq(self.authz_silo.id())) + .filter( + dsl::user_provision_type + .eq(model::UserProvisionType::Scim), + ) + .filter(dsl::id.eq(id)) + .filter(dsl::time_deleted.is_null()) + .select(dsl::id) + .first_async(conn) + .await + .optional()? + }; + + match (maybe_user, maybe_group) { + (None, None) => { + // 404 + return Err(err.bail( + scim2_rs::Error::not_found(value.clone()).into(), + )); + } + + (Some(_), None) => ResourceType::User, + + (None, Some(_)) => { + return Err(err.bail( + scim2_rs::Error::internal_error( + "nested groups not supported".to_string(), + ) + .into(), + )); + } + + (Some(_), Some(_)) => { + return Err(err.bail( + scim2_rs::Error::internal_error(format!( + "{value} returned a user and group!" + )) + .into(), + )); + } + } + }; + + Ok(( + SiloUserUuid::from_untyped_uuid(id), + GroupMember { + resource_type: Some(resource_type.to_string()), + value: Some(value.to_string()), + }, + )) + } + + async fn create_group_in_txn( + &self, + conn: &async_bb8_diesel::Connection, + err: OptionalError, + group_request: CreateGroupRequest, + ) -> Result, diesel::result::Error> { + use nexus_db_schema::schema::silo_group::dsl; + + let CreateGroupRequest { display_name, external_id, members } = + group_request; + + // displayName is meant to be unique: If the group request is changing + // the displayName to one that already exists, reject it. + // + // Note that the SCIM spec says that displayName's uniqueness is none - + // but our / Nexus groups have to be unique due to the lookup by Name. + + let maybe_group = dsl::silo_group + .filter(dsl::silo_id.eq(self.authz_silo.id())) + .filter(dsl::user_provision_type.eq(model::UserProvisionType::Scim)) + .filter(lower(dsl::display_name).eq(lower(display_name.clone()))) + .filter(dsl::time_deleted.is_null()) + .select(model::SiloGroup::as_returning()) + .first_async(conn) + .await + .optional()?; + + if maybe_group.is_some() { + return Err(err.bail( + scim2_rs::Error::conflict(format!( + "displayName {}", + display_name + )) + .into(), + )); + } + + let group_id = SiloGroupUuid::new_v4(); + + let new_group = SiloGroupScim::new( + self.authz_silo.id(), + group_id, + display_name.clone(), + external_id, + ); + + let model: model::SiloGroup = new_group.clone().into(); + + diesel::insert_into(dsl::silo_group) + .values(model) + .execute_async(conn) + .await?; + + let members = if let Some(members) = &members { + let mut returned_members = IdOrdMap::with_capacity(members.len()); + let mut memberships = Vec::with_capacity(members.len()); + + // Validate the members arg, and insert silo group membership + // records. + for member in members { + let (user_id, returned_member) = self + .validate_group_member_in_txn(conn, err.clone(), member) + .await?; + + match returned_members.insert_unique(returned_member) { + Ok(_) => {} + + Err(e) => { + return Err(err.bail(ProviderStoreError::Scim( + scim2_rs::Error::conflict(format!( + "{:?}", + e.new_item() + )), + ))); + } + } + + memberships.push(model::SiloGroupMembership { + silo_group_id: group_id.into(), + silo_user_id: user_id.into(), + }); + } + + use nexus_db_schema::schema::silo_group_membership::dsl; + diesel::insert_into(dsl::silo_group_membership) + .values(memberships) + .execute_async(conn) + .await?; + + Some(returned_members) + } else { + None + }; + + // If this group's name matches the silo's admin group name, then create + // the appropriate policy granting members of that group the silo admin + // role. + + { + use nexus_db_schema::schema::silo::dsl; + let silo = dsl::silo + .filter(dsl::id.eq(self.authz_silo.id())) + .select(model::Silo::as_select()) + .first_async(conn) + .await?; + + if let Some(admin_group_name) = silo.admin_group_name { + if admin_group_name.eq_ignore_ascii_case(&display_name) { + // XXX code copied from silo create + + use nexus_db_schema::schema::role_assignment::dsl; + + let new_assignment = model::RoleAssignment::new( + model::IdentityType::SiloGroup, + group_id.into_untyped_uuid(), + self.authz_silo.resource_type(), + self.authz_silo.resource_id(), + &SiloRole::Admin.to_database_string(), + ); + + diesel::insert_into(dsl::role_assignment) + .values(new_assignment) + .execute_async(conn) + .await?; + } + } + } + + Ok(convert_to_scim_group(new_group, members)) + } + + async fn list_groups_in_txn( + &self, + conn: &async_bb8_diesel::Connection, + err: OptionalError, + filter: Option, + ) -> Result>, diesel::result::Error> { + use nexus_db_schema::schema::silo_group::dsl; + + let mut query = dsl::silo_group + .filter(dsl::silo_id.eq(self.authz_silo.id())) + .filter(dsl::user_provision_type.eq(model::UserProvisionType::Scim)) + .filter(dsl::time_deleted.is_null()) + .into_boxed(); + + match filter { + Some(FilterOp::DisplayNameEq(display_name)) => { + // displayName is defined as `"caseExact" : false` in RFC 7643, + // section 8.7.1 + query = query.filter( + lower(dsl::display_name).eq(lower(display_name.clone())), + ); + } + + None => { + // ok + } + + Some(_) => { + return Err(err.bail( + scim2_rs::Error::invalid_filter( + "invalid or unsupported filter".to_string(), + ) + .into(), + )); + } + } + + let groups = query + .select(model::SiloGroup::as_returning()) + .load_async(conn) + .await?; + + let mut returned_groups = Vec::with_capacity(groups.len()); + + for group in groups { + let members = self + .get_group_members_for_group_in_txn( + conn, + group.identity.id.into(), + ) + .await?; + + let SiloGroup::Scim(group) = group.into() else { + // With the user provision type filter, this should never be + // another type. + unreachable!(); + }; + + returned_groups.push(convert_to_scim_group(group, members)); + } + + Ok(returned_groups) + } + + async fn replace_group_in_txn( + &self, + conn: &async_bb8_diesel::Connection, + err: OptionalError, + group_id: SiloGroupUuid, + group_request: CreateGroupRequest, + ) -> Result, diesel::result::Error> { + use nexus_db_schema::schema::silo_group::dsl; + + let maybe_group = dsl::silo_group + .filter(dsl::silo_id.eq(self.authz_silo.id())) + .filter(dsl::user_provision_type.eq(model::UserProvisionType::Scim)) + .filter(dsl::id.eq(to_db_typed_uuid(group_id))) + .filter(dsl::time_deleted.is_null()) + .select(model::SiloGroup::as_returning()) + .first_async(conn) + .await + .optional()?; + + if maybe_group.is_none() { + return Err(err.bail( + scim2_rs::Error::not_found(group_id.to_string()).into(), + )); + }; + + let CreateGroupRequest { display_name, external_id, members } = + group_request; + + // displayName is meant to be unique: If the group request is changing + // the displayName to one that already exists, reject it. + + let maybe_group = dsl::silo_group + .filter(dsl::silo_id.eq(self.authz_silo.id())) + .filter(dsl::user_provision_type.eq(model::UserProvisionType::Scim)) + .filter(lower(dsl::display_name).eq(lower(display_name.clone()))) + .filter(dsl::id.ne(to_db_typed_uuid(group_id))) + .filter(dsl::time_deleted.is_null()) + .select(model::SiloGroup::as_returning()) + .first_async(conn) + .await + .optional()?; + + if maybe_group.is_some() { + return Err(err.bail( + scim2_rs::Error::conflict(format!( + "displayName {}", + display_name + )) + .into(), + )); + } + + // Stash the group for later + + let existing_group = dsl::silo_group + .filter(dsl::silo_id.eq(self.authz_silo.id())) + .filter(dsl::user_provision_type.eq(model::UserProvisionType::Scim)) + .filter(dsl::id.eq(to_db_typed_uuid(group_id))) + .filter(dsl::time_deleted.is_null()) + .select(model::SiloGroup::as_select()) + .first_async(conn) + .await?; + + let SiloGroup::Scim(existing_group) = existing_group.into() else { + // With the user provision type filter, this should never be + // another type. + unreachable!(); + }; + + // Overwrite all fields based on CreateGroupRequest. + + let updated = diesel::update(dsl::silo_group) + .filter(dsl::silo_id.eq(self.authz_silo.id())) + .filter(dsl::user_provision_type.eq(model::UserProvisionType::Scim)) + .filter(dsl::id.eq(to_db_typed_uuid(group_id))) + .filter(dsl::time_deleted.is_null()) + .set(( + dsl::time_modified.eq(Utc::now()), + dsl::display_name.eq(display_name), + dsl::external_id.eq(external_id), + )) + .execute_async(conn) + .await?; + + if updated != 1 { + return Err(err.bail(ProviderStoreError::StoreError(anyhow!( + "expected 1 row to be updated, not {updated}" + )))); + } + + let group = dsl::silo_group + .filter(dsl::silo_id.eq(self.authz_silo.id())) + .filter(dsl::user_provision_type.eq(model::UserProvisionType::Scim)) + .filter(dsl::id.eq(to_db_typed_uuid(group_id))) + .filter(dsl::time_deleted.is_null()) + .select(model::SiloGroup::as_select()) + .first_async(conn) + .await?; + + let SiloGroup::Scim(group) = group.into() else { + // With the user provision type filter, this should never be + // another type. + unreachable!(); + }; + + // Delete all existing group memberships for this group id + + { + use nexus_db_schema::schema::silo_group_membership::dsl; + + diesel::delete(dsl::silo_group_membership) + .filter(dsl::silo_group_id.eq(to_db_typed_uuid(group_id))) + .execute_async(conn) + .await?; + } + + // Validate the members arg, and insert silo group membership records. + + let members = if let Some(members) = &members { + let mut returned_members = IdOrdMap::with_capacity(members.len()); + let mut memberships = Vec::with_capacity(members.len()); + + // Validate the members arg, and insert silo group membership + // records. + for member in members { + let (user_id, returned_member) = self + .validate_group_member_in_txn(conn, err.clone(), member) + .await?; + + match returned_members.insert_unique(returned_member) { + Ok(_) => {} + + Err(e) => { + return Err(err.bail(ProviderStoreError::Scim( + scim2_rs::Error::conflict(format!( + "{:?}", + e.new_item() + )), + ))); + } + } + + memberships.push(model::SiloGroupMembership { + silo_group_id: group_id.into(), + silo_user_id: user_id.into(), + }); + } + + use nexus_db_schema::schema::silo_group_membership::dsl; + diesel::insert_into(dsl::silo_group_membership) + .values(memberships) + .execute_async(conn) + .await?; + + Some(returned_members) + } else { + None + }; + + // If displayName changes from the Silo admin group name to something + // else, delete the Silo admin role assignment for this group ID. If it + // changes _to_ the Silo admin group name, insert the appropriate Silo + // admin role assignment. + + { + use nexus_db_schema::schema::silo::dsl; + let silo = dsl::silo + .filter(dsl::id.eq(self.authz_silo.id())) + .select(model::Silo::as_select()) + .first_async(conn) + .await?; + + let authz_silo = authz::Silo::new( + authz::FLEET, + self.authz_silo.id(), + LookupType::ById(self.authz_silo.id()), + ); + + if let Some(admin_group_name) = silo.admin_group_name { + use nexus_db_schema::schema::role_assignment::dsl; + + // Did the group's name match the admin group name, and was it + // changed? + if existing_group + .display_name + .eq_ignore_ascii_case(&admin_group_name) + { + if !group + .display_name + .eq_ignore_ascii_case(&admin_group_name) + { + // Scan for the matching role assignment, and delete + // that. + + diesel::delete(dsl::role_assignment) + .filter( + dsl::identity_type + .eq(model::IdentityType::SiloGroup), + ) + .filter( + dsl::identity_id.eq(to_db_typed_uuid(group_id)), + ) + .filter( + dsl::resource_type + .eq(authz_silo.resource_type().to_string()), + ) + .filter(dsl::resource_id.eq(authz_silo.id())) + .filter( + dsl::role_name + .eq(SiloRole::Admin.to_database_string()), + ) + .execute_async(conn) + .await?; + } + } else { + // Did the group's name change _to_ the admin group name? + if group + .display_name + .eq_ignore_ascii_case(&admin_group_name) + { + // If so, insert a new assignment. + + let new_assignment = model::RoleAssignment::new( + model::IdentityType::SiloGroup, + group_id.into_untyped_uuid(), + authz_silo.resource_type(), + authz_silo.resource_id(), + &SiloRole::Admin.to_database_string(), + ); + + // The ON CONFLICT + DO NOTHING is required to handle + // the case where the group was granted the silo admin + // role _before_ being renamed to match the silo's admin + // group name. + + diesel::insert_into(dsl::role_assignment) + .values(new_assignment) + .on_conflict(( + dsl::identity_type, + dsl::identity_id, + dsl::resource_type, + dsl::resource_id, + dsl::role_name, + )) + .do_nothing() + .execute_async(conn) + .await?; + } + } + } + } + + Ok(convert_to_scim_group(group, members)) + } + + async fn delete_group_by_id_in_txn( + &self, + conn: &async_bb8_diesel::Connection, + err: OptionalError, + group_id: SiloGroupUuid, + ) -> Result { + use nexus_db_schema::schema::silo_group::dsl; + + let maybe_group = dsl::silo_group + .filter(dsl::silo_id.eq(self.authz_silo.id())) + .filter(dsl::user_provision_type.eq(model::UserProvisionType::Scim)) + .filter(dsl::id.eq(to_db_typed_uuid(group_id))) + .filter(dsl::time_deleted.is_null()) + .select(model::SiloGroup::as_select()) + .first_async(conn) + .await + .optional()?; + + if maybe_group.is_none() { + return Ok(false); + } + + let updated = diesel::update(dsl::silo_group) + .filter(dsl::silo_id.eq(self.authz_silo.id())) + .filter(dsl::user_provision_type.eq(model::UserProvisionType::Scim)) + .filter(dsl::id.eq(to_db_typed_uuid(group_id))) + .filter(dsl::time_deleted.is_null()) + .set(dsl::time_deleted.eq(Utc::now())) + .execute_async(conn) + .await?; + + if updated != 1 { + return Err(err.bail(ProviderStoreError::StoreError(anyhow!( + "expected 1 row to be updated, not {updated}" + )))); + } + + { + use nexus_db_schema::schema::silo_group_membership::dsl; + + diesel::delete(dsl::silo_group_membership) + .filter(dsl::silo_group_id.eq(to_db_typed_uuid(group_id))) + .execute_async(conn) + .await?; + } + + // Cleanup role assignment records for the deleted group + { + use nexus_db_schema::schema::role_assignment::dsl; + + diesel::delete(dsl::role_assignment) + .filter(dsl::identity_id.eq(to_db_typed_uuid(group_id))) + .filter(dsl::identity_type.eq(IdentityType::SiloGroup)) + .execute_async(conn) + .await?; + } + + Ok(true) } } -#[async_trait::async_trait] -impl ProviderStore for CrdbScimProviderStore { +fn convert_to_scim_user( + silo_user: SiloUserScim, + groups: Option>, +) -> StoredParts { + StoredParts { + resource: User { + id: silo_user.id.to_string(), + name: silo_user.user_name, + active: silo_user.active, + external_id: silo_user.external_id, + groups, + }, + + meta: StoredMeta { + created: silo_user.time_created, + last_modified: silo_user.time_modified, + version: "W/unimplemented".to_string(), + }, + } +} + +fn convert_to_scim_group( + silo_group: SiloGroupScim, + members: Option>, +) -> StoredParts { + StoredParts { + resource: Group { + id: silo_group.id.to_string(), + display_name: silo_group.display_name, + external_id: silo_group.external_id, + members, + }, + + meta: StoredMeta { + created: silo_group.time_created, + last_modified: silo_group.time_modified, + version: "W/unimplemented".to_string(), + }, + } +} + +impl<'a> ProviderStore for CrdbScimProviderStore<'a> { async fn get_user_by_id( &self, - _user_id: &str, + user_id: &str, ) -> Result>, ProviderStoreError> { - return Err(ProviderStoreError::StoreError(anyhow!( - "not implemented!" - ))); + let user_id: SiloUserUuid = match user_id.parse() { + Ok(v) => v, + Err(_) => { + return Err(ProviderStoreError::StoreError(anyhow!( + "user id must be uuid" + ))); + } + }; + + let authz_silo_user = authz::SiloUser::new( + self.authz_silo.clone(), + user_id, + LookupType::by_id(user_id), + ); + self.opctx + .authorize(authz::Action::Read, &authz_silo_user) + .await + .map_err(external_error_to_provider_error)?; + let conn = self + .datastore + .pool_connection_authorized(self.opctx) + .await + .map_err(|err| { + ProviderStoreError::StoreError(anyhow!( + "Failed to access DB connection: {err}" + )) + })?; + + let err: OptionalError = OptionalError::new(); + + let maybe_user = + self.datastore + .transaction_retry_wrapper("scim_get_user_by_id") + .transaction(&conn, |conn| { + let err = err.clone(); + + async move { + self.get_user_by_id_in_txn(&conn, err, user_id).await + } + }) + .await + .map_err(|e| { + if let Some(e) = err.take() { + e + } else { + ProviderStoreError::StoreError(e.into()) + } + })?; + + Ok(maybe_user) } async fn create_user( &self, - _user_request: CreateUserRequest, + user_request: CreateUserRequest, ) -> Result, ProviderStoreError> { - return Err(ProviderStoreError::StoreError(anyhow!( - "not implemented!" - ))); + let authz_silo_user_list = + authz::SiloUserList::new(self.authz_silo.clone()); + self.opctx + .authorize(authz::Action::CreateChild, &authz_silo_user_list) + .await + .map_err(external_error_to_provider_error)?; + + let conn = self + .datastore + .pool_connection_authorized(self.opctx) + .await + .map_err(|err| { + ProviderStoreError::StoreError(anyhow!( + "Failed to access DB connection: {err}" + )) + })?; + + let err: OptionalError = OptionalError::new(); + + let user = self + .datastore + .transaction_retry_wrapper("scim_create_user") + .transaction(&conn, |conn| { + let user_request = user_request.clone(); + let err = err.clone(); + + async move { + self.create_user_in_txn(&conn, err, user_request).await + } + }) + .await + .map_err(|e| { + if let Some(e) = err.take() { + e + } else { + ProviderStoreError::StoreError(e.into()) + } + })?; + + Ok(user) } async fn list_users( &self, - _filter: Option, + filter: Option, ) -> Result>, ProviderStoreError> { - return Err(ProviderStoreError::StoreError(anyhow!( - "not implemented!" - ))); + self.opctx + .authorize( + authz::Action::ListChildren, + &SiloUserList::new(self.authz_silo.clone()), + ) + .await + .map_err(external_error_to_provider_error)?; + let conn = self + .datastore + .pool_connection_authorized(self.opctx) + .await + .map_err(|err| { + ProviderStoreError::StoreError(anyhow!( + "Failed to access DB connection: {err}" + )) + })?; + + let err: OptionalError = OptionalError::new(); + + let users = self + .datastore + .transaction_retry_wrapper("scim_list_users") + .transaction(&conn, |conn| { + let err = err.clone(); + let filter = filter.clone(); + + async move { self.list_users_in_txn(&conn, err, filter).await } + }) + .await + .map_err(|e| { + if let Some(e) = err.take() { + e + } else { + ProviderStoreError::StoreError(e.into()) + } + })?; + + Ok(users) } async fn replace_user( &self, - _user_id: &str, - _user_request: CreateUserRequest, + user_id: &str, + user_request: CreateUserRequest, ) -> Result, ProviderStoreError> { - return Err(ProviderStoreError::StoreError(anyhow!( - "not implemented!" - ))); + let user_id: SiloUserUuid = match user_id.parse() { + Ok(v) => v, + Err(_) => { + return Err(ProviderStoreError::StoreError(anyhow!( + "user id must be uuid" + ))); + } + }; + + let authz_silo_user = authz::SiloUser::new( + self.authz_silo.clone(), + user_id, + LookupType::by_id(user_id), + ); + self.opctx + .authorize(authz::Action::Modify, &authz_silo_user) + .await + .map_err(external_error_to_provider_error)?; + + let conn = self + .datastore + .pool_connection_authorized(self.opctx) + .await + .map_err(|err| { + ProviderStoreError::StoreError(anyhow!( + "Failed to access DB connection: {err}" + )) + })?; + + let err: OptionalError = OptionalError::new(); + + let user = self + .datastore + .transaction_retry_wrapper("scim_replace_user") + .transaction(&conn, |conn| { + let err = err.clone(); + let user_request = user_request.clone(); + + async move { + self.replace_user_in_txn(&conn, err, user_id, user_request) + .await + } + }) + .await + .map_err(|e| { + if let Some(e) = err.take() { + e + } else { + ProviderStoreError::StoreError(e.into()) + } + })?; + + Ok(user) } async fn delete_user_by_id( &self, - _user_id: &str, + user_id: &str, ) -> Result { - return Err(ProviderStoreError::StoreError(anyhow!( - "not implemented!" - ))); + let user_id: SiloUserUuid = match user_id.parse() { + Ok(v) => v, + Err(_) => { + return Err(ProviderStoreError::StoreError(anyhow!( + "user id must be uuid" + ))); + } + }; + + let authz_silo_user = authz::SiloUser::new( + self.authz_silo.clone(), + user_id, + LookupType::by_id(user_id), + ); + self.opctx + .authorize(authz::Action::Delete, &authz_silo_user) + .await + .map_err(external_error_to_provider_error)?; + let conn = self + .datastore + .pool_connection_authorized(self.opctx) + .await + .map_err(|err| { + ProviderStoreError::StoreError(anyhow!( + "Failed to access DB connection: {err}" + )) + })?; + + let err: OptionalError = OptionalError::new(); + + let deleted_user = self + .datastore + .transaction_retry_wrapper("scim_delete_user_by_id") + .transaction(&conn, |conn| { + let err = err.clone(); + + async move { + self.delete_user_by_id_in_txn(&conn, err, user_id).await + } + }) + .await + .map_err(|e| { + if let Some(e) = err.take() { + e + } else { + ProviderStoreError::StoreError(e.into()) + } + })?; + + if deleted_user { + Ok(ProviderStoreDeleteResult::Deleted) + } else { + Ok(ProviderStoreDeleteResult::NotFound) + } } async fn get_group_by_id( &self, - _group_id: &str, + group_id: &str, ) -> Result>, ProviderStoreError> { - return Err(ProviderStoreError::StoreError(anyhow!( - "not implemented!" - ))); + let group_id: SiloGroupUuid = match group_id.parse() { + Ok(v) => v, + Err(_) => { + return Err(ProviderStoreError::StoreError(anyhow!( + "group id must be uuid" + ))); + } + }; + + let authz_silo_group = authz::SiloGroup::new( + self.authz_silo.clone(), + group_id, + LookupType::by_id(group_id), + ); + self.opctx + .authorize(authz::Action::Read, &authz_silo_group) + .await + .map_err(external_error_to_provider_error)?; + let conn = self + .datastore + .pool_connection_authorized(self.opctx) + .await + .map_err(|err| { + ProviderStoreError::StoreError(anyhow!( + "Failed to access DB connection: {err}" + )) + })?; + + let err: OptionalError = OptionalError::new(); + + let maybe_group = self + .datastore + .transaction_retry_wrapper("scim_get_group_by_id") + .transaction(&conn, |conn| { + let err = err.clone(); + + async move { + self.get_group_by_id_in_txn(&conn, err, group_id).await + } + }) + .await + .map_err(|e| { + if let Some(e) = err.take() { + e + } else { + ProviderStoreError::StoreError(e.into()) + } + })?; + + Ok(maybe_group) } async fn create_group( &self, - _group_request: CreateGroupRequest, + group_request: CreateGroupRequest, ) -> Result, ProviderStoreError> { - return Err(ProviderStoreError::StoreError(anyhow!( - "not implemented!" - ))); + let authz_silo_group_list = + authz::SiloGroupList::new(self.authz_silo.clone()); + self.opctx + .authorize(authz::Action::CreateChild, &authz_silo_group_list) + .await + .map_err(external_error_to_provider_error)?; + let conn = self + .datastore + .pool_connection_authorized(self.opctx) + .await + .map_err(|err| { + ProviderStoreError::StoreError(anyhow!( + "Failed to access DB connection: {err}" + )) + })?; + + let err: OptionalError = OptionalError::new(); + + let group = self + .datastore + .transaction_retry_wrapper("scim_create_group") + .transaction(&conn, |conn| { + let group_request = group_request.clone(); + let err = err.clone(); + + async move { + self.create_group_in_txn(&conn, err, group_request).await + } + }) + .await + .map_err(|e| { + if let Some(e) = err.take() { + e + } else { + ProviderStoreError::StoreError(e.into()) + } + })?; + + Ok(group) } async fn list_groups( &self, - _filter: Option, + filter: Option, ) -> Result>, ProviderStoreError> { - return Err(ProviderStoreError::StoreError(anyhow!( - "not implemented!" - ))); + self.opctx + .authorize( + authz::Action::ListChildren, + &SiloGroupList::new(self.authz_silo.clone()), + ) + .await + .map_err(external_error_to_provider_error)?; + let conn = self + .datastore + .pool_connection_authorized(self.opctx) + .await + .map_err(|err| { + ProviderStoreError::StoreError(anyhow!( + "Failed to access DB connection: {err}" + )) + })?; + + let err: OptionalError = OptionalError::new(); + + let groups = self + .datastore + .transaction_retry_wrapper("scim_list_groups") + .transaction(&conn, |conn| { + let err = err.clone(); + let filter = filter.clone(); + + async move { self.list_groups_in_txn(&conn, err, filter).await } + }) + .await + .map_err(|e| { + if let Some(e) = err.take() { + e + } else { + ProviderStoreError::StoreError(e.into()) + } + })?; + + Ok(groups) } async fn replace_group( &self, - _group_id: &str, - _group_request: CreateGroupRequest, + group_id: &str, + group_request: CreateGroupRequest, ) -> Result, ProviderStoreError> { - return Err(ProviderStoreError::StoreError(anyhow!( - "not implemented!" - ))); + let group_id: SiloGroupUuid = match group_id.parse() { + Ok(v) => v, + Err(_) => { + return Err(ProviderStoreError::StoreError(anyhow!( + "group id must be uuid" + ))); + } + }; + + let authz_silo_group = authz::SiloGroup::new( + self.authz_silo.clone(), + group_id, + LookupType::by_id(group_id), + ); + self.opctx + .authorize(authz::Action::Modify, &authz_silo_group) + .await + .map_err(external_error_to_provider_error)?; + + let conn = self + .datastore + .pool_connection_authorized(self.opctx) + .await + .map_err(|err| { + ProviderStoreError::StoreError(anyhow!( + "Failed to access DB connection: {err}" + )) + })?; + + let err: OptionalError = OptionalError::new(); + + let group = self + .datastore + .transaction_retry_wrapper("scim_replace_group") + .transaction(&conn, |conn| { + let err = err.clone(); + let group_request = group_request.clone(); + + async move { + self.replace_group_in_txn( + &conn, + err, + group_id, + group_request, + ) + .await + } + }) + .await + .map_err(|e| { + if let Some(e) = err.take() { + e + } else { + ProviderStoreError::StoreError(e.into()) + } + })?; + + Ok(group) } async fn delete_group_by_id( &self, - _group_id: &str, + group_id: &str, ) -> Result { - return Err(ProviderStoreError::StoreError(anyhow!( - "not implemented!" - ))); + let group_id: SiloGroupUuid = match group_id.parse() { + Ok(v) => v, + Err(_) => { + return Err(ProviderStoreError::StoreError(anyhow!( + "group id must be uuid" + ))); + } + }; + + let authz_silo_group = authz::SiloGroup::new( + self.authz_silo.clone(), + group_id, + LookupType::by_id(group_id), + ); + self.opctx + .authorize(authz::Action::Delete, &authz_silo_group) + .await + .map_err(external_error_to_provider_error)?; + + let conn = self + .datastore + .pool_connection_authorized(self.opctx) + .await + .map_err(|err| { + ProviderStoreError::StoreError(anyhow!( + "Failed to access DB connection: {err}" + )) + })?; + + let err: OptionalError = OptionalError::new(); + + let deleted_group = self + .datastore + .transaction_retry_wrapper("scim_delete_group_by_id") + .transaction(&conn, |conn| { + let err = err.clone(); + + async move { + self.delete_group_by_id_in_txn(&conn, err, group_id).await + } + }) + .await + .map_err(|e| { + if let Some(e) = err.take() { + e + } else { + ProviderStoreError::StoreError(e.into()) + } + })?; + + if deleted_group { + Ok(ProviderStoreDeleteResult::Deleted) + } else { + Ok(ProviderStoreDeleteResult::NotFound) + } } } diff --git a/nexus/db-queries/src/db/datastore/silo_user.rs b/nexus/db-queries/src/db/datastore/silo_user.rs index 0bdc8125fc..f33ccfe6b9 100644 --- a/nexus/db-queries/src/db/datastore/silo_user.rs +++ b/nexus/db-queries/src/db/datastore/silo_user.rs @@ -92,6 +92,25 @@ impl SiloUser { SiloUser::Scim(_) => UserProvisionType::Scim, } } + + /// Return if a user should be considered active, where inactive users + /// should not have sessions created for them. + pub fn is_active(&self) -> bool { + match &self { + SiloUser::ApiOnly(_) => true, + + SiloUser::Jit(_) => true, + + SiloUser::Scim(u) => { + // If a SCIM provisioning client has informed Nexus of the + // active field's value, use that. Otherwise, consider users + // created by clients that do _not_ use the "active" field at + // all to be active: otherwise every user created by a client + // does not use the "active" field would be considered inactive. + u.active.unwrap_or(true) + } + } + } } impl From for SiloUser { diff --git a/nexus/db-queries/src/policy_test/resource_builder.rs b/nexus/db-queries/src/policy_test/resource_builder.rs index 631b657f24..5f4cbb9fcd 100644 --- a/nexus/db-queries/src/policy_test/resource_builder.rs +++ b/nexus/db-queries/src/policy_test/resource_builder.rs @@ -348,6 +348,23 @@ impl DynAuthorizedResource for authz::SiloUserList { } } +impl DynAuthorizedResource for authz::SiloGroupList { + fn do_authorize<'a, 'b>( + &'a self, + opctx: &'b OpContext, + action: authz::Action, + ) -> BoxFuture<'a, Result<(), Error>> + where + 'b: 'a, + { + opctx.authorize(action, self).boxed() + } + + fn resource_name(&self) -> String { + format!("{}: group list", self.silo().resource_name()) + } +} + impl DynAuthorizedResource for authz::SiloUserSessionList { fn do_authorize<'a, 'b>( &'a self, diff --git a/nexus/db-queries/src/policy_test/resources.rs b/nexus/db-queries/src/policy_test/resources.rs index dcb77e100a..5f0f8c65d9 100644 --- a/nexus/db-queries/src/policy_test/resources.rs +++ b/nexus/db-queries/src/policy_test/resources.rs @@ -259,6 +259,7 @@ async fn make_silo( )); builder.new_resource(authz::SiloUserList::new(silo.clone())); + builder.new_resource(authz::SiloGroupList::new(silo.clone())); let silo_user_id = SiloUserUuid::new_v4(); let silo_user = authz::SiloUser::new( silo.clone(), diff --git a/nexus/db-queries/tests/output/authz-roles.out b/nexus/db-queries/tests/output/authz-roles.out index 84317563ba..38f8ca294e 100644 --- a/nexus/db-queries/tests/output/authz-roles.out +++ b/nexus/db-queries/tests/output/authz-roles.out @@ -296,7 +296,22 @@ resource: Silo "silo1": user list silo1-proj1-collaborator ✘ ✘ ✔ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✔ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! - scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + scim ✘ ✘ ✔ ✘ ✘ ✘ ✔ ✘ + +resource: Silo "silo1": group list + + USER Q R LC RP M MP CC D + fleet-admin ✘ ✘ ✔ ✘ ✘ ✘ ✔ ✘ + fleet-collaborator ✘ ✘ ✔ ✘ ✘ ✘ ✘ ✘ + fleet-viewer ✘ ✘ ✔ ✘ ✘ ✘ ✘ ✘ + silo1-admin ✘ ✘ ✔ ✘ ✘ ✘ ✔ ✘ + silo1-collaborator ✘ ✘ ✔ ✘ ✘ ✘ ✘ ✘ + silo1-viewer ✘ ✘ ✔ ✘ ✘ ✘ ✘ ✘ + silo1-proj1-admin ✘ ✘ ✔ ✘ ✘ ✘ ✘ ✘ + silo1-proj1-collaborator ✘ ✘ ✔ ✘ ✘ ✘ ✘ ✘ + silo1-proj1-viewer ✘ ✘ ✔ ✘ ✘ ✘ ✘ ✘ + unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✔ ✘ ✘ ✘ ✔ ✘ resource: SiloUser "silo1-user" @@ -311,7 +326,7 @@ resource: SiloUser "silo1-user" silo1-proj1-collaborator ✘ ✔ ✔ ✔ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✔ ✔ ✔ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! - scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + scim ✘ ✔ ✘ ✔ ✔ ✔ ✘ ✔ resource: SshKey "silo1-user-ssh-key" @@ -326,7 +341,7 @@ resource: SshKey "silo1-user-ssh-key" silo1-proj1-collaborator ✘ ✔ ✘ ✔ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✔ ✘ ✔ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! - scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + scim ✘ ✔ ✘ ✔ ✘ ✘ ✘ ✘ resource: SiloGroup "silo1-group" @@ -341,7 +356,7 @@ resource: SiloGroup "silo1-group" silo1-proj1-collaborator ✘ ✔ ✔ ✔ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✔ ✔ ✔ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! - scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + scim ✘ ✔ ✘ ✔ ✔ ✔ ✘ ✔ resource: SiloImage "silo1-silo-image" @@ -958,6 +973,21 @@ resource: Silo "silo2": user list unauthenticated ! ! ! ! ! ! ! ! scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ +resource: Silo "silo2": group list + + USER Q R LC RP M MP CC D + fleet-admin ✘ ✘ ✔ ✘ ✘ ✘ ✔ ✘ + fleet-collaborator ✘ ✘ ✔ ✘ ✘ ✘ ✘ ✘ + fleet-viewer ✘ ✘ ✔ ✘ ✘ ✘ ✘ ✘ + silo1-admin ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-proj1-admin ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + resource: SiloUser "silo2-user" USER Q R LC RP M MP CC D diff --git a/nexus/src/app/scim.rs b/nexus/src/app/scim.rs index d9b6a3bc5e..70c5be8bfb 100644 --- a/nexus/src/app/scim.rs +++ b/nexus/src/app/scim.rs @@ -12,6 +12,7 @@ use dropshot::Body; use dropshot::HttpError; use http::Response; use http::StatusCode; +use nexus_auth::authz; use nexus_db_lookup::lookup; use nexus_db_queries::authn::{Actor, Reason}; use nexus_db_queries::context::OpContext; @@ -22,6 +23,7 @@ use omicron_common::api::external::DeleteResult; use omicron_common::api::external::Error; use omicron_common::api::external::ListResultVec; use omicron_common::api::external::LookupResult; +use omicron_common::api::external::LookupType; use uuid::Uuid; impl super::Nexus { @@ -139,18 +141,31 @@ impl super::Nexus { } /// For an authenticataed Actor::Scim, return a scim2_rs::Provider - pub(crate) async fn scim_get_provider_from_opctx( + pub(crate) async fn scim_get_provider_from_opctx<'a>( &self, - opctx: &OpContext, - ) -> LookupResult> { + opctx: &'a OpContext, + ) -> LookupResult>> { match opctx.authn.actor() { - Some(Actor::Scim { silo_id }) => Ok(scim2_rs::Provider::new( - self.log.new(slog::o!( - "component" => "scim2_rs::Provider", - "silo" => silo_id.to_string(), - )), - CrdbScimProviderStore::new(*silo_id, self.datastore().clone()), - )), + Some(Actor::Scim { silo_id }) => { + // Get the silo + let silo = authz::Silo::new( + authz::FLEET, + *silo_id, + LookupType::ById(*silo_id), + ); + + Ok(scim2_rs::Provider::new( + self.log.new(slog::o!( + "component" => "scim2_rs::Provider", + "silo" => silo_id.to_string(), + )), + CrdbScimProviderStore::new( + silo, + self.datastore().clone(), + opctx, + ), + )) + } _ => Err(Error::Unauthenticated { internal_message: "not an Actor::Scim".to_string(), diff --git a/nexus/src/app/session.rs b/nexus/src/app/session.rs index 9e817e3947..0bd803fdde 100644 --- a/nexus/src/app/session.rs +++ b/nexus/src/app/session.rs @@ -43,6 +43,12 @@ impl super::Nexus { opctx: &OpContext, user: &SiloUser, ) -> CreateResult { + // If the user is disabled, disallow session creation. + if !user.is_active() { + return Err(Error::Unauthenticated { + internal_message: String::from("user is not active"), + }); + } let session = db::model::ConsoleSession::new(generate_session_token(), user.id()); self.db_datastore.session_create(opctx, session).await diff --git a/nexus/test-utils/src/resource_helpers.rs b/nexus/test-utils/src/resource_helpers.rs index 3c9044740b..b78adb2554 100644 --- a/nexus/test-utils/src/resource_helpers.rs +++ b/nexus/test-utils/src/resource_helpers.rs @@ -65,6 +65,7 @@ use omicron_test_utils::dev::poll::CondCheckError; use omicron_test_utils::dev::poll::wait_for_condition; use omicron_uuid_kinds::DatasetUuid; use omicron_uuid_kinds::PhysicalDiskUuid; +use omicron_uuid_kinds::SiloGroupUuid; use omicron_uuid_kinds::SiloUserUuid; use omicron_uuid_kinds::SledUuid; use omicron_uuid_kinds::ZpoolUuid; @@ -394,6 +395,23 @@ pub async fn create_silo( silo_name: &str, discoverable: bool, identity_mode: shared::SiloIdentityMode, +) -> Silo { + create_silo_with_admin_group_name( + client, + silo_name, + discoverable, + identity_mode, + None, + ) + .await +} + +pub async fn create_silo_with_admin_group_name( + client: &ClientTestContext, + silo_name: &str, + discoverable: bool, + identity_mode: shared::SiloIdentityMode, + admin_group_name: Option, ) -> Silo { object_create( client, @@ -406,7 +424,7 @@ pub async fn create_silo( quotas: params::SiloQuotasCreate::arbitrarily_high_default(), discoverable, identity_mode, - admin_group_name: None, + admin_group_name, tls_certificates: vec![], mapped_fleet_roles: Default::default(), }, @@ -1146,6 +1164,48 @@ pub async fn grant_iam( .expect("failed to update policy"); } +/// Grant a role on a resource to a group +/// +/// * `grant_resource_url`: URL of the resource we're granting the role on +/// * `grant_role`: the role we're granting +/// * `grant_group`: the uuid of the group we're granting the role to +/// * `run_as`: the user _doing_ the granting +pub async fn grant_iam_for_group( + client: &ClientTestContext, + grant_resource_url: &str, + grant_role: T, + grant_group: SiloGroupUuid, + run_as: AuthnMode, +) where + T: serde::Serialize + serde::de::DeserializeOwned, +{ + let policy_url = format!("{}/policy", grant_resource_url); + let existing_policy: shared::Policy = + NexusRequest::object_get(client, &policy_url) + .authn_as(run_as.clone()) + .execute() + .await + .expect("failed to fetch policy") + .parsed_body() + .expect("failed to parse policy"); + let new_role_assignment = + shared::RoleAssignment::for_silo_group(grant_group, grant_role); + let new_role_assignments = existing_policy + .role_assignments + .into_iter() + .chain(std::iter::once(new_role_assignment)) + .collect(); + + let new_policy = shared::Policy { role_assignments: new_role_assignments }; + + // TODO-correctness use etag when we have it + NexusRequest::object_put(client, &policy_url, Some(&new_policy)) + .authn_as(run_as) + .execute() + .await + .expect("failed to update policy"); +} + pub async fn project_get( client: &ClientTestContext, project_url: &str, diff --git a/nexus/tests/integration_tests/scim.rs b/nexus/tests/integration_tests/scim.rs index a5826d2488..425a7e1055 100644 --- a/nexus/tests/integration_tests/scim.rs +++ b/nexus/tests/integration_tests/scim.rs @@ -10,12 +10,15 @@ use base64::Engine; use chrono::Utc; use http::StatusCode; use http::method::Method; +use nexus_auth::context::OpContext; use nexus_db_queries::authn::USER_TEST_PRIVILEGED; use nexus_db_queries::authn::silos::{IdentityProviderType, SamlLoginPost}; use nexus_db_queries::db::model::ScimClientBearerToken; use nexus_test_utils::http_testing::{AuthnMode, NexusRequest, RequestBuilder}; use nexus_test_utils::resource_helpers::create_silo; +use nexus_test_utils::resource_helpers::create_silo_with_admin_group_name; use nexus_test_utils::resource_helpers::grant_iam; +use nexus_test_utils::resource_helpers::grant_iam_for_group; use nexus_test_utils::resource_helpers::object_create; use nexus_test_utils::resource_helpers::object_create_no_body; use nexus_test_utils::resource_helpers::object_delete; @@ -26,8 +29,12 @@ use nexus_types::external_api::{params, shared}; use nexus_types::identity::Asset; use omicron_common::api::external::IdentityMetadataCreateParams; use omicron_nexus::TestInterfaces; +use omicron_uuid_kinds::GenericUuid; +use omicron_uuid_kinds::SiloGroupUuid; use uuid::Uuid; +use scim2_test_client::Tester; + type ControlPlaneTestContext = nexus_test_utils::ControlPlaneTestContext; @@ -441,7 +448,6 @@ async fn test_scim_client_token_bearer_auth( .await; // Check that we can get a SCIM provider using that token - // XXX this will 500 until the final impl PR, but it should not 401 RequestBuilder::new(client, Method::GET, "/scim/v2/Users") .header( @@ -449,10 +455,10 @@ async fn test_scim_client_token_bearer_auth( format!("Bearer {}", created_token.bearer_token), ) .allow_non_dropshot_errors() - .expect_status(Some(StatusCode::INTERNAL_SERVER_ERROR)) + .expect_status(Some(StatusCode::OK)) .execute() .await - .expect("expected 500"); + .expect("expected 200"); } #[nexus_test] @@ -501,13 +507,1486 @@ async fn test_scim_client_no_auth_with_expired_token( // This should 401 RequestBuilder::new(client, Method::GET, "/scim/v2/Users") - .header( - http::header::AUTHORIZATION, - String::from("Bearer oxide-scim-testpost"), - ) + .header(http::header::AUTHORIZATION, String::from("Bearer testpost")) .allow_non_dropshot_errors() .expect_status(Some(StatusCode::UNAUTHORIZED)) .execute() .await .expect("expected 401"); } + +#[nexus_test] +async fn test_scim2_crate_self_test(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.server_context().nexus; + let opctx = OpContext::for_tests( + cptestctx.logctx.log.new(o!()), + nexus.datastore().clone(), + ); + + // Create a Silo, then grant the PrivilegedUser the Admin role on it + + const SILO_NAME: &str = "saml-scim-silo"; + create_silo(&client, SILO_NAME, true, shared::SiloIdentityMode::SamlScim) + .await; + + grant_iam( + client, + &format!("/v1/system/silos/{SILO_NAME}"), + shared::SiloRole::Admin, + opctx.authn.actor().unwrap().silo_user_id().unwrap(), + AuthnMode::PrivilegedUser, + ) + .await; + + // Create a token + + let created_token: views::ScimClientBearerTokenValue = + object_create_no_body( + client, + &format!("/v1/system/scim/tokens?silo={}", SILO_NAME,), + ) + .await; + + // Point the scim2-rs crate's self tester at Nexus + + let tester = Tester::new( + client.url("/scim/v2").to_string(), + Some(created_token.bearer_token), + ) + .unwrap(); + + tester.run().await.unwrap(); +} + +// Test that disabling a SCIM user means they can no longer log in, and it +// invalidates all their sessions. +#[nexus_test] +async fn test_disabling_scim_user(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.server_context().nexus; + let opctx = OpContext::for_tests( + cptestctx.logctx.log.new(o!()), + nexus.datastore().clone(), + ); + + // Create the Silo + + const SILO_NAME: &str = "saml-scim-silo"; + create_silo(&client, SILO_NAME, true, shared::SiloIdentityMode::SamlScim) + .await; + + // Create a SAML IDP + + let _silo_saml_idp: views::SamlIdentityProvider = object_create( + client, + &format!("/v1/system/identity-providers/saml?silo={}", SILO_NAME), + ¶ms::SamlIdentityProviderCreate { + identity: IdentityMetadataCreateParams { + name: "some-totally-real-saml-provider" + .to_string() + .parse() + .unwrap(), + description: "a demo provider".to_string(), + }, + + idp_metadata_source: params::IdpMetadataSource::Base64EncodedXml { + data: base64::engine::general_purpose::STANDARD + .encode(SAML_RESPONSE_IDP_DESCRIPTOR), + }, + + idp_entity_id: "https://some.idp.test/oxide_rack/".to_string(), + sp_client_id: "client_id".to_string(), + acs_url: "https://customer.site/oxide_rack/saml".to_string(), + slo_url: "https://customer.site/oxide_rack/saml".to_string(), + technical_contact_email: "technical@fake".to_string(), + + signing_keypair: None, + + group_attribute_name: Some("groups".into()), + }, + ) + .await; + + nexus.set_samael_max_issue_delay( + chrono::Utc::now() + - "2022-05-04T15:36:12.631Z" + .parse::>() + .unwrap() + + chrono::Duration::seconds(60), + ); + + // The user isn't created yet so we should see a 401. + + NexusRequest::new( + RequestBuilder::new( + client, + Method::POST, + &format!( + "/login/{}/saml/some-totally-real-saml-provider", + SILO_NAME + ), + ) + .raw_body(Some( + serde_urlencoded::to_string(SamlLoginPost { + saml_response: base64::engine::general_purpose::STANDARD + .encode(SAML_RESPONSE_WITH_GROUPS), + relay_state: None, + }) + .unwrap(), + )) + .expect_status(Some(StatusCode::UNAUTHORIZED)), + ) + .execute() + .await + .expect("expected 401"); + + // Grant permissions on this silo for the PrivilegedUser + + grant_iam( + client, + &format!("/v1/system/silos/{SILO_NAME}"), + shared::SiloRole::Admin, + opctx.authn.actor().unwrap().silo_user_id().unwrap(), + AuthnMode::PrivilegedUser, + ) + .await; + + // Create a token + + let created_token: views::ScimClientBearerTokenValue = + object_create_no_body( + client, + &format!("/v1/system/scim/tokens?silo={}", SILO_NAME,), + ) + .await; + + // Using this SCIM token, create a user with a name matching the saml:NameID + // email in SAML_RESPONSE_WITH_GROUPS. + + NexusRequest::new( + RequestBuilder::new(client, Method::POST, "/scim/v2/Users") + .header(http::header::CONTENT_TYPE, "application/scim+json") + .header( + http::header::AUTHORIZATION, + format!("Bearer {}", created_token.bearer_token), + ) + .allow_non_dropshot_errors() + .raw_body(Some( + serde_json::to_string(&serde_json::json!( + { + "userName": "some@customer.com", + "externalId": "some@customer.com", + } + )) + .unwrap(), + )) + .expect_status(Some(StatusCode::CREATED)), + ) + .execute() + .await + .expect("expected 201"); + + // Now the user can log in and create a valid session + + let result = NexusRequest::new( + RequestBuilder::new( + client, + Method::POST, + &format!( + "/login/{}/saml/some-totally-real-saml-provider", + SILO_NAME + ), + ) + .raw_body(Some( + serde_urlencoded::to_string(SamlLoginPost { + saml_response: base64::engine::general_purpose::STANDARD + .encode(SAML_RESPONSE_WITH_GROUPS), + relay_state: None, + }) + .unwrap(), + )) + .expect_status(Some(StatusCode::SEE_OTHER)), + ) + .execute() + .await + .expect("expected 303"); + + let session_cookie_value = + result.headers["Set-Cookie"].to_str().unwrap().to_string(); + + let me: views::CurrentUser = NexusRequest::new( + RequestBuilder::new(client, Method::GET, "/v1/me") + .header(http::header::COOKIE, session_cookie_value.clone()) + .expect_status(Some(StatusCode::OK)), + ) + .execute() + .await + .expect("expected success") + .parsed_body() + .unwrap(); + + assert_eq!(me.user.display_name, String::from("some@customer.com")); + + // Disable the user by asetting active = false + + NexusRequest::new( + RequestBuilder::new( + client, + Method::PATCH, + &format!("/scim/v2/Users/{}", me.user.id), + ) + .header(http::header::CONTENT_TYPE, "application/scim+json") + .header( + http::header::AUTHORIZATION, + format!("Bearer {}", created_token.bearer_token), + ) + .allow_non_dropshot_errors() + .raw_body(Some( + serde_json::to_string(&serde_json::json!( + { + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:PatchOp" + ], + "Operations": [ + { + "op": "replace", + "value": { + "active": false + } + } + ] + } + )) + .unwrap(), + )) + .expect_status(Some(StatusCode::OK)), + ) + .execute() + .await + .expect("expected 200"); + + // The same session should not work anymore. + + NexusRequest::new( + RequestBuilder::new(client, Method::GET, "/v1/me") + .header(http::header::COOKIE, session_cookie_value.clone()) + .expect_status(Some(StatusCode::UNAUTHORIZED)), + ) + .execute() + .await + .expect("expected 401"); + + // And they can no longer log in + + NexusRequest::new( + RequestBuilder::new( + client, + Method::POST, + &format!( + "/login/{}/saml/some-totally-real-saml-provider", + SILO_NAME + ), + ) + .raw_body(Some( + serde_urlencoded::to_string(SamlLoginPost { + saml_response: base64::engine::general_purpose::STANDARD + .encode(SAML_RESPONSE_WITH_GROUPS), + relay_state: None, + }) + .unwrap(), + )) + .expect_status(Some(StatusCode::UNAUTHORIZED)), + ) + .execute() + .await + .expect("expected 401"); +} + +// Test that searching for a SCIM user works and is case insensitive +#[nexus_test] +async fn test_scim_user_search(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.server_context().nexus; + let opctx = OpContext::for_tests( + cptestctx.logctx.log.new(o!()), + nexus.datastore().clone(), + ); + + // Create the Silo + + const SILO_NAME: &str = "saml-scim-silo"; + create_silo(&client, SILO_NAME, true, shared::SiloIdentityMode::SamlScim) + .await; + + // Grant permissions on this silo for the PrivilegedUser + + grant_iam( + client, + &format!("/v1/system/silos/{SILO_NAME}"), + shared::SiloRole::Admin, + opctx.authn.actor().unwrap().silo_user_id().unwrap(), + AuthnMode::PrivilegedUser, + ) + .await; + + // Create a token + + let created_token: views::ScimClientBearerTokenValue = + object_create_no_body( + client, + &format!("/v1/system/scim/tokens?silo={}", SILO_NAME,), + ) + .await; + + // Using this SCIM token, create two users. + + let _mike: scim2_rs::User = NexusRequest::new( + RequestBuilder::new(client, Method::POST, "/scim/v2/Users") + .header(http::header::CONTENT_TYPE, "application/scim+json") + .header( + http::header::AUTHORIZATION, + format!("Bearer {}", created_token.bearer_token), + ) + .allow_non_dropshot_errors() + .raw_body(Some( + serde_json::to_string(&serde_json::json!( + { + "userName": "mscott", + "externalId": "mscott@dundermifflin.com", + } + )) + .unwrap(), + )) + .expect_status(Some(StatusCode::CREATED)), + ) + .execute() + .await + .expect("expected 201") + .parsed_body() + .expect("created user"); + + let created_user: scim2_rs::User = NexusRequest::new( + RequestBuilder::new(client, Method::POST, "/scim/v2/Users") + .header(http::header::CONTENT_TYPE, "application/scim+json") + .header( + http::header::AUTHORIZATION, + format!("Bearer {}", created_token.bearer_token), + ) + .allow_non_dropshot_errors() + .raw_body(Some( + serde_json::to_string(&serde_json::json!( + { + // Note that the name uses upper case! + "userName": "JHALPERT", + "externalId": "jhalpert@dundermifflin.com", + } + )) + .unwrap(), + )) + .expect_status(Some(StatusCode::CREATED)), + ) + .execute() + .await + .expect("expected 201") + .parsed_body() + .expect("created user"); + + // Now search for that user + + let response: scim2_rs::ListResponse = NexusRequest::new( + RequestBuilder::new( + client, + Method::GET, + "/scim/v2/Users?filter=username%20eq%20%22JHALPERT%22", + ) + .header(http::header::CONTENT_TYPE, "application/scim+json") + .header( + http::header::AUTHORIZATION, + format!("Bearer {}", created_token.bearer_token), + ) + .allow_non_dropshot_errors() + .expect_status(Some(StatusCode::OK)), + ) + .execute() + .await + .expect("expected 200") + .parsed_body() + .expect("list of users"); + + // `response.resources` is a Vec of generic resources that have the type + // `serde_json::Map`. This to_value -> from_value + // converts it into a User without a trip though string serialization + + // deserialization. + let users: Vec = serde_json::from_value( + serde_json::to_value(&response.resources).unwrap(), + ) + .unwrap(); + + assert_eq!(users.len(), 1); + assert_eq!(users[0].id, created_user.id); + + // Case insensitive search should also work + + let response: scim2_rs::ListResponse = NexusRequest::new( + RequestBuilder::new( + client, + Method::GET, + "/scim/v2/Users?filter=username%20eq%20%22JhaLpErT%22", + ) + .header(http::header::CONTENT_TYPE, "application/scim+json") + .header( + http::header::AUTHORIZATION, + format!("Bearer {}", created_token.bearer_token), + ) + .allow_non_dropshot_errors() + .expect_status(Some(StatusCode::OK)), + ) + .execute() + .await + .expect("expected 200") + .parsed_body() + .expect("list of users"); + + let users: Vec = serde_json::from_value( + serde_json::to_value(&response.resources).unwrap(), + ) + .unwrap(); + + assert_eq!(users.len(), 1); + assert_eq!(users[0].id, created_user.id); + + // Searching for a non-existent user should return nothing + + let response: scim2_rs::ListResponse = NexusRequest::new( + RequestBuilder::new( + client, + Method::GET, + "/scim/v2/Users?filter=username%20eq%20%22dschrute%22", + ) + .header(http::header::CONTENT_TYPE, "application/scim+json") + .header( + http::header::AUTHORIZATION, + format!("Bearer {}", created_token.bearer_token), + ) + .allow_non_dropshot_errors() + .expect_status(Some(StatusCode::OK)), + ) + .execute() + .await + .expect("expected 200") + .parsed_body() + .expect("list of users"); + + assert_eq!(response.total_results, 0); +} + +// Test that searching for a SCIM group works and is case insensitive +#[nexus_test] +async fn test_scim_group_search(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.server_context().nexus; + let opctx = OpContext::for_tests( + cptestctx.logctx.log.new(o!()), + nexus.datastore().clone(), + ); + + // Create the Silo + + const SILO_NAME: &str = "saml-scim-silo"; + create_silo(&client, SILO_NAME, true, shared::SiloIdentityMode::SamlScim) + .await; + + // Grant permissions on this silo for the PrivilegedUser + + grant_iam( + client, + &format!("/v1/system/silos/{SILO_NAME}"), + shared::SiloRole::Admin, + opctx.authn.actor().unwrap().silo_user_id().unwrap(), + AuthnMode::PrivilegedUser, + ) + .await; + + // Create a token + + let created_token: views::ScimClientBearerTokenValue = + object_create_no_body( + client, + &format!("/v1/system/scim/tokens?silo={}", SILO_NAME,), + ) + .await; + + // Using this SCIM token, create two groups. + + let _regional_managers: scim2_rs::Group = NexusRequest::new( + RequestBuilder::new(client, Method::POST, "/scim/v2/Groups") + .header(http::header::CONTENT_TYPE, "application/scim+json") + .header( + http::header::AUTHORIZATION, + format!("Bearer {}", created_token.bearer_token), + ) + .allow_non_dropshot_errors() + .raw_body(Some( + serde_json::to_string(&serde_json::json!( + { + "displayName": "regional_managers", + "externalId": "regional_managers@dundermifflin.com", + } + )) + .unwrap(), + )) + .expect_status(Some(StatusCode::CREATED)), + ) + .execute() + .await + .expect("expected 201") + .parsed_body() + .expect("created group"); + + let created_group: scim2_rs::Group = NexusRequest::new( + RequestBuilder::new(client, Method::POST, "/scim/v2/Groups") + .header(http::header::CONTENT_TYPE, "application/scim+json") + .header( + http::header::AUTHORIZATION, + format!("Bearer {}", created_token.bearer_token), + ) + .allow_non_dropshot_errors() + .raw_body(Some( + serde_json::to_string(&serde_json::json!( + { + // Note that the name uses upper case! + "displayName": "ASSISTANT_TO_REGIONAL_MANAGER", + "externalId": "arm@dundermifflin.com", + } + )) + .unwrap(), + )) + .expect_status(Some(StatusCode::CREATED)), + ) + .execute() + .await + .expect("expected 201") + .parsed_body() + .expect("created group"); + + // Now search for that group + + let response: scim2_rs::ListResponse = NexusRequest::new( + RequestBuilder::new( + client, + Method::GET, + &format!( + "/scim/v2/Groups?filter=displayname%20eq%20%22{}%22", + "ASSISTANT_TO_REGIONAL_MANAGER", + ), + ) + .header(http::header::CONTENT_TYPE, "application/scim+json") + .header( + http::header::AUTHORIZATION, + format!("Bearer {}", created_token.bearer_token), + ) + .allow_non_dropshot_errors() + .expect_status(Some(StatusCode::OK)), + ) + .execute() + .await + .expect("expected 200") + .parsed_body() + .expect("list of groups"); + + // `response.resources` is a Vec of generic resources that have the type + // `serde_json::Map`. This to_value -> from_value + // converts it into a Group without a trip though string serialization + + // deserialization. + let groups: Vec = serde_json::from_value( + serde_json::to_value(&response.resources).unwrap(), + ) + .unwrap(); + + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].id, created_group.id); + + // Case insensitive search should also work + + let response: scim2_rs::ListResponse = NexusRequest::new( + RequestBuilder::new( + client, + Method::GET, + &format!( + "/scim/v2/Groups?filter=displayname%20eq%20%22{}%22", + "AsSIStANT_TO_regIOnAL_mANaGER", + ), + ) + .header(http::header::CONTENT_TYPE, "application/scim+json") + .header( + http::header::AUTHORIZATION, + format!("Bearer {}", created_token.bearer_token), + ) + .allow_non_dropshot_errors() + .expect_status(Some(StatusCode::OK)), + ) + .execute() + .await + .expect("expected 200") + .parsed_body() + .expect("list of groups"); + + let groups: Vec = serde_json::from_value( + serde_json::to_value(&response.resources).unwrap(), + ) + .unwrap(); + + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].id, created_group.id); + + // Searching for a non-existent group should return nothing + + let response: scim2_rs::ListResponse = NexusRequest::new( + RequestBuilder::new( + client, + Method::GET, + &format!( + "/scim/v2/Groups?filter=displayname%20eq%20%22{}%22", + "assistant_to_assistant_to_regional_manager", + ), + ) + .header(http::header::CONTENT_TYPE, "application/scim+json") + .header( + http::header::AUTHORIZATION, + format!("Bearer {}", created_token.bearer_token), + ) + .allow_non_dropshot_errors() + .expect_status(Some(StatusCode::OK)), + ) + .execute() + .await + .expect("expected 200") + .parsed_body() + .expect("list of groups"); + + assert_eq!(response.total_results, 0); +} + +// Test that for SCIM users, userName is unique (even if case is different) +#[nexus_test] +async fn test_scim_user_unique(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.server_context().nexus; + let opctx = OpContext::for_tests( + cptestctx.logctx.log.new(o!()), + nexus.datastore().clone(), + ); + + // Create the Silo + + const SILO_NAME: &str = "saml-scim-silo"; + create_silo(&client, SILO_NAME, true, shared::SiloIdentityMode::SamlScim) + .await; + + // Grant permissions on this silo for the PrivilegedUser + + grant_iam( + client, + &format!("/v1/system/silos/{SILO_NAME}"), + shared::SiloRole::Admin, + opctx.authn.actor().unwrap().silo_user_id().unwrap(), + AuthnMode::PrivilegedUser, + ) + .await; + + // Create a token + + let created_token: views::ScimClientBearerTokenValue = + object_create_no_body( + client, + &format!("/v1/system/scim/tokens?silo={}", SILO_NAME,), + ) + .await; + + // Using this SCIM token, try to create two "identical" users. + + let _mike: scim2_rs::User = NexusRequest::new( + RequestBuilder::new(client, Method::POST, "/scim/v2/Users") + .header(http::header::CONTENT_TYPE, "application/scim+json") + .header( + http::header::AUTHORIZATION, + format!("Bearer {}", created_token.bearer_token), + ) + .allow_non_dropshot_errors() + .raw_body(Some( + serde_json::to_string(&serde_json::json!( + { + "userName": "mscott", + "externalId": "mscott@dundermifflin.com", + } + )) + .unwrap(), + )) + .expect_status(Some(StatusCode::CREATED)), + ) + .execute() + .await + .expect("expected 201") + .parsed_body() + .expect("created user"); + + NexusRequest::new( + RequestBuilder::new(client, Method::POST, "/scim/v2/Users") + .header(http::header::CONTENT_TYPE, "application/scim+json") + .header( + http::header::AUTHORIZATION, + format!("Bearer {}", created_token.bearer_token), + ) + .allow_non_dropshot_errors() + .raw_body(Some( + serde_json::to_string(&serde_json::json!( + { + "userName": "MscOtT", + "externalId": "mscott@dundermifflin.com", + } + )) + .unwrap(), + )) + .expect_status(Some(StatusCode::CONFLICT)), + ) + .execute() + .await + .expect("expected 409"); + + // Now, create a different user, then try to PUT so that the name matches. + // This should fail with a 409 as well. + + let mike_scarn: scim2_rs::User = NexusRequest::new( + RequestBuilder::new(client, Method::POST, "/scim/v2/Users") + .header(http::header::CONTENT_TYPE, "application/scim+json") + .header( + http::header::AUTHORIZATION, + format!("Bearer {}", created_token.bearer_token), + ) + .allow_non_dropshot_errors() + .raw_body(Some( + serde_json::to_string(&serde_json::json!( + { + "userName": "mscarn", + "externalId": "mscarn@dundermifflin.com", + } + )) + .unwrap(), + )) + .expect_status(Some(StatusCode::CREATED)), + ) + .execute() + .await + .expect("expected 201") + .parsed_body() + .expect("created user"); + + NexusRequest::new( + RequestBuilder::new( + client, + Method::PUT, + &format!("/scim/v2/Users/{}", mike_scarn.id), + ) + .header(http::header::CONTENT_TYPE, "application/scim+json") + .header( + http::header::AUTHORIZATION, + format!("Bearer {}", created_token.bearer_token), + ) + .allow_non_dropshot_errors() + .raw_body(Some( + serde_json::to_string(&serde_json::json!( + { + "userName": "mscott", + "externalId": "mscott@dundermifflin.com", + } + )) + .unwrap(), + )) + .expect_status(Some(StatusCode::CONFLICT)), + ) + .execute() + .await + .expect("expected 409"); +} + +// Test that for SCIM groups, displayName is unique (even if case is different) +#[nexus_test] +async fn test_scim_group_unique(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.server_context().nexus; + let opctx = OpContext::for_tests( + cptestctx.logctx.log.new(o!()), + nexus.datastore().clone(), + ); + + // Create the Silo + + const SILO_NAME: &str = "saml-scim-silo"; + create_silo(&client, SILO_NAME, true, shared::SiloIdentityMode::SamlScim) + .await; + + // Grant permissions on this silo for the PrivilegedUser + + grant_iam( + client, + &format!("/v1/system/silos/{SILO_NAME}"), + shared::SiloRole::Admin, + opctx.authn.actor().unwrap().silo_user_id().unwrap(), + AuthnMode::PrivilegedUser, + ) + .await; + + // Create a token + + let created_token: views::ScimClientBearerTokenValue = + object_create_no_body( + client, + &format!("/v1/system/scim/tokens?silo={}", SILO_NAME,), + ) + .await; + + // Using this SCIM token, try to create two "identical" groups + + let _sales: scim2_rs::Group = NexusRequest::new( + RequestBuilder::new(client, Method::POST, "/scim/v2/Groups") + .header(http::header::CONTENT_TYPE, "application/scim+json") + .header( + http::header::AUTHORIZATION, + format!("Bearer {}", created_token.bearer_token), + ) + .allow_non_dropshot_errors() + .raw_body(Some( + serde_json::to_string(&serde_json::json!( + { + "displayName": "Sales", + "externalId": "sales@dundermifflin.com", + } + )) + .unwrap(), + )) + .expect_status(Some(StatusCode::CREATED)), + ) + .execute() + .await + .expect("expected 201") + .parsed_body() + .expect("created group"); + + NexusRequest::new( + RequestBuilder::new(client, Method::POST, "/scim/v2/Groups") + .header(http::header::CONTENT_TYPE, "application/scim+json") + .header( + http::header::AUTHORIZATION, + format!("Bearer {}", created_token.bearer_token), + ) + .allow_non_dropshot_errors() + .raw_body(Some( + serde_json::to_string(&serde_json::json!( + { + "displayName": "SALES", + "externalId": "sales@dundermifflin.com", + } + )) + .unwrap(), + )) + .expect_status(Some(StatusCode::CONFLICT)), + ) + .execute() + .await + .expect("expected 409"); + + // Now, create a different group, then try to PUT so that the name matches. + // This should fail with a 409 as well. + + let accounting: scim2_rs::Group = NexusRequest::new( + RequestBuilder::new(client, Method::POST, "/scim/v2/Groups") + .header(http::header::CONTENT_TYPE, "application/scim+json") + .header( + http::header::AUTHORIZATION, + format!("Bearer {}", created_token.bearer_token), + ) + .allow_non_dropshot_errors() + .raw_body(Some( + serde_json::to_string(&serde_json::json!( + { + "displayName": "accounting", + "externalId": "accounting@dundermifflin.com", + } + )) + .unwrap(), + )) + .expect_status(Some(StatusCode::CREATED)), + ) + .execute() + .await + .expect("expected 201") + .parsed_body() + .expect("created group"); + + NexusRequest::new( + RequestBuilder::new( + client, + Method::PUT, + &format!("/scim/v2/Groups/{}", accounting.id), + ) + .header(http::header::CONTENT_TYPE, "application/scim+json") + .header( + http::header::AUTHORIZATION, + format!("Bearer {}", created_token.bearer_token), + ) + .allow_non_dropshot_errors() + .raw_body(Some( + serde_json::to_string(&serde_json::json!( + { + "displayName": "sales", + "externalId": "sales@dundermifflin.com", + } + )) + .unwrap(), + )) + .expect_status(Some(StatusCode::CONFLICT)), + ) + .execute() + .await + .expect("expected 409"); +} + +// Test that a group with the silo admin group name confers admin privileges +#[nexus_test] +async fn test_scim_user_admin_group_priv(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.server_context().nexus; + let opctx = OpContext::for_tests( + cptestctx.logctx.log.new(o!()), + nexus.datastore().clone(), + ); + + // Create the Silo + + const SILO_NAME: &str = "saml-scim-silo"; + create_silo_with_admin_group_name( + &client, + SILO_NAME, + true, + shared::SiloIdentityMode::SamlScim, + Some(String::from("scranton_admins")), + ) + .await; + + // Create a SAML IDP + + let _silo_saml_idp: views::SamlIdentityProvider = object_create( + client, + &format!("/v1/system/identity-providers/saml?silo={}", SILO_NAME), + ¶ms::SamlIdentityProviderCreate { + identity: IdentityMetadataCreateParams { + name: "some-totally-real-saml-provider" + .to_string() + .parse() + .unwrap(), + description: "a demo provider".to_string(), + }, + + idp_metadata_source: params::IdpMetadataSource::Base64EncodedXml { + data: base64::engine::general_purpose::STANDARD + .encode(SAML_RESPONSE_IDP_DESCRIPTOR), + }, + + idp_entity_id: "https://some.idp.test/oxide_rack/".to_string(), + sp_client_id: "client_id".to_string(), + acs_url: "https://customer.site/oxide_rack/saml".to_string(), + slo_url: "https://customer.site/oxide_rack/saml".to_string(), + technical_contact_email: "technical@fake".to_string(), + + signing_keypair: None, + + group_attribute_name: Some("groups".into()), + }, + ) + .await; + + nexus.set_samael_max_issue_delay( + chrono::Utc::now() + - "2022-05-04T15:36:12.631Z" + .parse::>() + .unwrap() + + chrono::Duration::seconds(60), + ); + + // Grant permissions on this silo for the PrivilegedUser + + grant_iam( + client, + &format!("/v1/system/silos/{SILO_NAME}"), + shared::SiloRole::Admin, + opctx.authn.actor().unwrap().silo_user_id().unwrap(), + AuthnMode::PrivilegedUser, + ) + .await; + + // Create a token + + let created_token: views::ScimClientBearerTokenValue = + object_create_no_body( + client, + &format!("/v1/system/scim/tokens?silo={}", SILO_NAME,), + ) + .await; + + // Using this SCIM token, create a user with a name matching the saml:NameID + // email in SAML_RESPONSE_WITH_GROUPS. + + let user: scim2_rs::User = NexusRequest::new( + RequestBuilder::new(client, Method::POST, "/scim/v2/Users") + .header(http::header::CONTENT_TYPE, "application/scim+json") + .header( + http::header::AUTHORIZATION, + format!("Bearer {}", created_token.bearer_token), + ) + .allow_non_dropshot_errors() + .raw_body(Some( + serde_json::to_string(&serde_json::json!( + { + "userName": "some@customer.com", + "externalId": "some@customer.com", + } + )) + .unwrap(), + )) + .expect_status(Some(StatusCode::CREATED)), + ) + .execute() + .await + .expect("expected 201") + .parsed_body() + .expect("created user"); + + // Login with that user + + let result = NexusRequest::new( + RequestBuilder::new( + client, + Method::POST, + &format!( + "/login/{}/saml/some-totally-real-saml-provider", + SILO_NAME + ), + ) + .raw_body(Some( + serde_urlencoded::to_string(SamlLoginPost { + saml_response: base64::engine::general_purpose::STANDARD + .encode(SAML_RESPONSE_WITH_GROUPS), + relay_state: None, + }) + .unwrap(), + )) + .expect_status(Some(StatusCode::SEE_OTHER)), + ) + .execute() + .await + .expect("expected 303"); + + let session_cookie_value = + result.headers["Set-Cookie"].to_str().unwrap().to_string(); + + // Initially this user should _not_ have the silo admin role, they are not + // part of any group. + + let me: views::CurrentUser = NexusRequest::new( + RequestBuilder::new(client, Method::GET, "/v1/me") + .header(http::header::COOKIE, session_cookie_value.clone()) + .expect_status(Some(StatusCode::OK)), + ) + .execute() + .await + .expect("expected success") + .parsed_body() + .unwrap(); + + assert!(!me.silo_admin); + + // Creating the group with a name that matches the silo admin group name but + // with no members does not change the existing user's role assignment. + + let admin_group: scim2_rs::Group = NexusRequest::new( + RequestBuilder::new(client, Method::POST, "/scim/v2/Groups") + .header(http::header::CONTENT_TYPE, "application/scim+json") + .header( + http::header::AUTHORIZATION, + format!("Bearer {}", created_token.bearer_token), + ) + .allow_non_dropshot_errors() + .raw_body(Some( + serde_json::to_string(&serde_json::json!( + { + "displayName": "scranton_admins", + "externalId": "scranton_admins", + "members": [], + } + )) + .unwrap(), + )) + .expect_status(Some(StatusCode::CREATED)), + ) + .execute() + .await + .expect("expected 201") + .parsed_body() + .expect("created group"); + + let me: views::CurrentUser = NexusRequest::new( + RequestBuilder::new(client, Method::GET, "/v1/me") + .header(http::header::COOKIE, session_cookie_value.clone()) + .expect_status(Some(StatusCode::OK)), + ) + .execute() + .await + .expect("expected success") + .parsed_body() + .unwrap(); + + assert!(!me.silo_admin); + + // Then, add the user to the silo admin group via a PATCH - they should gain + // the admin role + + NexusRequest::new( + RequestBuilder::new( + client, + Method::PATCH, + &format!("/scim/v2/Groups/{}", admin_group.id), + ) + .header(http::header::CONTENT_TYPE, "application/scim+json") + .header( + http::header::AUTHORIZATION, + format!("Bearer {}", created_token.bearer_token), + ) + .allow_non_dropshot_errors() + .raw_body(Some( + serde_json::to_string(&serde_json::json!( + { + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:PatchOp" + ], + "Operations": [ + { + "op": "add", + "path": "members", + "value": [ + { + "value": user.id + } + ] + } + ] + } + )) + .unwrap(), + )) + .expect_status(Some(StatusCode::OK)), + ) + .execute() + .await + .expect("expected 200"); + + let me: views::CurrentUser = NexusRequest::new( + RequestBuilder::new(client, Method::GET, "/v1/me") + .header(http::header::COOKIE, session_cookie_value.clone()) + .expect_status(Some(StatusCode::OK)), + ) + .execute() + .await + .expect("expected success") + .parsed_body() + .unwrap(); + + assert!(me.silo_admin); + + // Renaming the group to _not_ the silo admin group name means they should + // lose the admin role + + NexusRequest::new( + RequestBuilder::new( + client, + Method::PATCH, + &format!("/scim/v2/Groups/{}", admin_group.id), + ) + .header(http::header::CONTENT_TYPE, "application/scim+json") + .header( + http::header::AUTHORIZATION, + format!("Bearer {}", created_token.bearer_token), + ) + .allow_non_dropshot_errors() + .raw_body(Some( + serde_json::to_string(&serde_json::json!( + { + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:PatchOp" + ], + "Operations": [ + { + "op": "replace", + "value": { + "id": admin_group.id, + "displayName": "scranton_the_electric_city", // WHAT? + } + } + ] + } + )) + .unwrap(), + )) + .expect_status(Some(StatusCode::OK)), + ) + .execute() + .await + .expect("expected 200"); + + let me: views::CurrentUser = NexusRequest::new( + RequestBuilder::new(client, Method::GET, "/v1/me") + .header(http::header::COOKIE, session_cookie_value.clone()) + .expect_status(Some(StatusCode::OK)), + ) + .execute() + .await + .expect("expected success") + .parsed_body() + .unwrap(); + + assert!(!me.silo_admin); + + // Renaming it back to what it was should mean they gain the admin role on + // their silo + + NexusRequest::new( + RequestBuilder::new( + client, + Method::PATCH, + &format!("/scim/v2/Groups/{}", admin_group.id), + ) + .header(http::header::CONTENT_TYPE, "application/scim+json") + .header( + http::header::AUTHORIZATION, + format!("Bearer {}", created_token.bearer_token), + ) + .allow_non_dropshot_errors() + .raw_body(Some( + serde_json::to_string(&serde_json::json!( + { + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:PatchOp" + ], + "Operations": [ + { + "op": "replace", + "value": { + "id": admin_group.id, + "displayName": "scranton_admins", + } + } + ] + } + )) + .unwrap(), + )) + .expect_status(Some(StatusCode::OK)), + ) + .execute() + .await + .expect("expected 200"); + + let me: views::CurrentUser = NexusRequest::new( + RequestBuilder::new(client, Method::GET, "/v1/me") + .header(http::header::COOKIE, session_cookie_value.clone()) + .expect_status(Some(StatusCode::OK)), + ) + .execute() + .await + .expect("expected success") + .parsed_body() + .unwrap(); + + assert!(me.silo_admin); + + // Removing them from the group means they lose the admin role on their silo + + NexusRequest::new( + RequestBuilder::new( + client, + Method::PATCH, + &format!("/scim/v2/Groups/{}", admin_group.id), + ) + .header(http::header::CONTENT_TYPE, "application/scim+json") + .header( + http::header::AUTHORIZATION, + format!("Bearer {}", created_token.bearer_token), + ) + .allow_non_dropshot_errors() + .raw_body(Some( + serde_json::to_string(&serde_json::json!( + { + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:PatchOp" + ], + "Operations": [ + { + "op": "remove", + "path": "members", + "value": [ + { + "value": user.id, + } + ] + } + ] + } + )) + .unwrap(), + )) + .expect_status(Some(StatusCode::OK)), + ) + .execute() + .await + .expect("expected 200"); + + let me: views::CurrentUser = NexusRequest::new( + RequestBuilder::new(client, Method::GET, "/v1/me") + .header(http::header::COOKIE, session_cookie_value.clone()) + .expect_status(Some(StatusCode::OK)), + ) + .execute() + .await + .expect("expected success") + .parsed_body() + .unwrap(); + + assert!(!me.silo_admin); +} + +// Test that if a group already has the silo admin role, renaming it to the silo +// admin group name won't error with a conflict. +#[nexus_test] +async fn test_scim_user_admin_group_priv_conflict( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.server_context().nexus; + let opctx = OpContext::for_tests( + cptestctx.logctx.log.new(o!()), + nexus.datastore().clone(), + ); + + // Create the Silo + + const SILO_NAME: &str = "saml-scim-silo"; + create_silo_with_admin_group_name( + &client, + SILO_NAME, + true, + shared::SiloIdentityMode::SamlScim, + Some(String::from("assistant_to_assistant_to_regional_manager")), + ) + .await; + + // Grant permissions on this silo for the PrivilegedUser + + grant_iam( + client, + &format!("/v1/system/silos/{SILO_NAME}"), + shared::SiloRole::Admin, + opctx.authn.actor().unwrap().silo_user_id().unwrap(), + AuthnMode::PrivilegedUser, + ) + .await; + + // Create a token + + let created_token: views::ScimClientBearerTokenValue = + object_create_no_body( + client, + &format!("/v1/system/scim/tokens?silo={}", SILO_NAME,), + ) + .await; + + // Create the group with a name that does not match the silo admin group + // name. + + let group: scim2_rs::Group = NexusRequest::new( + RequestBuilder::new(client, Method::POST, "/scim/v2/Groups") + .header(http::header::CONTENT_TYPE, "application/scim+json") + .header( + http::header::AUTHORIZATION, + format!("Bearer {}", created_token.bearer_token), + ) + .allow_non_dropshot_errors() + .raw_body(Some( + serde_json::to_string(&serde_json::json!( + { + "displayName": "assistant_to_regional_manager", + "externalId": "assistant_to_regional_manager", + "members": [], + } + )) + .unwrap(), + )) + .expect_status(Some(StatusCode::CREATED)), + ) + .execute() + .await + .expect("expected 201") + .parsed_body() + .expect("created group"); + + // Create a role assignment of silo admin for this group + + grant_iam_for_group( + client, + &format!("/v1/system/silos/{SILO_NAME}"), + shared::SiloRole::Admin, + SiloGroupUuid::from_untyped_uuid(group.id.parse().unwrap()), + AuthnMode::PrivilegedUser, + ) + .await; + + // Rename the group to match the silo's admin group name - this should not + // error out + + NexusRequest::new( + RequestBuilder::new( + client, + Method::PATCH, + &format!("/scim/v2/Groups/{}", group.id), + ) + .header(http::header::CONTENT_TYPE, "application/scim+json") + .header( + http::header::AUTHORIZATION, + format!("Bearer {}", created_token.bearer_token), + ) + .allow_non_dropshot_errors() + .raw_body(Some( + serde_json::to_string(&serde_json::json!( + { + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:PatchOp" + ], + "Operations": [ + { + "op": "replace", + "value": { + "id": group.id, + "displayName": + "assistant_to_assistant_to_regional_manager", + } + } + ] + } + )) + .unwrap(), + )) + .expect_status(Some(StatusCode::OK)), + ) + .execute() + .await + .expect("expected 200"); +} diff --git a/schema/crdb/dbinit.sql b/schema/crdb/dbinit.sql index 971df41053..97dde2efe6 100644 --- a/schema/crdb/dbinit.sql +++ b/schema/crdb/dbinit.sql @@ -940,9 +940,9 @@ WHERE (user_provision_type = 'api_only' OR user_provision_type = 'jit'); CREATE UNIQUE INDEX IF NOT EXISTS - lookup_silo_user_by_silo_and_user_name + lookup_silo_user_by_silo_and_user_name_lower ON - omicron.public.silo_user (silo_id, user_name) + omicron.public.silo_user (silo_id, LOWER(user_name)) WHERE time_deleted IS NULL AND user_provision_type = 'scim'; @@ -1003,9 +1003,9 @@ WHERE (user_provision_type = 'api_only' OR user_provision_type = 'jit'); CREATE UNIQUE INDEX IF NOT EXISTS - lookup_silo_group_by_silo_and_display_name + lookup_silo_group_by_silo_and_display_name_lower ON - omicron.public.silo_group (silo_id, display_name) + omicron.public.silo_group (silo_id, LOWER(display_name)) WHERE time_deleted IS NULL AND user_provision_type = 'scim'; @@ -3150,6 +3150,13 @@ CREATE TABLE IF NOT EXISTS omicron.public.role_assignment ( ) ); +/* + * When SCIM IdPs delete users and groups we want to be able to cleanup all role + * assignments associated with them. + */ +CREATE INDEX IF NOT EXISTS lookup_role_assignment_by_identity_id + ON omicron.public.role_assignment ( identity_id ); + /*******************************************************************/ /* @@ -6132,6 +6139,9 @@ CREATE TABLE IF NOT EXISTS omicron.public.audit_log ( -- For silo_user: must have both actor_id and actor_silo_id (actor_kind = 'silo_user' AND actor_id IS NOT NULL AND actor_silo_id IS NOT NULL) OR + -- For a scim actor: must have a actor_silo_id + (actor_kind = 'scim' AND actor_id IS NULL AND actor_silo_id IS NOT NULL) + OR -- For unauthenticated: must not have actor_id or actor_silo_id (actor_kind = 'unauthenticated' AND actor_id IS NULL AND actor_silo_id IS NULL) ) @@ -6857,7 +6867,7 @@ INSERT INTO omicron.public.db_metadata ( version, target_version ) VALUES - (TRUE, NOW(), NOW(), '202.0.0', NULL) + (TRUE, NOW(), NOW(), '203.0.0', NULL) ON CONFLICT DO NOTHING; COMMIT; diff --git a/schema/crdb/scim-actor-audit-log/up01.sql b/schema/crdb/scim-actor-audit-log/up01.sql new file mode 100644 index 0000000000..348b11329f --- /dev/null +++ b/schema/crdb/scim-actor-audit-log/up01.sql @@ -0,0 +1,4 @@ +ALTER TABLE + omicron.public.audit_log +DROP CONSTRAINT IF EXISTS + actor_kind_and_id_consistent; diff --git a/schema/crdb/scim-actor-audit-log/up02.sql b/schema/crdb/scim-actor-audit-log/up02.sql new file mode 100644 index 0000000000..6a170aa7bc --- /dev/null +++ b/schema/crdb/scim-actor-audit-log/up02.sql @@ -0,0 +1,16 @@ +ALTER TABLE + omicron.public.audit_log +ADD CONSTRAINT IF NOT EXISTS + actor_kind_and_id_consistent CHECK ( + -- For user_builtin: must have actor_id, must not have actor_silo_id + (actor_kind = 'user_builtin' AND actor_id IS NOT NULL AND actor_silo_id IS NULL) + OR + -- For silo_user: must have both actor_id and actor_silo_id + (actor_kind = 'silo_user' AND actor_id IS NOT NULL AND actor_silo_id IS NOT NULL) + OR + -- For a scim actor: must have a actor_silo_id + (actor_kind = 'scim' AND actor_id IS NULL AND actor_silo_id IS NOT NULL) + OR + -- For unauthenticated: must not have actor_id or actor_silo_id + (actor_kind = 'unauthenticated' AND actor_id IS NULL AND actor_silo_id IS NULL) + ); diff --git a/schema/crdb/scim-actor-audit-log/up03.sql b/schema/crdb/scim-actor-audit-log/up03.sql new file mode 100644 index 0000000000..ffeb5e1b4d --- /dev/null +++ b/schema/crdb/scim-actor-audit-log/up03.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS lookup_silo_group_by_silo_and_display_name; diff --git a/schema/crdb/scim-actor-audit-log/up04.sql b/schema/crdb/scim-actor-audit-log/up04.sql new file mode 100644 index 0000000000..7a648a0da1 --- /dev/null +++ b/schema/crdb/scim-actor-audit-log/up04.sql @@ -0,0 +1,6 @@ +CREATE UNIQUE INDEX IF NOT EXISTS + lookup_silo_group_by_silo_and_display_name_lower +ON + omicron.public.silo_group (silo_id, LOWER(display_name)) +WHERE + time_deleted IS NULL AND user_provision_type = 'scim'; diff --git a/schema/crdb/scim-actor-audit-log/up05.sql b/schema/crdb/scim-actor-audit-log/up05.sql new file mode 100644 index 0000000000..97d805c59a --- /dev/null +++ b/schema/crdb/scim-actor-audit-log/up05.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS lookup_silo_user_by_silo_and_user_name; diff --git a/schema/crdb/scim-actor-audit-log/up06.sql b/schema/crdb/scim-actor-audit-log/up06.sql new file mode 100644 index 0000000000..d7f721714b --- /dev/null +++ b/schema/crdb/scim-actor-audit-log/up06.sql @@ -0,0 +1,6 @@ +CREATE UNIQUE INDEX IF NOT EXISTS + lookup_silo_user_by_silo_and_user_name_lower +ON + omicron.public.silo_user (silo_id, LOWER(user_name)) +WHERE + time_deleted IS NULL AND user_provision_type = 'scim'; diff --git a/schema/crdb/scim-actor-audit-log/up07.sql b/schema/crdb/scim-actor-audit-log/up07.sql new file mode 100644 index 0000000000..a4955ad4d1 --- /dev/null +++ b/schema/crdb/scim-actor-audit-log/up07.sql @@ -0,0 +1,2 @@ +CREATE INDEX IF NOT EXISTS lookup_role_assignment_by_identity_id + ON omicron.public.role_assignment ( identity_id ); diff --git a/workspace-hack/Cargo.toml b/workspace-hack/Cargo.toml index 6ef9f57bba..abf375ace5 100644 --- a/workspace-hack/Cargo.toml +++ b/workspace-hack/Cargo.toml @@ -110,7 +110,7 @@ rand_chacha-468e82937335b1c9 = { package = "rand_chacha", version = "0.3.1", def regex = { version = "1.11.3" } regex-automata = { version = "0.4.11", default-features = false, features = ["dfa", "hybrid", "meta", "nfa", "perf", "std", "unicode"] } regex-syntax = { version = "0.8.5" } -reqwest = { version = "0.12.22", features = ["blocking", "cookies", "json", "rustls-tls", "stream"] } +reqwest = { version = "0.12.24", features = ["blocking", "cookies", "json", "rustls-tls", "stream"] } rsa = { version = "0.9.8", features = ["serde", "sha2"] } rustc-hash = { version = "2.1.1" } rustls = { version = "0.23.19", features = ["ring"] } @@ -251,7 +251,7 @@ rand_chacha-468e82937335b1c9 = { package = "rand_chacha", version = "0.3.1", def regex = { version = "1.11.3" } regex-automata = { version = "0.4.11", default-features = false, features = ["dfa", "hybrid", "meta", "nfa", "perf", "std", "unicode"] } regex-syntax = { version = "0.8.5" } -reqwest = { version = "0.12.22", features = ["blocking", "cookies", "json", "rustls-tls", "stream"] } +reqwest = { version = "0.12.24", features = ["blocking", "cookies", "json", "rustls-tls", "stream"] } rsa = { version = "0.9.8", features = ["serde", "sha2"] } rustc-hash = { version = "2.1.1" } rustls = { version = "0.23.19", features = ["ring"] } @@ -382,7 +382,6 @@ dof-9fbad63c4bcf4a8f = { package = "dof", version = "0.4.0", default-features = getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3.4", default-features = false, features = ["std"] } hyper-rustls = { version = "0.27.7", features = ["http2", "ring", "webpki-tokio"] } hyper-util = { version = "0.1.17", features = ["full"] } -itertools-5ef9efb8ec2df382 = { package = "itertools", version = "0.12.1" } itertools-93f6ce9d446188ac = { package = "itertools", version = "0.10.5" } miniz_oxide = { version = "0.8.5", default-features = false, features = ["with-alloc"] } mio = { version = "1.0.2", features = ["net", "os-ext"] } @@ -401,7 +400,6 @@ dof-9fbad63c4bcf4a8f = { package = "dof", version = "0.4.0", default-features = getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3.4", default-features = false, features = ["std"] } hyper-rustls = { version = "0.27.7", features = ["http2", "ring", "webpki-tokio"] } hyper-util = { version = "0.1.17", features = ["full"] } -itertools-5ef9efb8ec2df382 = { package = "itertools", version = "0.12.1" } itertools-93f6ce9d446188ac = { package = "itertools", version = "0.10.5" } miniz_oxide = { version = "0.8.5", default-features = false, features = ["with-alloc"] } mio = { version = "1.0.2", features = ["net", "os-ext"] }