diff --git a/common/src/sql/dbinit.sql b/common/src/sql/dbinit.sql index 313d8e354fe..b8fc1bcbc26 100644 --- a/common/src/sql/dbinit.sql +++ b/common/src/sql/dbinit.sql @@ -1418,6 +1418,11 @@ CREATE INDEX ON omicron.public.console_session ( time_created ); +-- This index is used to remove sessions for a user that's being deleted. +CREATE INDEX ON omicron.public.console_session ( + silo_user_id +); + /*******************************************************************/ CREATE TYPE omicron.public.update_artifact_kind AS ENUM ( @@ -1532,6 +1537,11 @@ CREATE UNIQUE INDEX ON omicron.public.device_access_token ( client_id, device_code ); +-- This index is used to remove tokens for a user that's being deleted. +CREATE INDEX ON omicron.public.device_access_token ( + silo_user_id +); + /* * Roles built into the system * diff --git a/nexus/db-model/src/silo_user.rs b/nexus/db-model/src/silo_user.rs index 146d8c22bc8..cd8a32705b1 100644 --- a/nexus/db-model/src/silo_user.rs +++ b/nexus/db-model/src/silo_user.rs @@ -15,6 +15,7 @@ pub struct SiloUser { #[diesel(embed)] identity: SiloUserIdentity, + pub time_deleted: Option>, pub silo_id: Uuid, /// The identity provider's ID for this user. @@ -23,7 +24,12 @@ pub struct SiloUser { impl SiloUser { pub fn new(silo_id: Uuid, user_id: Uuid, external_id: String) -> Self { - Self { identity: SiloUserIdentity::new(user_id), silo_id, external_id } + Self { + identity: SiloUserIdentity::new(user_id), + time_deleted: None, + silo_id, + external_id, + } } } diff --git a/nexus/src/app/iam.rs b/nexus/src/app/iam.rs index 1d178848277..a5ae495bacd 100644 --- a/nexus/src/app/iam.rs +++ b/nexus/src/app/iam.rs @@ -58,7 +58,8 @@ impl super::Nexus { // Silo users - pub async fn silo_users_list( + /// List users in the current Silo + pub async fn silo_users_list_current( &self, opctx: &OpContext, pagparams: &DataPageParams<'_, Uuid>, @@ -67,18 +68,23 @@ impl super::Nexus { .authn .silo_required() .internal_context("listing current silo's users")?; + let authz_silo_user_list = authz::SiloUserList::new(authz_silo.clone()); self.db_datastore - .silo_users_list_by_id(opctx, &authz_silo, pagparams) + .silo_users_list_by_id(opctx, &authz_silo_user_list, pagparams) .await } - pub async fn silo_user_fetch_by_id( + /// Fetch the currently-authenticated Silo user + pub async fn silo_user_fetch_self( &self, opctx: &OpContext, - silo_user_id: &Uuid, ) -> LookupResult { + let &actor = opctx + .authn + .actor_required() + .internal_context("loading current user")?; let (.., db_silo_user) = LookupPath::new(opctx, &self.db_datastore) - .silo_user_id(*silo_user_id) + .silo_user_id(actor.actor_id()) .fetch() .await?; Ok(db_silo_user) diff --git a/nexus/src/app/silo.rs b/nexus/src/app/silo.rs index 4d288bacbc0..155e3ca181e 100644 --- a/nexus/src/app/silo.rs +++ b/nexus/src/app/silo.rs @@ -4,6 +4,7 @@ //! Silos, Users, and SSH Keys. +use crate::authz::ApiResource; use crate::context::OpContext; use crate::db; use crate::db::identity::{Asset, Resource}; @@ -14,13 +15,15 @@ use crate::external_api::params; use crate::external_api::shared; use crate::{authn, authz}; use anyhow::Context; -use omicron_common::api::external::CreateResult; +use nexus_db_model::UserProvisionType; use omicron_common::api::external::DataPageParams; 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::UpdateResult; +use omicron_common::api::external::{CreateResult, ResourceType}; +use std::str::FromStr; use uuid::Uuid; impl super::Nexus { @@ -140,18 +143,138 @@ impl super::Nexus { // Users + /// Helper function for looking up a user in a Silo + /// + /// `LookupPath` lets you look up users directly, regardless of what Silo + /// they're in. This helper validates that they're in the expected Silo. + async fn silo_user_lookup_by_id( + &self, + opctx: &OpContext, + authz_silo: &authz::Silo, + silo_user_id: Uuid, + action: authz::Action, + ) -> LookupResult<(authz::SiloUser, db::model::SiloUser)> { + let (_, authz_silo_user, db_silo_user) = + LookupPath::new(opctx, self.datastore()) + .silo_user_id(silo_user_id) + .fetch_for(action) + .await?; + if db_silo_user.silo_id != authz_silo.id() { + return Err(authz_silo_user.not_found()); + } + + Ok((authz_silo_user, db_silo_user)) + } + + /// List the users in a Silo + pub async fn silo_list_users( + &self, + opctx: &OpContext, + silo_name: &Name, + pagparams: &DataPageParams<'_, Uuid>, + ) -> ListResultVec { + let (authz_silo,) = LookupPath::new(opctx, self.datastore()) + .silo_name(silo_name) + .lookup_for(authz::Action::Read) + .await?; + let authz_silo_user_list = authz::SiloUserList::new(authz_silo); + self.db_datastore + .silo_users_list_by_id(opctx, &authz_silo_user_list, pagparams) + .await + } + + /// Fetch a user in a Silo pub async fn silo_user_fetch( &self, opctx: &OpContext, + silo_name: &Name, silo_user_id: Uuid, ) -> LookupResult { - let (.., db_silo_user) = LookupPath::new(opctx, &self.datastore()) - .silo_user_id(silo_user_id) + let (authz_silo,) = LookupPath::new(opctx, self.datastore()) + .silo_name(silo_name) + .lookup_for(authz::Action::Read) + .await?; + let (_, db_silo_user) = self + .silo_user_lookup_by_id( + opctx, + &authz_silo, + silo_user_id, + authz::Action::Read, + ) + .await?; + Ok(db_silo_user) + } + + // The "local" identity provider (available only in `LocalOnly` Silos) + + /// Helper function for looking up a LocalOnly Silo by name + /// + /// This is called from contexts that are trying to access the "local" + /// identity provider. On failure, it returns a 404 for that identity + /// provider. + async fn local_idp_fetch_silo( + &self, + opctx: &OpContext, + silo_name: &Name, + ) -> LookupResult<(authz::Silo, db::model::Silo)> { + let (authz_silo, db_silo) = LookupPath::new(opctx, &self.db_datastore) + .silo_name(silo_name) .fetch() .await?; + if db_silo.user_provision_type != UserProvisionType::ApiOnly { + return Err(Error::not_found_by_name( + ResourceType::IdentityProvider, + &omicron_common::api::external::Name::from_str("local") + .unwrap(), + )); + } + Ok((authz_silo, db_silo)) + } + + /// Create a user in a Silo's local identity provider + pub async fn local_idp_create_user( + &self, + opctx: &OpContext, + silo_name: &Name, + new_user_params: params::UserCreate, + ) -> CreateResult { + let (authz_silo, _) = + self.local_idp_fetch_silo(opctx, silo_name).await?; + let authz_silo_user_list = authz::SiloUserList::new(authz_silo.clone()); + // TODO-cleanup This authz check belongs in silo_user_create(). + opctx + .authorize(authz::Action::CreateChild, &authz_silo_user_list) + .await?; + let silo_user = db::model::SiloUser::new( + authz_silo.id(), + Uuid::new_v4(), + new_user_params.external_id.as_ref().to_owned(), + ); + let (_, db_silo_user) = + self.datastore().silo_user_create(&authz_silo, silo_user).await?; Ok(db_silo_user) } + /// Delete a user in a Silo's local identity provider + pub async fn local_idp_delete_user( + &self, + opctx: &OpContext, + silo_name: &Name, + silo_user_id: Uuid, + ) -> DeleteResult { + let (authz_silo, _) = + self.local_idp_fetch_silo(opctx, silo_name).await?; + let (authz_silo_user, _) = self + .silo_user_lookup_by_id( + opctx, + &authz_silo, + silo_user_id, + authz::Action::Delete, + ) + .await?; + self.db_datastore.silo_user_delete(opctx, &authz_silo_user).await + } + /// Based on an authenticated subject, fetch or create a silo user pub async fn silo_user_from_authenticated_subject( &self, @@ -378,6 +501,12 @@ impl super::Nexus { .await?; let authz_idp_list = authz::SiloIdentityProviderList::new(authz_silo); + if db_silo.user_provision_type != UserProvisionType::Jit { + return Err(Error::invalid_request( + "cannot create identity providers in this kind of Silo", + )); + } + // This check is not strictly necessary yet. We'll check this // permission in the DataStore when we actually update the list. // But we check now to protect the code that fetches the descriptor from diff --git a/nexus/src/authz/api_resources.rs b/nexus/src/authz/api_resources.rs index ff8f166871f..34821072add 100644 --- a/nexus/src/authz/api_resources.rs +++ b/nexus/src/authz/api_resources.rs @@ -559,6 +559,63 @@ impl AuthorizedResource for SiloIdentityProviderList { } } +/// Synthetic resource describing the list of Users in a Silo +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SiloUserList(Silo); + +impl SiloUserList { + pub fn new(silo: Silo) -> SiloUserList { + SiloUserList(silo) + } + + pub fn silo(&self) -> &Silo { + &self.0 + } +} + +impl oso::PolarClass for SiloUserList { + fn get_polar_class_builder() -> oso::ClassBuilder { + oso::Class::builder() + .with_equality_check() + .add_attribute_getter("silo", |list: &SiloUserList| list.0.clone()) + } +} + +impl AuthorizedResource for SiloUserList { + fn load_roles<'a, 'b, 'c, 'd, 'e, 'f>( + &'a self, + opctx: &'b OpContext, + datastore: &'c DataStore, + authn: &'d authn::Context, + roleset: &'e mut RoleSet, + ) -> futures::future::BoxFuture<'f, Result<(), Error>> + where + 'a: 'f, + 'b: 'f, + 'c: 'f, + 'd: 'f, + 'e: 'f, + { + // There are no roles on this resource, but we still need to load the + // Silo-related roles. + self.silo().load_roles(opctx, datastore, authn, roleset) + } + + fn on_unauthorized( + &self, + _: &Authz, + error: Error, + _: AnyActor, + _: Action, + ) -> Error { + error + } + + fn polar_class(&self) -> oso::Class { + Self::get_polar_class() + } +} + // Main resource hierarchy: Organizations, Projects, and their resources authz_resource! { diff --git a/nexus/src/authz/omicron.polar b/nexus/src/authz/omicron.polar index d01387e9cbd..d5c6fffb437 100644 --- a/nexus/src/authz/omicron.polar +++ b/nexus/src/authz/omicron.polar @@ -239,19 +239,31 @@ resource SiloUser { "create_child", ]; - relations = { parent_silo: Silo }; + # Fleet and Silo administrators can manage a Silo's users. This is one + # of the only areas of Silo configuration that Fleet Administrators have + # permissions on. + relations = { parent_silo: Silo, parent_fleet: Fleet }; "list_children" if "viewer" on "parent_silo"; "read" if "viewer" on "parent_silo"; "modify" if "admin" on "parent_silo"; "create_child" if "admin" on "parent_silo"; + "list_children" if "admin" on "parent_fleet"; + "modify" if "admin" on "parent_fleet"; + "read" if "admin" on "parent_fleet"; + "create_child" if "admin" on "parent_fleet"; } has_relation(silo: Silo, "parent_silo", user: SiloUser) if user.silo = silo; +has_relation(fleet: Fleet, "parent_fleet", user: SiloUser) + if user.silo.fleet = fleet; # authenticated actors have all permissions on themselves has_permission(actor: AuthenticatedActor, _perm: String, silo_user: SiloUser) if actor.equals_silo_user(silo_user); +has_permission(actor: AuthenticatedActor, "read", silo_user: SiloUser) + if silo_user.silo in actor.silo; + resource SiloGroup { permissions = [ "list_children", @@ -413,11 +425,12 @@ resource SiloIdentityProviderList { "list_children" if "read" on "parent_silo"; # Fleet and Silo administrators can manage the Silo's identity provider - # configuration. This is the only area of Silo configuration that Fleet - # Administrators have permissions on. This is also the only case (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. + # configuration. This is one of the only areas of Silo configuration + # that Fleet Administrators have permissions on. This is also one of + # the only cases 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"; "create_child" if "admin" on "parent_fleet"; } @@ -426,6 +439,32 @@ has_relation(silo: Silo, "parent_silo", collection: SiloIdentityProviderList) has_relation(fleet: Fleet, "parent_fleet", collection: SiloIdentityProviderList) if collection.silo.fleet = fleet; +# Describes the policy for creating and managing Silo users (mostly intended for +# API-managed users) +resource SiloUserList { + permissions = [ "list_children", "create_child" ]; + + relations = { parent_silo: Silo, parent_fleet: Fleet }; + + # Everyone who can read the Silo (which includes all the users in the + # Silo) can see the users in it. + "list_children" if "read" on "parent_silo"; + + # Fleet and Silo administrators can manage the Silo's users. 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: SiloUserList) + if collection.silo = silo; +has_relation(fleet: Fleet, "parent_fleet", collection: SiloUserList) + if collection.silo.fleet = fleet; + # 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/src/authz/oso_generic.rs b/nexus/src/authz/oso_generic.rs index 185daa70b0d..7cd602546cb 100644 --- a/nexus/src/authz/oso_generic.rs +++ b/nexus/src/authz/oso_generic.rs @@ -110,6 +110,7 @@ pub fn make_omicron_oso(log: &slog::Logger) -> Result { ConsoleSessionList::get_polar_class(), DeviceAuthRequestList::get_polar_class(), SiloIdentityProviderList::get_polar_class(), + SiloUserList::get_polar_class(), ]; for c in classes { oso_builder = oso_builder.register_class(c)?; diff --git a/nexus/src/authz/policy_test/resource_builder.rs b/nexus/src/authz/policy_test/resource_builder.rs index 815883793eb..5607f82dddb 100644 --- a/nexus/src/authz/policy_test/resource_builder.rs +++ b/nexus/src/authz/policy_test/resource_builder.rs @@ -263,3 +263,20 @@ impl DynAuthorizedResource for authz::SiloIdentityProviderList { format!("{}: identity provider list", self.silo().resource_name()) } } + +impl DynAuthorizedResource for authz::SiloUserList { + 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!("{}: user list", self.silo().resource_name()) + } +} diff --git a/nexus/src/authz/policy_test/resources.rs b/nexus/src/authz/policy_test/resources.rs index 56471d85ae5..103e0bcc522 100644 --- a/nexus/src/authz/policy_test/resources.rs +++ b/nexus/src/authz/policy_test/resources.rs @@ -110,6 +110,7 @@ async fn make_silo( } builder.new_resource(authz::SiloIdentityProviderList::new(silo.clone())); + builder.new_resource(authz::SiloUserList::new(silo.clone())); let norganizations = if first_branch { 2 } else { 1 }; for i in 0..norganizations { diff --git a/nexus/src/db/datastore/silo_user.rs b/nexus/src/db/datastore/silo_user.rs index 393a7087fbb..ff77a33fba5 100644 --- a/nexus/src/db/datastore/silo_user.rs +++ b/nexus/src/db/datastore/silo_user.rs @@ -17,12 +17,16 @@ use crate::db::model::Name; use crate::db::model::SiloUser; use crate::db::model::UserBuiltin; use crate::db::pagination::paginated; +use crate::db::update_and_check::UpdateAndCheck; use crate::external_api::params; +use async_bb8_diesel::AsyncConnection; use async_bb8_diesel::AsyncRunQueryDsl; +use chrono::Utc; use diesel::prelude::*; use nexus_types::identity::Asset; use omicron_common::api::external::CreateResult; use omicron_common::api::external::DataPageParams; +use omicron_common::api::external::DeleteResult; use omicron_common::api::external::Error; use omicron_common::api::external::ListResultVec; use omicron_common::api::external::ResourceType; @@ -64,6 +68,84 @@ impl DataStore { }) } + /// Delete a Silo User + pub async fn silo_user_delete( + &self, + opctx: &OpContext, + authz_silo_user: &authz::SiloUser, + ) -> DeleteResult { + opctx.authorize(authz::Action::Delete, authz_silo_user).await?; + + // Delete the user and everything associated with them. + // TODO-scalability Many of these should be done in batches. + // TODO-robustness We might consider the RFD 192 "rcgen" pattern as well + // so that people can't, say, login while we do this. + let authz_silo_user_id = authz_silo_user.id(); + self.pool_authorized(opctx) + .await? + .transaction_async(|mut conn| async move { + // Delete the user record. + { + use db::schema::silo_user::dsl; + diesel::update(dsl::silo_user) + .filter(dsl::id.eq(authz_silo_user_id)) + .filter(dsl::time_deleted.is_null()) + .set(dsl::time_deleted.eq(Utc::now())) + .check_if_exists::(authz_silo_user_id) + .execute_and_check(&mut conn) + .await?; + } + + // Delete console sessions. + { + use db::schema::console_session::dsl; + diesel::delete(dsl::console_session) + .filter(dsl::silo_user_id.eq(authz_silo_user_id)) + .execute_async(&mut conn) + .await?; + } + + // Delete device authentication tokens. + { + use db::schema::device_access_token::dsl; + diesel::delete(dsl::device_access_token) + .filter(dsl::silo_user_id.eq(authz_silo_user_id)) + .execute_async(&mut conn) + .await?; + } + + // Delete group memberships. + { + use db::schema::silo_group_membership::dsl; + diesel::delete(dsl::silo_group_membership) + .filter(dsl::silo_user_id.eq(authz_silo_user_id)) + .execute_async(&mut conn) + .await?; + } + + // Delete ssh keys. + { + use db::schema::ssh_key::dsl; + diesel::update(dsl::ssh_key) + .filter(dsl::silo_user_id.eq(authz_silo_user_id)) + .filter(dsl::time_deleted.is_null()) + .set(dsl::time_deleted.eq(Utc::now())) + .execute_async(&mut conn) + .await?; + } + + Ok(()) + }) + .await + .map_err(|e| { + public_error_from_diesel_pool( + e, + ErrorHandler::NotFoundByResource(authz_silo_user), + ) + .internal_context("deleting silo user") + }) + } + /// Given an external ID, return /// - Ok(Some((authz::SiloUser, SiloUser))) if that external id refers to an /// existing silo user @@ -103,14 +185,16 @@ impl DataStore { pub async fn silo_users_list_by_id( &self, opctx: &OpContext, - authz_silo: &authz::Silo, + authz_silo_user_list: &authz::SiloUserList, pagparams: &DataPageParams<'_, Uuid>, ) -> ListResultVec { use db::schema::silo_user::dsl; - opctx.authorize(authz::Action::Read, authz_silo).await?; + opctx + .authorize(authz::Action::ListChildren, authz_silo_user_list) + .await?; paginated(dsl::silo_user, dsl::id, pagparams) - .filter(dsl::silo_id.eq(authz_silo.id())) + .filter(dsl::silo_id.eq(authz_silo_user_list.silo().id())) .filter(dsl::time_deleted.is_null()) .select(SiloUser::as_select()) .load_async::(self.pool_authorized(opctx).await?) diff --git a/nexus/src/db/lookup.rs b/nexus/src/db/lookup.rs index 957600384d0..5307aa84286 100644 --- a/nexus/src/db/lookup.rs +++ b/nexus/src/db/lookup.rs @@ -454,7 +454,8 @@ lookup_resource! { children = [ "SshKey" ], lookup_by_name = false, soft_deletes = true, - primary_key_columns = [ { column_name = "id", rust_type = Uuid } ] + primary_key_columns = [ { column_name = "id", rust_type = Uuid } ], + visible_outside_silo = true } lookup_resource! { diff --git a/nexus/src/db/update_and_check.rs b/nexus/src/db/update_and_check.rs index ff339f0ace6..8c7845b61b3 100644 --- a/nexus/src/db/update_and_check.rs +++ b/nexus/src/db/update_and_check.rs @@ -5,7 +5,7 @@ //! CTE implementation for "UPDATE with extended return status". use super::pool::DbConnection; -use async_bb8_diesel::{AsyncRunQueryDsl, ConnectionManager, PoolError}; +use async_bb8_diesel::{AsyncRunQueryDsl, PoolError}; use diesel::associations::HasTable; use diesel::pg::Pg; use diesel::prelude::*; @@ -153,16 +153,19 @@ where /// - Ok(Row exists and was updated) /// - Ok(Row exists, but was not updated) /// - Error (row doesn't exist, or other diesel error) - pub async fn execute_and_check( + pub async fn execute_and_check( self, - pool: &bb8::Pool>, + conn: &(impl async_bb8_diesel::AsyncConnection + + Sync), ) -> Result, PoolError> where // We require this bound to ensure that "Self" is runnable as query. Self: LoadQuery<'static, DbConnection, (Option, Option, Q)>, + ConnErr: From + Send + 'static, + PoolError: From, { let (id0, id1, found) = - self.get_result_async::<(Option, Option, Q)>(pool).await?; + self.get_result_async::<(Option, Option, Q)>(conn).await?; let status = if id0 == id1 { UpdateStatus::Updated } else { diff --git a/nexus/src/external_api/console_api.rs b/nexus/src/external_api/console_api.rs index e31eecb6012..ff35d570c28 100644 --- a/nexus/src/external_api/console_api.rs +++ b/nexus/src/external_api/console_api.rs @@ -35,7 +35,6 @@ use hyper::Body; use lazy_static::lazy_static; use mime_guess; use omicron_common::api::external::Error; -use omicron_common::api::external::InternalContext; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_urlencoded; @@ -601,12 +600,7 @@ pub async fn session_me( // as _somebody_. We could restrict this to session auth only, but it's // not clear what the advantage would be. let opctx = OpContext::for_external_api(&rqctx).await?; - let &actor = opctx - .authn - .actor_required() - .internal_context("loading current user")?; - let user = - nexus.silo_user_fetch_by_id(&opctx, &actor.actor_id()).await?; + let user = nexus.silo_user_fetch_self(&opctx).await?; Ok(HttpResponseOk(user.into())) }; apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 5a96000be95..55a7f340944 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -235,6 +235,9 @@ pub fn external_api() -> NexusApiDescription { api.register(saml_identity_provider_create)?; api.register(saml_identity_provider_view)?; + api.register(local_idp_user_create)?; + api.register(local_idp_user_delete)?; + api.register(system_image_list)?; api.register(system_image_create)?; api.register(system_image_view)?; @@ -242,7 +245,10 @@ pub fn external_api() -> NexusApiDescription { api.register(system_image_delete)?; api.register(updates_refresh)?; + api.register(user_list)?; + api.register(silo_users_list)?; + api.register(silo_user_view)?; api.register(group_list)?; // Console API operations @@ -624,6 +630,76 @@ async fn silo_policy_update( apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await } +// Silo-specific user endpoints + +/// List users in a specific Silo +#[endpoint { + method = GET, + path = "/system/silos/{silo_name}/users/all", + tags = ["system"], +}] +async fn silo_users_list( + rqctx: Arc>>, + path_params: Path, + query_params: Query, +) -> Result>, HttpError> { + let apictx = rqctx.context(); + let nexus = &apictx.nexus; + let silo_name = path_params.into_inner().silo_name; + let query = query_params.into_inner(); + let pagparams = data_page_params_for(&rqctx, &query)?; + let handler = async { + let opctx = OpContext::for_external_api(&rqctx).await?; + let users = nexus + .silo_list_users(&opctx, &silo_name, &pagparams) + .await? + .into_iter() + .map(|i| i.into()) + .collect(); + Ok(HttpResponseOk(ScanById::results_page( + &query, + users, + &|_, user: &User| user.id, + )?)) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + +/// Path parameters for Silo User requests +#[derive(Deserialize, JsonSchema)] +struct UserPathParam { + /// The silo's unique name. + silo_name: Name, + /// The user's internal id + user_id: Uuid, +} + +#[endpoint { + method = GET, + path = "/system/silos/{silo_name}/users/id/{user_id}", + tags = ["system"], +}] +async fn silo_user_view( + rqctx: Arc>>, + path_params: Path, +) -> Result, HttpError> { + let apictx = rqctx.context(); + let nexus = &apictx.nexus; + let path_params = path_params.into_inner(); + let handler = async { + let opctx = OpContext::for_external_api(&rqctx).await?; + let user = nexus + .silo_user_fetch( + &opctx, + &path_params.silo_name, + path_params.user_id, + ) + .await?; + Ok(HttpResponseOk(user.into())) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + // Silo identity providers /// List a silo's IDPs @@ -732,6 +808,66 @@ async fn saml_identity_provider_view( // TODO: no DELETE for identity providers? +// "Local" Identity Provider + +/// Create a user +/// +/// Users can only be created in Silos with `provision_type` == `Fixed`. +/// Otherwise, Silo users are just-in-time (JIT) provisioned when a user first +/// logs in using an external Identity Provider. +#[endpoint { + method = POST, + path = "/system/silos/{silo_name}/identity-providers/local/users", + tags = ["system"], +}] +async fn local_idp_user_create( + rqctx: Arc>>, + path_params: Path, + new_user_params: TypedBody, +) -> Result, HttpError> { + let apictx = rqctx.context(); + let nexus = &apictx.nexus; + let silo_name = path_params.into_inner().silo_name; + let handler = async { + let opctx = OpContext::for_external_api(&rqctx).await?; + let user = nexus + .local_idp_create_user( + &opctx, + &silo_name, + new_user_params.into_inner(), + ) + .await?; + Ok(HttpResponseCreated(user.into())) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + +#[endpoint { + method = DELETE, + path = "/system/silos/{silo_name}/identity-providers/local/users/{user_id}", + tags = ["system"], +}] +async fn local_idp_user_delete( + rqctx: Arc>>, + path_params: Path, +) -> Result { + let apictx = rqctx.context(); + let nexus = &apictx.nexus; + let path_params = path_params.into_inner(); + let handler = async { + let opctx = OpContext::for_external_api(&rqctx).await?; + nexus + .local_idp_delete_user( + &opctx, + &path_params.silo_name, + path_params.user_id, + ) + .await?; + Ok(HttpResponseDeleted()) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + /// List organizations #[endpoint { method = GET, @@ -4099,7 +4235,7 @@ async fn user_list( let handler = async { let opctx = OpContext::for_external_api(&rqctx).await?; let users = nexus - .silo_users_list(&opctx, &pagparams) + .silo_users_list_current(&opctx, &pagparams) .await? .into_iter() .map(|i| i.into()) @@ -4182,7 +4318,7 @@ async fn system_user_list( /// Path parameters for global (system) user requests #[derive(Deserialize, JsonSchema)] -struct UserPathParam { +struct BuiltinUserPathParam { /// The built-in user's unique name. user_name: Name, } @@ -4195,7 +4331,7 @@ struct UserPathParam { }] async fn system_user_view( rqctx: Arc>>, - path_params: Path, + path_params: Path, ) -> Result, HttpError> { let apictx = rqctx.context(); let nexus = &apictx.nexus; diff --git a/nexus/test-utils/src/http_testing.rs b/nexus/test-utils/src/http_testing.rs index cd80ce0e1df..ff1bbad3c01 100644 --- a/nexus/test-utils/src/http_testing.rs +++ b/nexus/test-utils/src/http_testing.rs @@ -448,7 +448,7 @@ impl TestResponse { /// Specifies what user (if any) the caller wants to use for authenticating to /// the server -#[derive(Clone)] +#[derive(Clone, Debug)] pub enum AuthnMode { UnprivilegedUser, PrivilegedUser, diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index 3cb7c9c7d77..e3377fa4cd4 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -27,12 +27,15 @@ use omicron_common::api::external::RouterRouteUpdateParams; use omicron_common::api::external::VpcFirewallRuleUpdateParams; use omicron_nexus::authn; use omicron_nexus::authz; +use omicron_nexus::db::fixed_data::silo::DEFAULT_SILO; +use omicron_nexus::db::identity::Resource; use omicron_nexus::external_api::params; use omicron_nexus::external_api::shared; use omicron_nexus::external_api::shared::IpRange; use omicron_nexus::external_api::shared::Ipv4Range; use std::net::IpAddr; use std::net::Ipv4Addr; +use std::str::FromStr; lazy_static! { pub static ref HARDWARE_RACK_URL: String = @@ -59,6 +62,23 @@ lazy_static! { identity_mode: shared::SiloIdentityMode::SamlJit, admin_group_name: None, }; + // Use the default Silo for testing the local IdP + pub static ref DEMO_SILO_USERS_CREATE_URL: String = format!( + "/system/silos/{}/identity-providers/local/users", + DEFAULT_SILO.identity().name, + ); + pub static ref DEMO_SILO_USERS_LIST_URL: String = format!( + "/system/silos/{}/users/all", + DEFAULT_SILO.identity().name, + ); + pub static ref DEMO_SILO_USER_ID_GET_URL: String = format!( + "/system/silos/{}/users/id/{{id}}", + DEFAULT_SILO.identity().name, + ); + pub static ref DEMO_SILO_USER_ID_DELETE_URL: String = format!( + "/system/silos/{}/identity-providers/local/users/{{id}}", + DEFAULT_SILO.identity().name, + ); // Organization used for testing pub static ref DEMO_ORG_NAME: Name = "demo-org".parse().unwrap(); @@ -262,7 +282,8 @@ lazy_static! { }; } -// Separate lazy_static! blocks to avoid hitting some recursion limit when compiling +// Separate lazy_static! blocks to avoid hitting some recursion limit when +// compiling lazy_static! { // Project Images pub static ref DEMO_IMAGE_NAME: Name = "demo-image".parse().unwrap(); @@ -386,6 +407,11 @@ lazy_static! { group_attribute_name: None, }; + + // Users + pub static ref DEMO_USER_CREATE: params::UserCreate = params::UserCreate { + external_id: params::UserId::from_str("dummy-user").unwrap(), + }; } /// Describes an API endpoint to be verified by the "unauthorized" test @@ -716,6 +742,44 @@ lazy_static! { ], }, + VerifyEndpoint { + url: &*DEMO_SILO_USERS_LIST_URL, + visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::ReadOnly, + allowed_methods: vec![ AllowedMethod::Get ], + }, + + VerifyEndpoint { + url: &*DEMO_SILO_USERS_CREATE_URL, + visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::ReadOnly, + allowed_methods: vec![ + AllowedMethod::Post( + serde_json::to_value( + &*DEMO_USER_CREATE + ).unwrap() + ), + ], + }, + + VerifyEndpoint { + url: &*DEMO_SILO_USER_ID_GET_URL, + visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::ReadOnly, + allowed_methods: vec![ + AllowedMethod::Get, + ], + }, + + VerifyEndpoint { + url: &*DEMO_SILO_USER_ID_DELETE_URL, + visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::ReadOnly, + allowed_methods: vec![ + AllowedMethod::Delete, + ], + }, + VerifyEndpoint { url: "/groups", visibility: Visibility::Public, diff --git a/nexus/tests/integration_tests/silos.rs b/nexus/tests/integration_tests/silos.rs index fa010cb4913..131fe59f025 100644 --- a/nexus/tests/integration_tests/silos.rs +++ b/nexus/tests/integration_tests/silos.rs @@ -12,7 +12,9 @@ use omicron_nexus::external_api::views::{ }; use omicron_nexus::external_api::{params, shared}; use omicron_nexus::TestInterfaces as _; -use std::collections::HashSet; +use std::collections::{BTreeMap, HashSet}; +use std::fmt::Write; +use std::str::FromStr; use http::method::Method; use http::StatusCode; @@ -28,6 +30,7 @@ use omicron_nexus::authz::{self, SiloRole}; use uuid::Uuid; use httptest::{matchers::*, responders::*, Expectation, Server}; +use omicron_common::api::external::ObjectIdentity; use omicron_nexus::authn::{USER_TEST_PRIVILEGED, USER_TEST_UNPRIVILEGED}; use omicron_nexus::db::fixed_data::silo::SILO_ID; use omicron_nexus::db::identity::Asset; @@ -206,8 +209,9 @@ async fn test_silos(cptestctx: &ControlPlaneTestContext) { .expect("failed to make request"); // Verify silo user was also deleted - nexus - .silo_user_fetch(authn_opctx, new_silo_user_id) + LookupPath::new(&authn_opctx, nexus.datastore()) + .silo_user_id(new_silo_user_id) + .fetch() .await .expect_err("unexpected success"); } @@ -1468,3 +1472,564 @@ async fn test_ensure_same_silo_group(cptestctx: &ControlPlaneTestContext) { // TODO-coverage were we intending to verify something here? } + +/// Tests the behavior of the per-Silo "list users" and "fetch user" endpoints. +/// +/// We'll run the tests separately for both kinds of Silo. The implementation +/// should be the same, but that's why we're verifying it. +#[nexus_test] +async fn test_silo_user_views(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.apictx.nexus; + + // We use fixed uuids for this test because the sort order is predictable + // and it makes it easier to debug repeated test failures. + let silo1_user1_id = + "1122f0b2-9a92-659b-da6b-93ad4955a3a3".parse().unwrap(); + let silo1_user2_id = + "120600f5-f7f4-e026-e569-ef312c16a7fc".parse().unwrap(); + let silo2_user1_id = + "214b47a9-fe53-41f4-9c08-f89cc9ac5d33".parse().unwrap(); + let silo2_user2_id = + "22d8d84d-8959-cc32-847e-de69fa8ee944".parse().unwrap(); + + // Create the two Silos. + let silo1 = + create_silo(client, "silo1", false, shared::SiloIdentityMode::SamlJit) + .await; + let silo2 = create_silo( + client, + "silo2", + false, + shared::SiloIdentityMode::LocalOnly, + ) + .await; + + // Create two users in each Silo. We need two so that we can verify that an + // ordinary user can see a user other than themselves in each Silo. + let silo1_user1: views::User = nexus + .silo_user_create(silo1.identity.id, silo1_user1_id, "user1".into()) + .await + .unwrap() + .into(); + let silo1_user2: views::User = nexus + .silo_user_create(silo1.identity.id, silo1_user2_id, "user2".into()) + .await + .unwrap() + .into(); + let silo1_expected_users = [silo1_user1.clone(), silo1_user2.clone()]; + let silo2_user1: views::User = nexus + .silo_user_create(silo2.identity.id, silo2_user1_id, "user1".into()) + .await + .unwrap() + .into(); + let silo2_user2: views::User = nexus + .silo_user_create(silo2.identity.id, silo2_user2_id, "user2".into()) + .await + .unwrap() + .into(); + let silo2_expected_users = [silo2_user1.clone(), silo2_user2.clone()]; + + let users_by_id = { + let mut users_by_id: BTreeMap = BTreeMap::new(); + assert_eq!(users_by_id.insert(silo1_user1_id, &silo1_user1), None); + assert_eq!(users_by_id.insert(silo1_user2_id, &silo1_user2), None); + assert_eq!(users_by_id.insert(silo2_user1_id, &silo2_user1), None); + assert_eq!(users_by_id.insert(silo2_user2_id, &silo2_user2), None); + users_by_id + }; + + // We'll run through a battery of tests: + // - for each of our test silos + // - for all *five* users ("test-privileged", plus the two users that we + // created in each Silo) + // - test the "list" endpoint + // - for all five user ids + // - test the "view user" endpoint for that user id + // + // This exercises a lot of different behaviors: + // - on success, the "list" and "view" endpoints always return the right + // contents + // - on failure, the "list" and "view" endpoints always return the right + // status code and message for the failure mode + // - that users can always list and fetch all users in their own Silo via + // /system/silos (/users is tested elsewhere) + // - that users without privileges cannot list or fetch users in other Silos + // - that users with privileges on another Silo can list and fetch users in + // that Silo + // - that a user with id "foo" in Silo1 cannot be accessed by that id in + // Silo 2. This case is easy to miss but would be very bad to get wrong! + let all_callers: Vec = + std::iter::once(AuthnMode::PrivilegedUser) + .chain(users_by_id.keys().map(|k| AuthnMode::SiloUser(*k))) + .collect(); + + struct TestSilo<'a> { + silo: &'a views::Silo, + expected_users: [views::User; 2], + } + + let test_silo1 = + TestSilo { silo: &silo1, expected_users: silo1_expected_users }; + let test_silo2 = + TestSilo { silo: &silo2, expected_users: silo2_expected_users }; + + let mut output = String::new(); + for test_silo in [test_silo1, test_silo2] { + let silo_name = &test_silo.silo.identity().name; + let silo_users_url = + &format!("/system/silos/{}/users", test_silo.silo.identity().name); + + write!(&mut output, "SILO: {}\n", silo_name).unwrap(); + + for calling_user in all_callers.iter() { + write!(&mut output, " test user {:?}:\n", calling_user).unwrap(); + + // Test the "list" endpoint. + write!(&mut output, " list = ").unwrap(); + let test_response = NexusRequest::new(RequestBuilder::new( + client, + Method::GET, + &format!("{}/all", silo_users_url), + )) + .authn_as(calling_user.clone()) + .execute() + .await + .unwrap(); + write!(&mut output, "{}", test_response.status.as_str()).unwrap(); + + // If this succeeded, it must have returned the expected users for + // this Silo. + if test_response.status == http::StatusCode::OK { + let found_users = test_response + .parsed_body::>() + .unwrap() + .items; + assert_eq!(found_users, test_silo.expected_users); + } else { + let error = test_response + .parsed_body::() + .unwrap(); + write!(&mut output, " (message = {:?})", error.message) + .unwrap(); + } + + write!(&mut output, "\n").unwrap(); + + // Test the "view" endpoint for each user in this Silo. + for (user_id, user) in &users_by_id { + let label = if user.silo_id == silo1.identity.id { + format!("silo 1 user {}", user.display_name) + } else { + assert_eq!(user.silo_id, silo2.identity.id); + format!("silo 2 user {}", user.display_name) + }; + write!(&mut output, " view {} ({}) = ", user_id, label,) + .unwrap(); + let test_response = NexusRequest::new(RequestBuilder::new( + client, + Method::GET, + &format!("{}/id/{}", silo_users_url, user_id), + )) + .authn_as(calling_user.clone()) + .execute() + .await + .unwrap(); + write!(&mut output, "{}", test_response.status.as_str()) + .unwrap(); + // If this succeeded, it must have returned the right user back. + if test_response.status == http::StatusCode::OK { + let found_user = + test_response.parsed_body::().unwrap(); + assert_eq!( + found_user.silo_id, + test_silo.silo.identity().id + ); + assert_eq!(found_user, **user); + } else { + let error = test_response + .parsed_body::() + .unwrap(); + write!(&mut output, " (message = {:?})", error.message) + .unwrap(); + } + + write!(&mut output, "\n").unwrap(); + } + + write!(&mut output, "\n").unwrap(); + } + } + + expectorate::assert_contents( + "tests/output/silo-user-views-output.txt", + &output, + ); +} + +/// Tests that LocalOnly-specific endpoints are not available in SamlJit Silos +#[nexus_test] +async fn test_jit_silo_constraints(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.apictx.nexus; + let silo = + create_silo(&client, "jit", true, shared::SiloIdentityMode::SamlJit) + .await; + + // We need one initial user that would in principle have privileges to + // create other users. + let new_silo_user_id = + "6922f0b2-9a92-659b-da6b-93ad4955a3a3".parse().unwrap(); + let _ = nexus + .silo_user_create( + silo.identity.id, + new_silo_user_id, + "admin-user".into(), + ) + .await + .unwrap(); + + // Grant this user "admin" privileges on that Silo. + grant_iam( + client, + "/system/silos/jit", + SiloRole::Admin, + new_silo_user_id, + AuthnMode::PrivilegedUser, + ) + .await; + + // Neither the "test-privileged" user nor this newly-created admin user + // ought to be able to create a user via the Silo's local identity provider + // (because that provider does not exist). + for caller in + [AuthnMode::PrivilegedUser, AuthnMode::SiloUser(new_silo_user_id)] + { + let error: dropshot::HttpErrorResponseBody = + NexusRequest::expect_failure_with_body( + client, + StatusCode::NOT_FOUND, + Method::POST, + "/system/silos/jit/identity-providers/local/users", + ¶ms::UserCreate { + external_id: params::UserId::from_str("dummy").unwrap(), + }, + ) + .authn_as(caller) + .execute() + .await + .unwrap() + .parsed_body() + .unwrap(); + assert_eq!( + error.message, + "not found: identity-provider with name \"local\"" + ); + } + + // Now create another user, as might happen via JIT. + let other_user_id = "57372ebb-ee76-4a2d-fa3e-e1875a8d11c0".parse().unwrap(); + let _ = nexus + .silo_user_create(silo.identity.id, other_user_id, "other-user".into()) + .await + .unwrap(); + let user_url_delete = format!( + "/system/silos/jit/identity-providers/local/users/{}", + other_user_id + ); + + // Neither the "test-privileged" user nor the Silo Admin ought to be able to + // remove this user via the local identity provider. + for caller in + [AuthnMode::PrivilegedUser, AuthnMode::SiloUser(new_silo_user_id)] + { + let error: dropshot::HttpErrorResponseBody = + NexusRequest::expect_failure( + client, + StatusCode::NOT_FOUND, + Method::DELETE, + &user_url_delete, + ) + .authn_as(caller) + .execute() + .await + .unwrap() + .parsed_body() + .unwrap(); + assert_eq!( + error.message, + "not found: identity-provider with name \"local\"" + ); + } +} + +/// Tests that SamlJit-specific endpoints are not available in LocalOnly Silos +#[nexus_test] +async fn test_local_silo_constraints(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.apictx.nexus; + + // Create a "LocalOnly" Silo with its own admin user. + let silo = create_silo( + &client, + "fixed", + true, + shared::SiloIdentityMode::LocalOnly, + ) + .await; + let new_silo_user_id = + "5b3564b6-8770-4a30-b538-8ef6ae3efa3b".parse().unwrap(); + let _ = nexus + .silo_user_create( + silo.identity.id, + new_silo_user_id, + "admin-user".into(), + ) + .await + .unwrap(); + grant_iam( + client, + "/system/silos/fixed", + SiloRole::Admin, + new_silo_user_id, + AuthnMode::PrivilegedUser, + ) + .await; + + // It's not allowed to create an identity provider in a LocalOnly Silo. + let error: dropshot::HttpErrorResponseBody = + NexusRequest::expect_failure_with_body( + client, + StatusCode::BAD_REQUEST, + Method::POST, + "/system/silos/fixed/identity-providers/saml", + ¶ms::SamlIdentityProviderCreate { + identity: IdentityMetadataCreateParams { + name: "some-totally-real-saml-provider" + .to_string() + .parse() + .unwrap(), + description: "a demo provider".to_string(), + }, + + idp_metadata_source: + params::IdpMetadataSource::Base64EncodedXml { + data: base64::encode(SAML_IDP_DESCRIPTOR.to_string()), + }, + + idp_entity_id: "entity_id".to_string(), + sp_client_id: "client_id".to_string(), + acs_url: "http://acs".to_string(), + slo_url: "http://slo".to_string(), + technical_contact_email: "technical@fake".to_string(), + + signing_keypair: None, + + group_attribute_name: None, + }, + ) + .authn_as(AuthnMode::SiloUser(new_silo_user_id)) + .execute() + .await + .unwrap() + .parsed_body() + .unwrap(); + + assert_eq!( + error.message, + "cannot create identity providers in this kind of Silo" + ); + + // The SAML login endpoints should not work, either. + NexusRequest::expect_failure( + client, + StatusCode::NOT_FOUND, + Method::GET, + "/login/fixed/saml/foo", + ) + .execute() + .await + .unwrap(); + NexusRequest::expect_failure( + client, + StatusCode::NOT_FOUND, + Method::POST, + "/login/fixed/saml/foo", + ) + .execute() + .await + .unwrap(); +} + +#[nexus_test] +async fn test_local_silo_users(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.apictx.nexus; + + // Create a "LocalOnly" Silo for testing. + let silo1 = create_silo( + &client, + "silo1", + true, + shared::SiloIdentityMode::LocalOnly, + ) + .await; + + // We'll run through a battery of tests as each of two different users: the + // usual "test-privileged" user (which should have full access because + // they're a Fleet Administrator) as well as a newly-created Silo Admin + // user. + run_user_tests(client, &silo1, &AuthnMode::PrivilegedUser, &[]).await; + + // Create a Silo Admin in our test Silo and run through the same tests. + let new_silo_user_id = + "5b3564b6-8770-4a30-b538-8ef6ae3efa3b".parse().unwrap(); + let admin_user = views::User::from( + nexus + .silo_user_create( + silo1.identity.id, + new_silo_user_id, + "admin-user".into(), + ) + .await + .unwrap(), + ); + grant_iam( + client, + "/system/silos/silo1", + SiloRole::Admin, + new_silo_user_id, + AuthnMode::PrivilegedUser, + ) + .await; + run_user_tests( + client, + &silo1, + &AuthnMode::SiloUser(new_silo_user_id), + &[admin_user.clone()], + ) + .await; +} + +/// Runs a sequence of tests for create, read, and delete of API-managed users +async fn run_user_tests( + client: &dropshot::test_util::ClientTestContext, + silo: &views::Silo, + authn_mode: &AuthnMode, + existing_users: &[views::User], +) { + let url_all_users = + format!("/system/silos/{}/users/all", silo.identity.name); + let url_local_idp_users = format!( + "/system/silos/{}/identity-providers/local/users", + silo.identity.name + ); + let url_user_create = format!("{}", url_local_idp_users); + + // Fetch users and verify it matches what the caller expects. + println!("run_user_tests: as {:?}: fetch all users", authn_mode); + let users = NexusRequest::object_get(client, &url_all_users) + .authn_as(authn_mode.clone()) + .execute() + .await + .expect("failed to list users") + .parsed_body::>() + .unwrap() + .items; + println!("users: {:?}", users); + assert_eq!(users, existing_users); + + // Create a user. + let user_created = NexusRequest::objects_post( + client, + &url_user_create, + ¶ms::UserCreate { + external_id: params::UserId::from_str("a-test-user").unwrap(), + }, + ) + .authn_as(authn_mode.clone()) + .execute() + .await + .expect("failed to create user") + .parsed_body::() + .unwrap(); + assert_eq!(user_created.display_name, "a-test-user"); + println!("created user: {:?}", user_created); + + // Fetch the user we just created. + let user_url_get = format!( + "/system/silos/{}/users/id/{}", + silo.identity.name, user_created.id + ); + let user_found = NexusRequest::object_get(client, &user_url_get) + .authn_as(authn_mode.clone()) + .execute() + .await + .expect("failed to fetch user we just created") + .parsed_body::() + .unwrap(); + assert_eq!(user_created, user_found); + + // List users. We should find whatever was there before, plus our new one. + let new_users = NexusRequest::object_get(client, &url_all_users) + .authn_as(authn_mode.clone()) + .execute() + .await + .expect("failed to list users") + .parsed_body::>() + .unwrap() + .items; + println!("new_users: {:?}", new_users); + let new_users = new_users + .iter() + .filter(|new_user| !users.iter().any(|old_user| *new_user == old_user)) + .collect::>(); + assert_eq!(new_users, &[&user_created]); + + // Delete the user that we created. + let user_url_delete = format!( + "/system/silos/{}/identity-providers/local/users/{}", + silo.identity.name, user_created.id + ); + NexusRequest::object_delete(client, &user_url_delete) + .authn_as(authn_mode.clone()) + .execute() + .await + .expect("failed to delete the user we just created"); + + // We should not be able to fetch or delete the user again. + for method in [Method::GET, Method::DELETE] { + let url = if method == Method::GET { + &user_url_get + } else { + &user_url_delete + }; + let error = NexusRequest::expect_failure( + client, + StatusCode::NOT_FOUND, + method, + url, + ) + .authn_as(authn_mode.clone()) + .execute() + .await + .expect("unexpectedly succeeded in fetching deleted user") + .parsed_body::() + .unwrap(); + let not_found_message = + format!("not found: silo-user with id \"{}\"", user_created.id); + assert_eq!(error.message, not_found_message); + } + + // List users again. We should just find whatever we started with. + let last_users = NexusRequest::object_get(client, &url_all_users) + .authn_as(authn_mode.clone()) + .execute() + .await + .expect("failed to list users") + .parsed_body::>() + .unwrap() + .items; + println!("last_users: {:?}", last_users); + assert_eq!(last_users, existing_users); +} diff --git a/nexus/tests/integration_tests/unauthorized.rs b/nexus/tests/integration_tests/unauthorized.rs index c807efad2e9..1ff3105a5c0 100644 --- a/nexus/tests/integration_tests/unauthorized.rs +++ b/nexus/tests/integration_tests/unauthorized.rs @@ -21,7 +21,6 @@ use nexus_test_utils::http_testing::TestResponse; use nexus_test_utils::resource_helpers::DiskTest; use nexus_test_utils::ControlPlaneTestContext; use nexus_test_utils_macros::nexus_test; -use omicron_common::api::external::IdentityMetadata; use omicron_nexus::authn::external::spoof; // This test hits a list Nexus API endpoints using both unauthenticated and @@ -189,6 +188,15 @@ lazy_static! { body: serde_json::to_value(&*DEMO_SILO_CREATE).unwrap(), id_routes: vec!["/system/by-id/silos/{id}"], }, + // Create a local User + SetupReq::Post { + url: &*DEMO_SILO_USERS_CREATE_URL, + body: serde_json::to_value(&*DEMO_USER_CREATE).unwrap(), + id_routes: vec![ + &*DEMO_SILO_USER_ID_GET_URL, + &*DEMO_SILO_USER_ID_DELETE_URL, + ], + }, // Create an IP pool SetupReq::Post { url: &*DEMO_IP_POOLS_URL, @@ -281,6 +289,15 @@ lazy_static! { ]; } +/// Contents returned from an endpoint that creates a resource that has an id +/// +/// This is a subset of `IdentityMetadata`. `IdentityMetadata` includes other +/// fields (like "name") that are not present on all objects. +#[derive(serde::Deserialize)] +struct IdMetadata { + id: String, +} + /// Verifies a single API endpoint, described with `endpoint` /// /// (Technically, a single `VerifyEndpoint` struct describes an HTTP resource, @@ -358,7 +375,7 @@ async fn verify_endpoint( Some(response) => endpoint.url.replace( "{id}", response - .parsed_body::() + .parsed_body::() .unwrap() .id .to_string() diff --git a/nexus/tests/output/authz-roles.out b/nexus/tests/output/authz-roles.out index 37f4d4ec262..644271cd0e5 100644 --- a/nexus/tests/output/authz-roles.out +++ b/nexus/tests/output/authz-roles.out @@ -134,6 +134,23 @@ resource: Silo "silo1": identity provider list silo1-org1-proj1-viewer ✘ ✘ ✔ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! +resource: Silo "silo1": user list + + USER Q R LC RP M MP CC D + fleet-admin ✘ ✘ ✔ ✘ ✘ ✘ ✔ ✘ + fleet-collaborator ✘ ✘ ✔ ✘ ✘ ✘ ✘ ✘ + fleet-viewer ✘ ✘ ✔ ✘ ✘ ✘ ✘ ✘ + silo1-admin ✘ ✘ ✔ ✘ ✘ ✘ ✔ ✘ + silo1-collaborator ✘ ✘ ✔ ✘ ✘ ✘ ✘ ✘ + silo1-viewer ✘ ✘ ✔ ✘ ✘ ✘ ✘ ✘ + silo1-org1-admin ✘ ✘ ✔ ✘ ✘ ✘ ✘ ✘ + silo1-org1-collaborator ✘ ✘ ✔ ✘ ✘ ✘ ✘ ✘ + silo1-org1-viewer ✘ ✘ ✔ ✘ ✘ ✘ ✘ ✘ + silo1-org1-proj1-admin ✘ ✘ ✔ ✘ ✘ ✘ ✘ ✘ + silo1-org1-proj1-collaborator ✘ ✘ ✔ ✘ ✘ ✘ ✘ ✘ + silo1-org1-proj1-viewer ✘ ✘ ✔ ✘ ✘ ✘ ✘ ✘ + unauthenticated ! ! ! ! ! ! ! ! + resource: Organization "silo1-org1" USER Q R LC RP M MP CC D @@ -559,6 +576,23 @@ resource: Silo "silo2": identity provider list silo1-org1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! +resource: Silo "silo2": user list + + USER Q R LC RP M MP CC D + fleet-admin ✘ ✘ ✔ ✘ ✘ ✘ ✔ ✘ + fleet-collaborator ✘ ✘ ✔ ✘ ✘ ✘ ✘ ✘ + fleet-viewer ✘ ✘ ✔ ✘ ✘ ✘ ✘ ✘ + silo1-admin ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-org1-admin ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-org1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-org1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-org1-proj1-admin ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-org1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-org1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + unauthenticated ! ! ! ! ! ! ! ! + resource: Organization "silo2-org1" USER Q R LC RP M MP CC D diff --git a/nexus/tests/output/nexus_tags.txt b/nexus/tests/output/nexus_tags.txt index 28885b43914..b3ebd936b0a 100644 --- a/nexus/tests/output/nexus_tags.txt +++ b/nexus/tests/output/nexus_tags.txt @@ -125,6 +125,8 @@ ip_pool_service_view /system/ip-pools-service/{rack_id} ip_pool_update /system/ip-pools/{pool_name} ip_pool_view /system/ip-pools/{pool_name} ip_pool_view_by_id /system/by-id/ip-pools/{id} +local_idp_user_create /system/silos/{silo_name}/identity-providers/local/users +local_idp_user_delete /system/silos/{silo_name}/identity-providers/local/users/{user_id} rack_list /system/hardware/racks rack_view /system/hardware/racks/{rack_id} saga_list /system/sagas @@ -137,6 +139,8 @@ silo_identity_provider_list /system/silos/{silo_name}/identity-prov silo_list /system/silos silo_policy_update /system/silos/{silo_name}/policy silo_policy_view /system/silos/{silo_name}/policy +silo_user_view /system/silos/{silo_name}/users/id/{user_id} +silo_users_list /system/silos/{silo_name}/users/all silo_view /system/silos/{silo_name} silo_view_by_id /system/by-id/silos/{id} sled_list /system/hardware/sleds diff --git a/nexus/tests/output/silo-user-views-output.txt b/nexus/tests/output/silo-user-views-output.txt new file mode 100644 index 00000000000..d484fa85a87 --- /dev/null +++ b/nexus/tests/output/silo-user-views-output.txt @@ -0,0 +1,72 @@ +SILO: silo1 + test user PrivilegedUser: + list = 200 + view 1122f0b2-9a92-659b-da6b-93ad4955a3a3 (silo 1 user user1) = 200 + view 120600f5-f7f4-e026-e569-ef312c16a7fc (silo 1 user user2) = 200 + view 214b47a9-fe53-41f4-9c08-f89cc9ac5d33 (silo 2 user user1) = 404 (message = "not found: silo-user with id \"214b47a9-fe53-41f4-9c08-f89cc9ac5d33\"") + view 22d8d84d-8959-cc32-847e-de69fa8ee944 (silo 2 user user2) = 404 (message = "not found: silo-user with id \"22d8d84d-8959-cc32-847e-de69fa8ee944\"") + + test user SiloUser(1122f0b2-9a92-659b-da6b-93ad4955a3a3): + list = 200 + view 1122f0b2-9a92-659b-da6b-93ad4955a3a3 (silo 1 user user1) = 200 + view 120600f5-f7f4-e026-e569-ef312c16a7fc (silo 1 user user2) = 200 + view 214b47a9-fe53-41f4-9c08-f89cc9ac5d33 (silo 2 user user1) = 404 (message = "not found: silo-user with id \"214b47a9-fe53-41f4-9c08-f89cc9ac5d33\"") + view 22d8d84d-8959-cc32-847e-de69fa8ee944 (silo 2 user user2) = 404 (message = "not found: silo-user with id \"22d8d84d-8959-cc32-847e-de69fa8ee944\"") + + test user SiloUser(120600f5-f7f4-e026-e569-ef312c16a7fc): + list = 200 + view 1122f0b2-9a92-659b-da6b-93ad4955a3a3 (silo 1 user user1) = 200 + view 120600f5-f7f4-e026-e569-ef312c16a7fc (silo 1 user user2) = 200 + view 214b47a9-fe53-41f4-9c08-f89cc9ac5d33 (silo 2 user user1) = 404 (message = "not found: silo-user with id \"214b47a9-fe53-41f4-9c08-f89cc9ac5d33\"") + view 22d8d84d-8959-cc32-847e-de69fa8ee944 (silo 2 user user2) = 404 (message = "not found: silo-user with id \"22d8d84d-8959-cc32-847e-de69fa8ee944\"") + + test user SiloUser(214b47a9-fe53-41f4-9c08-f89cc9ac5d33): + list = 404 (message = "not found: silo with name \"silo1\"") + view 1122f0b2-9a92-659b-da6b-93ad4955a3a3 (silo 1 user user1) = 404 (message = "not found: silo with name \"silo1\"") + view 120600f5-f7f4-e026-e569-ef312c16a7fc (silo 1 user user2) = 404 (message = "not found: silo with name \"silo1\"") + view 214b47a9-fe53-41f4-9c08-f89cc9ac5d33 (silo 2 user user1) = 404 (message = "not found: silo with name \"silo1\"") + view 22d8d84d-8959-cc32-847e-de69fa8ee944 (silo 2 user user2) = 404 (message = "not found: silo with name \"silo1\"") + + test user SiloUser(22d8d84d-8959-cc32-847e-de69fa8ee944): + list = 404 (message = "not found: silo with name \"silo1\"") + view 1122f0b2-9a92-659b-da6b-93ad4955a3a3 (silo 1 user user1) = 404 (message = "not found: silo with name \"silo1\"") + view 120600f5-f7f4-e026-e569-ef312c16a7fc (silo 1 user user2) = 404 (message = "not found: silo with name \"silo1\"") + view 214b47a9-fe53-41f4-9c08-f89cc9ac5d33 (silo 2 user user1) = 404 (message = "not found: silo with name \"silo1\"") + view 22d8d84d-8959-cc32-847e-de69fa8ee944 (silo 2 user user2) = 404 (message = "not found: silo with name \"silo1\"") + +SILO: silo2 + test user PrivilegedUser: + list = 200 + view 1122f0b2-9a92-659b-da6b-93ad4955a3a3 (silo 1 user user1) = 404 (message = "not found: silo-user with id \"1122f0b2-9a92-659b-da6b-93ad4955a3a3\"") + view 120600f5-f7f4-e026-e569-ef312c16a7fc (silo 1 user user2) = 404 (message = "not found: silo-user with id \"120600f5-f7f4-e026-e569-ef312c16a7fc\"") + view 214b47a9-fe53-41f4-9c08-f89cc9ac5d33 (silo 2 user user1) = 200 + view 22d8d84d-8959-cc32-847e-de69fa8ee944 (silo 2 user user2) = 200 + + test user SiloUser(1122f0b2-9a92-659b-da6b-93ad4955a3a3): + list = 404 (message = "not found: silo with name \"silo2\"") + view 1122f0b2-9a92-659b-da6b-93ad4955a3a3 (silo 1 user user1) = 404 (message = "not found: silo with name \"silo2\"") + view 120600f5-f7f4-e026-e569-ef312c16a7fc (silo 1 user user2) = 404 (message = "not found: silo with name \"silo2\"") + view 214b47a9-fe53-41f4-9c08-f89cc9ac5d33 (silo 2 user user1) = 404 (message = "not found: silo with name \"silo2\"") + view 22d8d84d-8959-cc32-847e-de69fa8ee944 (silo 2 user user2) = 404 (message = "not found: silo with name \"silo2\"") + + test user SiloUser(120600f5-f7f4-e026-e569-ef312c16a7fc): + list = 404 (message = "not found: silo with name \"silo2\"") + view 1122f0b2-9a92-659b-da6b-93ad4955a3a3 (silo 1 user user1) = 404 (message = "not found: silo with name \"silo2\"") + view 120600f5-f7f4-e026-e569-ef312c16a7fc (silo 1 user user2) = 404 (message = "not found: silo with name \"silo2\"") + view 214b47a9-fe53-41f4-9c08-f89cc9ac5d33 (silo 2 user user1) = 404 (message = "not found: silo with name \"silo2\"") + view 22d8d84d-8959-cc32-847e-de69fa8ee944 (silo 2 user user2) = 404 (message = "not found: silo with name \"silo2\"") + + test user SiloUser(214b47a9-fe53-41f4-9c08-f89cc9ac5d33): + list = 200 + view 1122f0b2-9a92-659b-da6b-93ad4955a3a3 (silo 1 user user1) = 404 (message = "not found: silo-user with id \"1122f0b2-9a92-659b-da6b-93ad4955a3a3\"") + view 120600f5-f7f4-e026-e569-ef312c16a7fc (silo 1 user user2) = 404 (message = "not found: silo-user with id \"120600f5-f7f4-e026-e569-ef312c16a7fc\"") + view 214b47a9-fe53-41f4-9c08-f89cc9ac5d33 (silo 2 user user1) = 200 + view 22d8d84d-8959-cc32-847e-de69fa8ee944 (silo 2 user user2) = 200 + + test user SiloUser(22d8d84d-8959-cc32-847e-de69fa8ee944): + list = 200 + view 1122f0b2-9a92-659b-da6b-93ad4955a3a3 (silo 1 user user1) = 404 (message = "not found: silo-user with id \"1122f0b2-9a92-659b-da6b-93ad4955a3a3\"") + view 120600f5-f7f4-e026-e569-ef312c16a7fc (silo 1 user user2) = 404 (message = "not found: silo-user with id \"120600f5-f7f4-e026-e569-ef312c16a7fc\"") + view 214b47a9-fe53-41f4-9c08-f89cc9ac5d33 (silo 2 user user1) = 200 + view 22d8d84d-8959-cc32-847e-de69fa8ee944 (silo 2 user user2) = 200 + diff --git a/nexus/types/src/external_api/params.rs b/nexus/types/src/external_api/params.rs index a1bca2666d9..9b87afab0cc 100644 --- a/nexus/types/src/external_api/params.rs +++ b/nexus/types/src/external_api/params.rs @@ -15,7 +15,7 @@ use serde::{ de::{self, Visitor}, Deserialize, Deserializer, Serialize, Serializer, }; -use std::net::IpAddr; +use std::{net::IpAddr, str::FromStr}; use uuid::Uuid; // Silos @@ -40,6 +40,55 @@ pub struct SiloCreate { pub admin_group_name: Option, } +/// Create-time parameters for a [`User`](crate::external_api::views::User) +#[derive(Clone, Deserialize, Serialize, JsonSchema)] +pub struct UserCreate { + /// username used to log in + pub external_id: UserId, +} + +/// A username for a local-only user +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(try_from = "String")] +pub struct UserId(String); + +impl AsRef for UserId { + fn as_ref(&self) -> &str { + self.0.as_ref() + } +} + +impl FromStr for UserId { + type Err = String; + fn from_str(value: &str) -> Result { + UserId::try_from(String::from(value)) + } +} + +/// Used to impl `Deserialize` +impl TryFrom for UserId { + type Error = String; + fn try_from(value: String) -> Result { + // Mostly, this validation exists to cap the input size. The specific + // length is not critical here. For convenience and consistency, we use + // the same rules as `Name`. + let _ = Name::try_from(value.clone())?; + Ok(UserId(value)) + } +} + +impl JsonSchema for UserId { + fn schema_name() -> String { + "UserId".to_string() + } + + fn json_schema( + gen: &mut schemars::gen::SchemaGenerator, + ) -> schemars::schema::Schema { + Name::json_schema(gen) + } +} + // Silo identity providers #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] diff --git a/openapi/nexus.json b/openapi/nexus.json index 54dba50ab7a..da446dbe96a 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -6815,6 +6815,98 @@ "x-dropshot-pagination": true } }, + "/system/silos/{silo_name}/identity-providers/local/users": { + "post": { + "tags": [ + "system" + ], + "summary": "Create a user", + "description": "Users can only be created in Silos with `provision_type` == `Fixed`. Otherwise, Silo users are just-in-time (JIT) provisioned when a user first logs in using an external Identity Provider.", + "operationId": "local_idp_user_create", + "parameters": [ + { + "in": "path", + "name": "silo_name", + "description": "The silo's unique name.", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "successful creation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/system/silos/{silo_name}/identity-providers/local/users/{user_id}": { + "delete": { + "tags": [ + "system" + ], + "operationId": "local_idp_user_delete", + "parameters": [ + { + "in": "path", + "name": "silo_name", + "description": "The silo's unique name.", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + }, + { + "in": "path", + "name": "user_id", + "description": "The user's internal id", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "simple" + } + ], + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/system/silos/{silo_name}/identity-providers/saml": { "post": { "tags": [ @@ -6999,6 +7091,125 @@ } } }, + "/system/silos/{silo_name}/users/all": { + "get": { + "tags": [ + "system" + ], + "summary": "List users in a specific Silo", + "operationId": "silo_users_list", + "parameters": [ + { + "in": "path", + "name": "silo_name", + "description": "The silo's unique name.", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + }, + { + "in": "query", + "name": "limit", + "description": "Maximum number of items returned by a single call", + "schema": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 1 + }, + "style": "form" + }, + { + "in": "query", + "name": "page_token", + "description": "Token returned by previous call to retrieve the subsequent page", + "schema": { + "nullable": true, + "type": "string" + }, + "style": "form" + }, + { + "in": "query", + "name": "sort_by", + "schema": { + "$ref": "#/components/schemas/IdSortMode" + }, + "style": "form" + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserResultsPage" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + }, + "x-dropshot-pagination": true + } + }, + "/system/silos/{silo_name}/users/id/{user_id}": { + "get": { + "tags": [ + "system" + ], + "operationId": "silo_user_view", + "parameters": [ + { + "in": "path", + "name": "silo_name", + "description": "The silo's unique name.", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + }, + { + "in": "path", + "name": "user_id", + "description": "The user's internal id", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/system/updates/refresh": { "post": { "tags": [ @@ -11367,6 +11578,30 @@ "items" ] }, + "UserCreate": { + "description": "Create-time parameters for a [`User`](crate::external_api::views::User)", + "type": "object", + "properties": { + "external_id": { + "description": "username used to log in", + "allOf": [ + { + "$ref": "#/components/schemas/UserId" + } + ] + } + }, + "required": [ + "external_id" + ] + }, + "UserId": { + "title": "A name unique within the parent collection", + "description": "Names must begin with a lower case ASCII letter, be composed exclusively of lowercase ASCII, uppercase ASCII, numbers, and '-', and may not end with a '-'. Names cannot be a UUID though they may contain a UUID.", + "type": "string", + "pattern": "^(?![0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$)^[a-z][a-z0-9-]*[a-zA-Z0-9]$", + "maxLength": 63 + }, "UserResultsPage": { "description": "A single page of results", "type": "object",