diff --git a/Cargo.lock b/Cargo.lock index 9cbb3b518e1..06341c09396 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6566,6 +6566,7 @@ dependencies = [ "futures", "gateway-client", "gateway-types", + "hex", "hyper-rustls", "id-map", "iddqd", @@ -6612,6 +6613,7 @@ dependencies = [ "regex", "rustls 0.22.4", "schemars", + "scim2-rs", "semver 1.0.27", "serde", "serde_json", diff --git a/nexus/auth/src/authz/api_resources.rs b/nexus/auth/src/authz/api_resources.rs index 94d0ee32231..817212ee762 100644 --- a/nexus/auth/src/authz/api_resources.rs +++ b/nexus/auth/src/authz/api_resources.rs @@ -1429,3 +1429,11 @@ authz_resource! { roles_allowed = false, polar_snippet = Custom, } + +authz_resource! { + name = "ScimClientBearerToken", + parent = "Silo", + primary_key = Uuid, + roles_allowed = false, + polar_snippet = Custom, +} diff --git a/nexus/auth/src/authz/omicron.polar b/nexus/auth/src/authz/omicron.polar index 2aa0284c1be..a7a29bfbf8e 100644 --- a/nexus/auth/src/authz/omicron.polar +++ b/nexus/auth/src/authz/omicron.polar @@ -97,7 +97,8 @@ resource Fleet { "viewer", # Internal-only roles - "external-authenticator" + "external-authenticator", + "external-scim" ]; # Roles implied by other roles on this resource @@ -149,6 +150,9 @@ resource Silo { # external authenticator has to create silo users "list_children" if "external-authenticator" on "parent_fleet"; "create_child" if "external-authenticator" on "parent_fleet"; + + # external scim has to be able to read SCIM tokens + "list_children" if "external-scim" on "parent_fleet"; } has_relation(fleet: Fleet, "parent_fleet", silo: Silo) @@ -703,3 +707,35 @@ resource AlertClassList { has_relation(fleet: Fleet, "parent_fleet", collection: AlertClassList) if collection.fleet = fleet; + +# These rules grant the external scim authenticator role the permission +# required to create the SCIM provider implementation for a Silo + +has_permission(actor: AuthenticatedActor, "read", silo: Silo) + if has_role(actor, "external-scim", silo.fleet); + +resource ScimClientBearerToken { + permissions = [ + "read", + "modify", + "create_child", + "list_children", + ]; + relations = { parent_silo: Silo, parent_fleet: Fleet }; + + # Silo-level roles grant privileges for SCIM client tokens. + "read" if "admin" on "parent_silo"; + "list_children" if "admin" on "parent_silo"; + "modify" if "admin" on "parent_silo"; + "create_child" if "admin" on "parent_silo"; + + # Fleet-level roles also grant privileges for SCIM client tokens. + "read" if "admin" on "parent_fleet"; + "list_children" if "admin" on "parent_fleet"; + "modify" if "admin" on "parent_fleet"; + "create_child" if "admin" on "parent_fleet"; +} +has_relation(silo: Silo, "parent_silo", scim_client_bearer_token: ScimClientBearerToken) + if scim_client_bearer_token.silo = silo; +has_relation(fleet: Fleet, "parent_fleet", collection: ScimClientBearerToken) + if collection.silo.fleet = fleet; diff --git a/nexus/auth/src/authz/oso_generic.rs b/nexus/auth/src/authz/oso_generic.rs index 1278b24382c..6d80a7eff23 100644 --- a/nexus/auth/src/authz/oso_generic.rs +++ b/nexus/auth/src/authz/oso_generic.rs @@ -175,6 +175,7 @@ pub fn make_omicron_oso(log: &slog::Logger) -> Result { Zpool::init(), Service::init(), UserBuiltin::init(), + ScimClientBearerToken::init(), ]; for init in generated_inits { diff --git a/nexus/db-lookup/src/lookup.rs b/nexus/db-lookup/src/lookup.rs index 4a949503cbd..a8a5d79029b 100644 --- a/nexus/db-lookup/src/lookup.rs +++ b/nexus/db-lookup/src/lookup.rs @@ -511,6 +511,18 @@ impl<'a> LookupPath<'a> { { Alert::PrimaryKey(Root { lookup_root: self }, id) } + + /// Select a resource of type [`ScimClientBearerToken`], identified by its + /// UUID. + pub fn scim_client_bearer_token_id<'b>( + self, + id: Uuid, + ) -> ScimClientBearerToken<'b> + where + 'a: 'b, + { + ScimClientBearerToken::PrimaryKey(Root { lookup_root: self }, id) + } } /// Represents the head of the selection path for a resource @@ -909,6 +921,15 @@ lookup_resource! { ] } +lookup_resource! { + name = "ScimClientBearerToken", + ancestors = ["Silo"], + lookup_by_name = false, + soft_deletes = true, + primary_key_columns = [ { column_name = "id", rust_type = Uuid } ], + visible_outside_silo = true +} + // Helpers for unifying the interfaces around images pub enum ImageLookup<'a> { diff --git a/nexus/db-model/src/lib.rs b/nexus/db-model/src/lib.rs index baa1a408407..30143cdda07 100644 --- a/nexus/db-model/src/lib.rs +++ b/nexus/db-model/src/lib.rs @@ -71,6 +71,7 @@ mod producer_endpoint; mod project; mod reconfigurator_config; mod rendezvous_debug_dataset; +mod scim_client_bearer_token; mod semver_version; mod serde_time_delta; mod silo_auth_settings; @@ -223,6 +224,7 @@ pub use rendezvous_debug_dataset::*; pub use role_assignment::*; pub use saga_types::*; pub use schema_versions::*; +pub use scim_client_bearer_token::*; pub use semver_version::*; pub use service_kind::*; pub use silo::*; diff --git a/nexus/db-model/src/schema_versions.rs b/nexus/db-model/src/schema_versions.rs index d4a37d0b6c8..066dac414d7 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(197, 0, 0); +pub const SCHEMA_VERSION: Version = Version::new(198, 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(198, "scim-client-bearer-token"), KnownVersion::new(197, "scim-users-and-groups"), KnownVersion::new(196, "user-provision-type-for-silo-user-and-group"), KnownVersion::new(195, "tuf-pruned-index"), diff --git a/nexus/db-model/src/scim_client_bearer_token.rs b/nexus/db-model/src/scim_client_bearer_token.rs new file mode 100644 index 00000000000..3239bc85299 --- /dev/null +++ b/nexus/db-model/src/scim_client_bearer_token.rs @@ -0,0 +1,53 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use chrono::DateTime; +use chrono::Utc; +use nexus_db_schema::schema::scim_client_bearer_token; +use nexus_types::external_api::views; +use uuid::Uuid; + +/// A SCIM client sends requests to a SCIM provider (in this case, Nexus) using +/// some sort of authentication. Nexus currently only supports Bearer token auth +/// from SCIM clients, and these tokens are stored here. +#[derive(Queryable, Insertable, Clone, Debug, Selectable)] +#[diesel(table_name = scim_client_bearer_token)] +pub struct ScimClientBearerToken { + pub id: Uuid, + + pub time_created: DateTime, + pub time_deleted: Option>, + pub time_expires: Option>, + + pub silo_id: Uuid, + + pub bearer_token: String, +} + +impl ScimClientBearerToken { + pub fn id(&self) -> Uuid { + self.id + } +} + +impl From for views::ScimClientBearerToken { + fn from(t: ScimClientBearerToken) -> views::ScimClientBearerToken { + views::ScimClientBearerToken { + id: t.id, + time_created: t.time_created, + time_expires: t.time_expires, + } + } +} + +impl From for views::ScimClientBearerTokenValue { + fn from(t: ScimClientBearerToken) -> views::ScimClientBearerTokenValue { + views::ScimClientBearerTokenValue { + id: t.id, + time_created: t.time_created, + time_expires: t.time_expires, + bearer_token: t.bearer_token, + } + } +} diff --git a/nexus/db-queries/Cargo.toml b/nexus/db-queries/Cargo.toml index 268157cc724..93b12059e12 100644 --- a/nexus/db-queries/Cargo.toml +++ b/nexus/db-queries/Cargo.toml @@ -23,6 +23,7 @@ diesel.workspace = true diesel-dtrace.workspace = true dropshot.workspace = true futures.workspace = true +hex.workspace = true id-map.workspace = true iddqd.workspace = true internal-dns-resolver.workspace = true @@ -39,6 +40,7 @@ rand.workspace = true ref-cast.workspace = true regex.workspace = true schemars.workspace = true +scim2-rs.workspace = true semver.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/nexus/db-queries/src/db/datastore/mod.rs b/nexus/db-queries/src/db/datastore/mod.rs index 0bf533a5e72..4650956aeec 100644 --- a/nexus/db-queries/src/db/datastore/mod.rs +++ b/nexus/db-queries/src/db/datastore/mod.rs @@ -96,6 +96,8 @@ pub mod region_snapshot_replacement; mod rendezvous_debug_dataset; mod role; mod saga; +mod scim; +mod scim_provider_store; mod silo; mod silo_auth_settings; mod silo_group; @@ -143,6 +145,7 @@ pub use region::RegionAllocationFor; pub use region::RegionAllocationParameters; pub use region_snapshot_replacement::NewRegionVolumeId; pub use region_snapshot_replacement::OldSnapshotVolumeId; +pub use scim_provider_store::CrdbScimProviderStore; pub use silo::Discoverability; pub use silo_group::SiloGroup; pub use silo_group::SiloGroupApiOnly; diff --git a/nexus/db-queries/src/db/datastore/scim.rs b/nexus/db-queries/src/db/datastore/scim.rs new file mode 100644 index 00000000000..7dbfcbd8d22 --- /dev/null +++ b/nexus/db-queries/src/db/datastore/scim.rs @@ -0,0 +1,157 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! [`DataStore`] methods related to SCIM + +use super::DataStore; +use crate::authz; +use crate::context::OpContext; +use crate::db::model::ScimClientBearerToken; +use async_bb8_diesel::AsyncRunQueryDsl; +use chrono::Utc; +use diesel::prelude::*; +use nexus_db_errors::ErrorHandler; +use nexus_db_errors::public_error_from_diesel; +use nexus_db_lookup::LookupPath; +use omicron_common::api::external::CreateResult; +use omicron_common::api::external::DeleteResult; +use omicron_common::api::external::ListResultVec; +use omicron_common::api::external::LookupResult; +use rand::{RngCore, SeedableRng, rngs::StdRng}; +use uuid::Uuid; + +// XXX this is the same as generate_session_token! +fn generate_scim_client_bearer_token() -> String { + let mut rng = StdRng::from_os_rng(); + let mut random_bytes: [u8; 20] = [0; 20]; + rng.fill_bytes(&mut random_bytes); + hex::encode(random_bytes) +} + +impl DataStore { + // SCIM tokens + + pub async fn scim_idp_get_tokens( + &self, + opctx: &OpContext, + authz_silo: &authz::Silo, + ) -> ListResultVec { + opctx.authorize(authz::Action::ListChildren, authz_silo).await?; + + let conn = self.pool_connection_authorized(opctx).await?; + + use nexus_db_schema::schema::scim_client_bearer_token::dsl; + let tokens = dsl::scim_client_bearer_token + .filter(dsl::silo_id.eq(authz_silo.id())) + .filter(dsl::time_deleted.is_null()) + .select(ScimClientBearerToken::as_select()) + .load_async::(&*conn) + .await + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))?; + + Ok(tokens) + } + + pub async fn scim_idp_create_token( + &self, + opctx: &OpContext, + authz_silo: &authz::Silo, + ) -> CreateResult { + opctx.authorize(authz::Action::CreateChild, authz_silo).await?; + + let conn = self.pool_connection_authorized(opctx).await?; + + let new_token = ScimClientBearerToken { + id: Uuid::new_v4(), + time_created: Utc::now(), + time_deleted: None, + // TODO: allow setting an expiry? have a silo default? + time_expires: None, + silo_id: authz_silo.id(), + bearer_token: generate_scim_client_bearer_token(), + }; + + use nexus_db_schema::schema::scim_client_bearer_token::dsl; + diesel::insert_into(dsl::scim_client_bearer_token) + .values(new_token.clone()) + .execute_async(&*conn) + .await + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))?; + + Ok(new_token) + } + + pub async fn scim_idp_get_token_by_id( + &self, + opctx: &OpContext, + authz_silo: &authz::Silo, + token_id: Uuid, + ) -> LookupResult { + let (_, authz_token) = LookupPath::new(opctx, self) + .scim_client_bearer_token_id(token_id) + .lookup_for(authz::Action::Read) + .await?; + + let conn = self.pool_connection_authorized(opctx).await?; + + use nexus_db_schema::schema::scim_client_bearer_token::dsl; + let token = dsl::scim_client_bearer_token + .filter(dsl::silo_id.eq(authz_silo.id())) + .filter(dsl::id.eq(authz_token.id())) + .filter(dsl::time_deleted.is_null()) + .select(ScimClientBearerToken::as_select()) + .first_async::(&*conn) + .await + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))?; + + Ok(token) + } + + pub async fn scim_idp_delete_token_by_id( + &self, + opctx: &OpContext, + authz_silo: &authz::Silo, + token_id: Uuid, + ) -> DeleteResult { + let (_, authz_token) = LookupPath::new(opctx, self) + .scim_client_bearer_token_id(token_id) + .lookup_for(authz::Action::Delete) + .await?; + + let conn = self.pool_connection_authorized(opctx).await?; + + use nexus_db_schema::schema::scim_client_bearer_token::dsl; + diesel::update(dsl::scim_client_bearer_token) + .filter(dsl::silo_id.eq(authz_silo.id())) + .filter(dsl::id.eq(authz_token.id())) + .filter(dsl::time_deleted.is_null()) + .set(dsl::time_deleted.eq(Utc::now())) + .execute_async(&*conn) + .await + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))?; + + Ok(()) + } + + /// SCIM clients should _not_ authenticate to an Actor in the traditional + /// sense: they shouldn't have permission on any resources under a Silo, + /// only enough to CRUD Silo users and groups. + pub async fn scim_idp_lookup_token_by_bearer( + &self, + bearer_token: String, + ) -> LookupResult> { + let conn = self.pool_connection_unauthorized().await?; + + use nexus_db_schema::schema::scim_client_bearer_token::dsl; + let maybe_token = dsl::scim_client_bearer_token + .filter(dsl::bearer_token.eq(bearer_token)) + .filter(dsl::time_deleted.is_null()) + .first_async(&*conn) + .await + .optional() + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))?; + + Ok(maybe_token) + } +} diff --git a/nexus/db-queries/src/db/datastore/scim_provider_store.rs b/nexus/db-queries/src/db/datastore/scim_provider_store.rs new file mode 100644 index 00000000000..e07b4c4b67d --- /dev/null +++ b/nexus/db-queries/src/db/datastore/scim_provider_store.rs @@ -0,0 +1,130 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! scim2-rs uses the pattern of implementing a SCIM "provider" over something +//! that implements a "provider store" trait that durably stores the SCIM +//! related information. Nexus uses cockroachdb as the provider store. + +use super::DataStore; +use anyhow::anyhow; +use std::sync::Arc; +use uuid::Uuid; + +use scim2_rs::CreateGroupRequest; +use scim2_rs::CreateUserRequest; +use scim2_rs::FilterOp; +use scim2_rs::Group; +use scim2_rs::ProviderStore; +use scim2_rs::ProviderStoreDeleteResult; +use scim2_rs::ProviderStoreError; +use scim2_rs::StoredParts; +use scim2_rs::User; + +// XXX temporary until SCIM impl PR +#[allow(dead_code)] +pub struct CrdbScimProviderStore { + silo_id: Uuid, + datastore: Arc, +} + +impl CrdbScimProviderStore { + pub fn new(silo_id: Uuid, datastore: Arc) -> Self { + CrdbScimProviderStore { silo_id, datastore } + } +} + +#[async_trait::async_trait] +impl ProviderStore for CrdbScimProviderStore { + async fn get_user_by_id( + &self, + _user_id: &str, + ) -> Result>, ProviderStoreError> { + return Err(ProviderStoreError::StoreError(anyhow!( + "not implemented!" + ))); + } + + async fn create_user( + &self, + _user_request: CreateUserRequest, + ) -> Result, ProviderStoreError> { + return Err(ProviderStoreError::StoreError(anyhow!( + "not implemented!" + ))); + } + + async fn list_users( + &self, + _filter: Option, + ) -> Result>, ProviderStoreError> { + return Err(ProviderStoreError::StoreError(anyhow!( + "not implemented!" + ))); + } + + async fn replace_user( + &self, + _user_id: &str, + _user_request: CreateUserRequest, + ) -> Result, ProviderStoreError> { + return Err(ProviderStoreError::StoreError(anyhow!( + "not implemented!" + ))); + } + + async fn delete_user_by_id( + &self, + _user_id: &str, + ) -> Result { + return Err(ProviderStoreError::StoreError(anyhow!( + "not implemented!" + ))); + } + + async fn get_group_by_id( + &self, + _group_id: &str, + ) -> Result>, ProviderStoreError> { + return Err(ProviderStoreError::StoreError(anyhow!( + "not implemented!" + ))); + } + + async fn create_group( + &self, + _group_request: CreateGroupRequest, + ) -> Result, ProviderStoreError> { + return Err(ProviderStoreError::StoreError(anyhow!( + "not implemented!" + ))); + } + + async fn list_groups( + &self, + _filter: Option, + ) -> Result>, ProviderStoreError> { + return Err(ProviderStoreError::StoreError(anyhow!( + "not implemented!" + ))); + } + + async fn replace_group( + &self, + _group_id: &str, + _group_request: CreateGroupRequest, + ) -> Result, ProviderStoreError> { + return Err(ProviderStoreError::StoreError(anyhow!( + "not implemented!" + ))); + } + + async fn delete_group_by_id( + &self, + _group_id: &str, + ) -> Result { + return Err(ProviderStoreError::StoreError(anyhow!( + "not implemented!" + ))); + } +} diff --git a/nexus/db-queries/src/policy_test/mod.rs b/nexus/db-queries/src/policy_test/mod.rs index 67879f61877..15f67857aec 100644 --- a/nexus/db-queries/src/policy_test/mod.rs +++ b/nexus/db-queries/src/policy_test/mod.rs @@ -145,7 +145,7 @@ async fn test_iam_prep( /// users and role assignments. #[tokio::test(flavor = "multi_thread")] async fn test_iam_roles_behavior() { - let logctx = dev::test_setup_log("test_iam_roles"); + let logctx = dev::test_setup_log("test_iam_roles_behavior"); let db = TestDatabase::new_with_datastore(&logctx.log).await; let (opctx, datastore) = (db.opctx(), db.datastore()); diff --git a/nexus/db-queries/src/policy_test/resource_builder.rs b/nexus/db-queries/src/policy_test/resource_builder.rs index e7c7da87b3d..ee1bcf6fc69 100644 --- a/nexus/db-queries/src/policy_test/resource_builder.rs +++ b/nexus/db-queries/src/policy_test/resource_builder.rs @@ -264,6 +264,7 @@ impl_dyn_authorized_resource_for_resource!(authz::PhysicalDisk); impl_dyn_authorized_resource_for_resource!(authz::Project); impl_dyn_authorized_resource_for_resource!(authz::ProjectImage); impl_dyn_authorized_resource_for_resource!(authz::SamlIdentityProvider); +impl_dyn_authorized_resource_for_resource!(authz::ScimClientBearerToken); impl_dyn_authorized_resource_for_resource!(authz::Service); impl_dyn_authorized_resource_for_resource!(authz::Silo); impl_dyn_authorized_resource_for_resource!(authz::SiloGroup); diff --git a/nexus/db-queries/src/policy_test/resources.rs b/nexus/db-queries/src/policy_test/resources.rs index dc88e0498ba..a2173dbf95a 100644 --- a/nexus/db-queries/src/policy_test/resources.rs +++ b/nexus/db-queries/src/policy_test/resources.rs @@ -302,6 +302,15 @@ async fn make_silo( let create_project_users = first_branch && i == 0; make_project(builder, &silo, &project_name, create_project_users).await; } + + let scim_client_bearer_token_id = + "7885144e-9c75-47f7-a97d-7dfc58e1186c".parse().unwrap(); + + builder.new_resource(authz::ScimClientBearerToken::new( + silo.clone(), + scim_client_bearer_token_id, + LookupType::by_id(scim_client_bearer_token_id), + )); } /// Helper for `make_resources()` that constructs a small Project hierarchy diff --git a/nexus/db-queries/tests/output/authz-roles.out b/nexus/db-queries/tests/output/authz-roles.out index 4d7478c7e32..d85802d2c61 100644 --- a/nexus/db-queries/tests/output/authz-roles.out +++ b/nexus/db-queries/tests/output/authz-roles.out @@ -768,6 +768,20 @@ resource: InternetGatewayIpAddress "silo1-proj2-igw1-address1" silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! +resource: ScimClientBearerToken id "7885144e-9c75-47f7-a97d-7dfc58e1186c" + + 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 ! ! ! ! ! ! ! ! + resource: Silo "silo2" USER Q R LC RP M MP CC D @@ -1160,6 +1174,20 @@ resource: InternetGatewayIpAddress "silo2-proj1-igw1-address1" silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! +resource: ScimClientBearerToken id "7885144e-9c75-47f7-a97d-7dfc58e1186c" + + 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 ! ! ! ! ! ! ! ! + resource: Rack id "c037e882-8b6d-c8b5-bef4-97e848eb0a50" USER Q R LC RP M MP CC D diff --git a/nexus/db-schema/src/schema.rs b/nexus/db-schema/src/schema.rs index 1f85f3e8d58..2cd55147d85 100644 --- a/nexus/db-schema/src/schema.rs +++ b/nexus/db-schema/src/schema.rs @@ -2790,3 +2790,17 @@ table! { result_kind -> crate::enums::AuditLogResultKindEnum, } } + +table! { + scim_client_bearer_token (id) { + id -> Uuid, + + time_created -> Timestamptz, + time_deleted -> Nullable, + time_expires -> Nullable, + + silo_id -> Uuid, + + bearer_token -> Text, + } +} diff --git a/nexus/external-api/output/nexus_tags.txt b/nexus/external-api/output/nexus_tags.txt index 65d7b5f06f0..d7c54768fc8 100644 --- a/nexus/external-api/output/nexus_tags.txt +++ b/nexus/external-api/output/nexus_tags.txt @@ -274,7 +274,6 @@ saml_identity_provider_create POST /v1/system/identity-providers/ saml_identity_provider_view GET /v1/system/identity-providers/saml/{provider} scim_token_create POST /v1/system/scim/tokens scim_token_delete DELETE /v1/system/scim/tokens/{token_id} -scim_token_delete_all DELETE /v1/system/scim/tokens scim_token_list GET /v1/system/scim/tokens scim_token_view GET /v1/system/scim/tokens/{token_id} silo_create POST /v1/system/silos diff --git a/nexus/external-api/src/lib.rs b/nexus/external-api/src/lib.rs index 7bf518397e6..bde6773aa08 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -650,19 +650,6 @@ pub trait NexusExternalApi { query_params: Query, ) -> Result; - /// Delete all SCIM tokens - /// - /// Specify the silo by name or ID using the `silo` query parameter. - #[endpoint { - method = DELETE, - path = "/v1/system/scim/tokens", - tags = ["system/silos"], - }] - async fn scim_token_delete_all( - rqctx: RequestContext, - query_params: Query, - ) -> Result; - // SCIM user endpoints // XXX is "silos" the correct tag? diff --git a/nexus/src/app/scim.rs b/nexus/src/app/scim.rs index 71aa8a638f7..ad868cc8f73 100644 --- a/nexus/src/app/scim.rs +++ b/nexus/src/app/scim.rs @@ -4,12 +4,16 @@ //! SCIM endpoints +use crate::db::model::UserProvisionType; +use chrono::Utc; use dropshot::Body; use dropshot::HttpError; use http::Response; +use http::StatusCode; use nexus_db_lookup::lookup; use nexus_db_queries::authz; use nexus_db_queries::context::OpContext; +use nexus_db_queries::db::datastore::CrdbScimProviderStore; use nexus_types::external_api::views; use omicron_common::api::external::CreateResult; use omicron_common::api::external::DeleteResult; @@ -18,11 +22,6 @@ use omicron_common::api::external::ListResultVec; use omicron_common::api::external::LookupResult; use uuid::Uuid; -// XXX temporary for stub PR -use crate::app::Unimpl; -use omicron_common::api::external::LookupType; -use omicron_common::api::external::ResourceType; - impl super::Nexus { // SCIM tokens @@ -31,15 +30,11 @@ impl super::Nexus { opctx: &OpContext, silo_lookup: &lookup::Silo<'_>, ) -> ListResultVec { - let (.., _authz_silo, _) = + let (.., authz_silo, _) = silo_lookup.fetch_for(authz::Action::ListChildren).await?; - - let resource_type = ResourceType::ScimClientBearerToken; - let lookup_type = - LookupType::ByOther(String::from("scim_idp_get_tokens")); - let not_found_error = lookup_type.into_not_found(resource_type); - let unimp = Unimpl::ProtectedLookup(not_found_error); - Err(self.unimplemented_todo(opctx, unimp).await) + let tokens = + self.datastore().scim_idp_get_tokens(opctx, &authz_silo).await?; + Ok(tokens.into_iter().map(|t| t.into()).collect()) } pub(crate) async fn scim_idp_create_token( @@ -47,15 +42,11 @@ impl super::Nexus { opctx: &OpContext, silo_lookup: &lookup::Silo<'_>, ) -> CreateResult { - let (.., _authz_silo, _) = - silo_lookup.fetch_for(authz::Action::Modify).await?; - - let resource_type = ResourceType::ScimClientBearerToken; - let lookup_type = - LookupType::ByOther(String::from("scim_idp_create_token")); - let not_found_error = lookup_type.into_not_found(resource_type); - let unimp = Unimpl::ProtectedLookup(not_found_error); - Err(self.unimplemented_todo(opctx, unimp).await) + let (.., authz_silo, _) = + silo_lookup.fetch_for(authz::Action::ListChildren).await?; + let token = + self.datastore().scim_idp_create_token(opctx, &authz_silo).await?; + Ok(token.into()) } pub(crate) async fn scim_idp_get_token_by_id( @@ -64,14 +55,15 @@ impl super::Nexus { silo_lookup: &lookup::Silo<'_>, token_id: Uuid, ) -> LookupResult { - let (.., _authz_silo, _) = - silo_lookup.fetch_for(authz::Action::Read).await?; + let (.., authz_silo, _) = + silo_lookup.fetch_for(authz::Action::ListChildren).await?; + + let token = self + .datastore() + .scim_idp_get_token_by_id(opctx, &authz_silo, token_id) + .await?; - let resource_type = ResourceType::ScimClientBearerToken; - let lookup_type = LookupType::by_id(token_id); - let not_found_error = lookup_type.into_not_found(resource_type); - let unimp = Unimpl::ProtectedLookup(not_found_error); - Err(self.unimplemented_todo(opctx, unimp).await) + Ok(token.into()) } pub(crate) async fn scim_idp_delete_token_by_id( @@ -80,135 +72,279 @@ impl super::Nexus { silo_lookup: &lookup::Silo<'_>, token_id: Uuid, ) -> DeleteResult { - let (.., _authz_silo, _) = - silo_lookup.fetch_for(authz::Action::Delete).await?; + let (.., authz_silo, _) = + silo_lookup.fetch_for(authz::Action::ListChildren).await?; + + self.datastore() + .scim_idp_delete_token_by_id(opctx, &authz_silo, token_id) + .await?; - let resource_type = ResourceType::ScimClientBearerToken; - let lookup_type = LookupType::by_id(token_id); - let not_found_error = lookup_type.into_not_found(resource_type); - let unimp = Unimpl::ProtectedLookup(not_found_error); - Err(self.unimplemented_todo(opctx, unimp).await) + Ok(()) } - pub(crate) async fn scim_idp_delete_tokens_for_silo( + // SCIM client authentication + + /// Authenticate a SCIM client based on a bearer token, and return a SCIM + /// provider store implementation that is scoped to a Silo. + pub(crate) async fn scim_idp_get_provider( &self, - opctx: &OpContext, - silo_lookup: &lookup::Silo<'_>, - ) -> DeleteResult { - let (.., _authz_silo, _) = - silo_lookup.fetch_for(authz::Action::ListChildren).await?; + request: &dropshot::RequestInfo, + ) -> LookupResult> { + let Some(header) = request.headers().get(http::header::AUTHORIZATION) + else { + return Err(Error::Unauthenticated { + internal_message: "Missing bearer token".to_string(), + }); + }; + + let token = match header.to_str() { + Ok(v) => v, + Err(_) => { + return Err(Error::Unauthenticated { + internal_message: "Invalid bearer token".to_string(), + }); + } + }; + + const BEARER: &str = "Bearer "; + + if !token.starts_with(BEARER) { + return Err(Error::Unauthenticated { + internal_message: "Invalid bearer token".to_string(), + }); + } + + let Some(bearer_token) = self + .datastore() + .scim_idp_lookup_token_by_bearer(token[BEARER.len()..].to_string()) + .await? + else { + return Err(Error::Unauthenticated { + internal_message: "Invalid bearer token".to_string(), + }); + }; + + if let Some(time_expires) = &bearer_token.time_expires { + if Utc::now() > *time_expires { + return Err(Error::Unauthenticated { + internal_message: "token expired".to_string(), + }); + } + } - let resource_type = ResourceType::ScimClientBearerToken; - let lookup_type = LookupType::ByOther(String::from( - "scim_idp_delete_tokens_for_silo", - )); - let not_found_error = lookup_type.into_not_found(resource_type); - let unimp = Unimpl::ProtectedLookup(not_found_error); - Err(self.unimplemented_todo(opctx, unimp).await) + // Validate that silo has the SCIM user provision type + let (_, db_silo) = { + let nexus_opctx = self.opctx_external_authn(); + self.silo_lookup(nexus_opctx, bearer_token.silo_id.into())? + .fetch() + .await? + }; + + if db_silo.user_provision_type != UserProvisionType::Scim { + return Err(Error::invalid_request( + "silo is not provisioned with scim", + )); + } + + let provider = scim2_rs::Provider::new( + self.log.new(slog::o!( + "component" => "scim2_rs::Provider", + "silo" => bearer_token.silo_id.to_string(), + )), + CrdbScimProviderStore::new( + bearer_token.silo_id, + self.datastore().clone(), + ), + ); + + Ok(provider) } // SCIM implementation - // XXX cannot use [`unimplemented_todo`] here, there's no opctx pub async fn scim_v2_list_users( &self, - _request: &dropshot::RequestInfo, - _query: scim2_rs::QueryParams, + request: &dropshot::RequestInfo, + query: scim2_rs::QueryParams, ) -> Result, HttpError> { - Err(Error::internal_error("endpoint is not implemented").into()) + let provider = self.scim_idp_get_provider(&request).await?; + + let result = match provider.list_users(query).await { + Ok(response) => response.to_http_response(), + Err(error) => error.to_http_response(), + }; + + result.map_err(HttpError::from) } pub async fn scim_v2_get_user_by_id( &self, - _request: &dropshot::RequestInfo, - _query: scim2_rs::QueryParams, - _user_id: String, + request: &dropshot::RequestInfo, + query: scim2_rs::QueryParams, + user_id: String, ) -> Result, HttpError> { - Err(Error::internal_error("endpoint is not implemented").into()) + let provider = self.scim_idp_get_provider(&request).await?; + + let result = match provider.get_user_by_id(query, &user_id).await { + Ok(response) => response.to_http_response(StatusCode::OK), + Err(error) => error.to_http_response(), + }; + + result.map_err(HttpError::from) } pub async fn scim_v2_create_user( &self, - _request: &dropshot::RequestInfo, - _body: scim2_rs::CreateUserRequest, + request: &dropshot::RequestInfo, + body: scim2_rs::CreateUserRequest, ) -> Result, HttpError> { - Err(Error::internal_error("endpoint is not implemented").into()) + let provider = self.scim_idp_get_provider(&request).await?; + + let result = match provider.create_user(body).await { + Ok(response) => response.to_http_response(StatusCode::CREATED), + Err(error) => error.to_http_response(), + }; + + result.map_err(HttpError::from) } pub async fn scim_v2_replace_user( &self, - _request: &dropshot::RequestInfo, - _user_id: String, - _body: scim2_rs::CreateUserRequest, + request: &dropshot::RequestInfo, + user_id: String, + body: scim2_rs::CreateUserRequest, ) -> Result, HttpError> { - Err(Error::internal_error("endpoint is not implemented").into()) + let provider = self.scim_idp_get_provider(&request).await?; + + let result = match provider.replace_user(&user_id, body).await { + Ok(response) => response.to_http_response(StatusCode::OK), + Err(error) => error.to_http_response(), + }; + + result.map_err(HttpError::from) } pub async fn scim_v2_patch_user( &self, - _request: &dropshot::RequestInfo, - _user_id: String, - _body: scim2_rs::PatchRequest, + request: &dropshot::RequestInfo, + user_id: String, + body: scim2_rs::PatchRequest, ) -> Result, HttpError> { - Err(Error::internal_error("endpoint is not implemented").into()) + let provider = self.scim_idp_get_provider(&request).await?; + + let result = match provider.patch_user(&user_id, body).await { + Ok(response) => response.to_http_response(StatusCode::OK), + Err(error) => error.to_http_response(), + }; + + result.map_err(HttpError::from) } pub async fn scim_v2_delete_user( &self, - _request: &dropshot::RequestInfo, - _user_id: String, + request: &dropshot::RequestInfo, + user_id: String, ) -> Result, HttpError> { - Err(Error::internal_error("endpoint is not implemented").into()) + let provider = self.scim_idp_get_provider(&request).await?; + + let result = match provider.delete_user(&user_id).await { + Ok(response) => Ok(response), + Err(error) => error.to_http_response(), + }; + + result.map_err(HttpError::from) } pub async fn scim_v2_list_groups( &self, - _request: &dropshot::RequestInfo, - _query: scim2_rs::QueryParams, + request: &dropshot::RequestInfo, + query: scim2_rs::QueryParams, ) -> Result, HttpError> { - Err(Error::internal_error("endpoint is not implemented").into()) + let provider = self.scim_idp_get_provider(&request).await?; + + let result = match provider.list_groups(query).await { + Ok(response) => response.to_http_response(), + Err(error) => error.to_http_response(), + }; + + result.map_err(HttpError::from) } pub async fn scim_v2_get_group_by_id( &self, - _request: &dropshot::RequestInfo, - _query: scim2_rs::QueryParams, - _group_id: String, + request: &dropshot::RequestInfo, + query: scim2_rs::QueryParams, + group_id: String, ) -> Result, HttpError> { - Err(Error::internal_error("endpoint is not implemented").into()) + let provider = self.scim_idp_get_provider(&request).await?; + + let result = match provider.get_group_by_id(query, &group_id).await { + Ok(response) => response.to_http_response(StatusCode::OK), + Err(error) => error.to_http_response(), + }; + + result.map_err(HttpError::from) } pub async fn scim_v2_create_group( &self, - _request: &dropshot::RequestInfo, - _body: scim2_rs::CreateGroupRequest, + request: &dropshot::RequestInfo, + body: scim2_rs::CreateGroupRequest, ) -> Result, HttpError> { - Err(Error::internal_error("endpoint is not implemented").into()) + let provider = self.scim_idp_get_provider(&request).await?; + + let result = match provider.create_group(body).await { + Ok(response) => response.to_http_response(StatusCode::CREATED), + Err(error) => error.to_http_response(), + }; + + result.map_err(HttpError::from) } pub async fn scim_v2_replace_group( &self, - _request: &dropshot::RequestInfo, - _group_id: String, - _body: scim2_rs::CreateGroupRequest, + request: &dropshot::RequestInfo, + group_id: String, + body: scim2_rs::CreateGroupRequest, ) -> Result, HttpError> { - Err(Error::internal_error("endpoint is not implemented").into()) + let provider = self.scim_idp_get_provider(&request).await?; + + let result = match provider.replace_group(&group_id, body).await { + Ok(response) => response.to_http_response(StatusCode::OK), + Err(error) => error.to_http_response(), + }; + + result.map_err(HttpError::from) } pub async fn scim_v2_patch_group( &self, - _request: &dropshot::RequestInfo, - _group_id: String, - _body: scim2_rs::PatchRequest, + request: &dropshot::RequestInfo, + group_id: String, + body: scim2_rs::PatchRequest, ) -> Result, HttpError> { - Err(Error::internal_error("endpoint is not implemented").into()) + let provider = self.scim_idp_get_provider(&request).await?; + + let result = match provider.patch_group(&group_id, body).await { + Ok(response) => response.to_http_response(StatusCode::OK), + Err(error) => error.to_http_response(), + }; + + result.map_err(HttpError::from) } pub async fn scim_v2_delete_group( &self, - _request: &dropshot::RequestInfo, - _group_id: String, + request: &dropshot::RequestInfo, + group_id: String, ) -> Result, HttpError> { - Err(Error::internal_error("endpoint is not implemented").into()) + let provider = self.scim_idp_get_provider(&request).await?; + + let result = match provider.delete_group(&group_id).await { + Ok(response) => Ok(response), + Err(error) => error.to_http_response(), + }; + + result.map_err(HttpError::from) } } diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index a4e940d8a5f..712aaa7b662 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -1031,40 +1031,6 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } - async fn scim_token_delete_all( - rqctx: RequestContext, - query_params: Query, - ) -> Result { - let apictx = rqctx.context(); - let handler = async { - let opctx = - crate::context::op_context_for_external_api(&rqctx).await?; - let nexus = &apictx.context.nexus; - let audit = nexus.audit_log_entry_init(&opctx, &rqctx).await?; - - let result = async { - let query = query_params.into_inner(); - let silo_lookup = nexus.silo_lookup(&opctx, query.silo)?; - - nexus - .scim_idp_delete_tokens_for_silo(&opctx, &silo_lookup) - .await?; - - Ok(HttpResponseDeleted()) - } - .await; - - let _ = - nexus.audit_log_entry_complete(&opctx, &audit, &result).await; - result - }; - apictx - .context - .external_latencies - .instrument_dropshot_handler(&rqctx, handler) - .await - } - async fn scim_v2_list_users( rqctx: RequestContext, query_params: Query, diff --git a/nexus/test-utils/src/http_testing.rs b/nexus/test-utils/src/http_testing.rs index ab4905267e6..7544d30b941 100644 --- a/nexus/test-utils/src/http_testing.rs +++ b/nexus/test-utils/src/http_testing.rs @@ -660,6 +660,17 @@ impl<'a> NexusRequest<'a> { ) } + /// Returns a new `NexusRequest` suitable for `POST $uri` with no body + pub fn objects_post_no_body( + testctx: &'a ClientTestContext, + uri: &str, + ) -> Self { + NexusRequest::new( + RequestBuilder::new(testctx, http::Method::POST, uri) + .expect_status(Some(http::StatusCode::CREATED)), + ) + } + /// Returns a new `NexusRequest` suitable for `GET $uri` pub fn object_get(testctx: &'a ClientTestContext, uri: &str) -> Self { NexusRequest::new( diff --git a/nexus/test-utils/src/resource_helpers.rs b/nexus/test-utils/src/resource_helpers.rs index 71871e932c8..297e1322700 100644 --- a/nexus/test-utils/src/resource_helpers.rs +++ b/nexus/test-utils/src/resource_helpers.rs @@ -148,6 +148,24 @@ where .unwrap() } +pub async fn object_create_no_body( + client: &ClientTestContext, + path: &str, +) -> OutputType +where + OutputType: serde::de::DeserializeOwned, +{ + NexusRequest::objects_post_no_body(client, path) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap_or_else(|e| { + panic!("failed to make \"POST\" request to {path}: {e}") + }) + .parsed_body() + .unwrap() +} + /// Make a POST, assert status code, return error response body pub async fn object_create_error( client: &ClientTestContext, diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index d8c62a95d94..3df21e99f00 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -1274,6 +1274,18 @@ pub static AUDIT_LOG_URL: LazyLock = LazyLock::new(|| { String::from("/v1/system/audit-log?start_time=2025-01-01T00:00:00Z") }); +pub static SCIM_TOKENS_URL: LazyLock = LazyLock::new(|| { + format!("/v1/system/scim/tokens?silo={}", DEFAULT_SILO.identity().name,) +}); + +pub static SCIM_TOKEN_URL: LazyLock = LazyLock::new(|| { + format!( + "/v1/system/scim/tokens/{}?silo={}", + "7885144e-9c75-47f7-a97d-7dfc58e1186c", + DEFAULT_SILO.identity().name, + ) +}); + /// Describes an API endpoint to be verified by the "unauthorized" test /// /// These structs are also used to check whether we're covering all endpoints in @@ -3031,6 +3043,25 @@ pub static VERIFY_ENDPOINTS: LazyLock> = LazyLock::new( unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![AllowedMethod::Get], }, + // SCIM client tokens + VerifyEndpoint { + url: &SCIM_TOKENS_URL, + visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::None, + allowed_methods: vec![ + AllowedMethod::Get, + AllowedMethod::Post(serde_json::Value::Null), + ], + }, + VerifyEndpoint { + url: &SCIM_TOKEN_URL, + visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::None, + allowed_methods: vec![ + AllowedMethod::Get, + AllowedMethod::Delete, + ], + }, ] }, ); diff --git a/nexus/tests/integration_tests/scim.rs b/nexus/tests/integration_tests/scim.rs index 60ee0453008..0f0c505900a 100644 --- a/nexus/tests/integration_tests/scim.rs +++ b/nexus/tests/integration_tests/scim.rs @@ -2,22 +2,31 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. +use crate::integration_tests::saml::SAML_IDP_DESCRIPTOR; +use crate::integration_tests::saml::SAML_RESPONSE_IDP_DESCRIPTOR; +use crate::integration_tests::saml::SAML_RESPONSE_WITH_GROUPS; +use async_bb8_diesel::AsyncRunQueryDsl; +use base64::Engine; +use chrono::Utc; +use http::StatusCode; +use http::method::Method; +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, object_create}; +use nexus_test_utils::resource_helpers::create_silo; +use nexus_test_utils::resource_helpers::grant_iam; +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; +use nexus_test_utils::resource_helpers::object_get; use nexus_test_utils_macros::nexus_test; use nexus_types::external_api::views::{self, Silo}; use nexus_types::external_api::{params, shared}; +use nexus_types::identity::Asset; use omicron_common::api::external::IdentityMetadataCreateParams; use omicron_nexus::TestInterfaces; - -use base64::Engine; -use http::StatusCode; -use http::method::Method; - -use crate::integration_tests::saml::SAML_IDP_DESCRIPTOR; -use crate::integration_tests::saml::SAML_RESPONSE_IDP_DESCRIPTOR; -use crate::integration_tests::saml::SAML_RESPONSE_WITH_GROUPS; +use uuid::Uuid; type ControlPlaneTestContext = nexus_test_utils::ControlPlaneTestContext; @@ -204,3 +213,298 @@ async fn test_no_jit_for_saml_scim_silos(cptestctx: &ControlPlaneTestContext) { .await .expect("expected 401"); } + +#[nexus_test] +async fn test_scim_client_token_crud(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + + // 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, + USER_TEST_PRIVILEGED.id(), + AuthnMode::PrivilegedUser, + ) + .await; + + // Initially, there should be no tokens created during silo create. + + let tokens: Vec = + object_get(client, &format!("/v1/system/scim/tokens?silo={SILO_NAME}")) + .await; + + assert!(tokens.is_empty()); + + // Fleet admins can create SCIM client tokens + + let created_token_1: views::ScimClientBearerTokenValue = + object_create_no_body( + client, + &format!("/v1/system/scim/tokens?silo={SILO_NAME}"), + ) + .await; + + // Now there's one! + + let tokens: Vec = + object_get(client, &format!("/v1/system/scim/tokens?silo={SILO_NAME}")) + .await; + + assert_eq!(tokens.len(), 1); + assert_eq!(tokens[0].id, created_token_1.id); + + // Get that specific token + + let token: views::ScimClientBearerToken = object_get( + client, + &format!( + "/v1/system/scim/tokens/{}?silo={SILO_NAME}", + created_token_1.id, + ), + ) + .await; + + assert_eq!(token.id, created_token_1.id); + + // Create a new token + + let created_token_2: views::ScimClientBearerTokenValue = + object_create_no_body( + client, + &format!("/v1/system/scim/tokens?silo={SILO_NAME}"), + ) + .await; + + // Now there's two! + + let tokens: Vec = + object_get(client, &format!("/v1/system/scim/tokens?silo={SILO_NAME}")) + .await; + + assert_eq!(tokens.len(), 2); + assert!(tokens.iter().any(|token| token.id == created_token_1.id)); + assert!(tokens.iter().any(|token| token.id == created_token_2.id)); + + // Create one more + + let created_token_3: views::ScimClientBearerTokenValue = + object_create_no_body( + client, + &format!("/v1/system/scim/tokens?silo={SILO_NAME}"), + ) + .await; + + let tokens: Vec = + object_get(client, &format!("/v1/system/scim/tokens?silo={SILO_NAME}")) + .await; + + assert_eq!(tokens.len(), 3); + assert!(tokens.iter().any(|token| token.id == created_token_1.id)); + assert!(tokens.iter().any(|token| token.id == created_token_2.id)); + assert!(tokens.iter().any(|token| token.id == created_token_3.id)); + + // Delete one + + object_delete( + client, + &format!( + "/v1/system/scim/tokens/{}?silo={SILO_NAME}", + created_token_1.id, + ), + ) + .await; + + // Check there's two + + let tokens: Vec = + object_get(client, &format!("/v1/system/scim/tokens?silo={SILO_NAME}")) + .await; + + assert_eq!(tokens.len(), 2); + assert!(tokens.iter().any(|token| token.id == created_token_2.id)); + assert!(tokens.iter().any(|token| token.id == created_token_3.id)); +} + +#[nexus_test] +async fn test_scim_client_token_tenancy(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + + // Create two Silos, then grant the PrivilegedUser the Admin role on both + + const SILO_1_NAME: &str = "saml-scim-silo-1"; + const SILO_2_NAME: &str = "saml-scim-silo-2"; + + create_silo(&client, SILO_1_NAME, true, shared::SiloIdentityMode::SamlScim) + .await; + + create_silo(&client, SILO_2_NAME, true, shared::SiloIdentityMode::SamlScim) + .await; + + grant_iam( + client, + &format!("/v1/system/silos/{SILO_1_NAME}"), + shared::SiloRole::Admin, + USER_TEST_PRIVILEGED.id(), + AuthnMode::PrivilegedUser, + ) + .await; + + grant_iam( + client, + &format!("/v1/system/silos/{SILO_2_NAME}"), + shared::SiloRole::Admin, + USER_TEST_PRIVILEGED.id(), + AuthnMode::PrivilegedUser, + ) + .await; + + // Initially, there should be no tokens created during silo create. + + let tokens: Vec = object_get( + client, + &format!("/v1/system/scim/tokens?silo={SILO_1_NAME}"), + ) + .await; + + assert!(tokens.is_empty()); + + let tokens: Vec = object_get( + client, + &format!("/v1/system/scim/tokens?silo={SILO_2_NAME}"), + ) + .await; + + assert!(tokens.is_empty()); + + // Create a token in one of the Silos + + let _created_token_1: views::ScimClientBearerTokenValue = + object_create_no_body( + client, + &format!("/v1/system/scim/tokens?silo={SILO_1_NAME}"), + ) + .await; + + // Now there's one but only in the first Silo + + let tokens: Vec = object_get( + client, + &format!("/v1/system/scim/tokens?silo={SILO_1_NAME}"), + ) + .await; + + assert!(!tokens.is_empty()); + + let tokens: Vec = object_get( + client, + &format!("/v1/system/scim/tokens?silo={SILO_2_NAME}"), + ) + .await; + + assert!(tokens.is_empty()); +} + +#[nexus_test] +async fn test_scim_client_token_bearer_auth( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + // 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, + USER_TEST_PRIVILEGED.id(), + 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; + + // 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( + http::header::AUTHORIZATION, + format!("Bearer {}", created_token.bearer_token), + ) + .allow_non_dropshot_errors() + .expect_status(Some(StatusCode::INTERNAL_SERVER_ERROR)) + .execute() + .await + .expect("expected 500"); +} + +#[nexus_test] +async fn test_scim_client_no_auth_with_expired_token( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.server_context().nexus; + + // Create a Silo, then insert an expired token into it + + const SILO_NAME: &str = "saml-scim-silo"; + + let silo = create_silo( + &client, + SILO_NAME, + true, + shared::SiloIdentityMode::SamlScim, + ) + .await; + + // Manually create an expired token + + { + let now = Utc::now(); + + let new_token = ScimClientBearerToken { + id: Uuid::new_v4(), + time_created: now, + time_deleted: None, + time_expires: Some(now), + silo_id: silo.identity.id, + bearer_token: String::from("testpost"), + }; + + let conn = nexus.datastore().pool_connection_for_tests().await.unwrap(); + + use nexus_db_schema::schema::scim_client_bearer_token::dsl; + diesel::insert_into(dsl::scim_client_bearer_token) + .values(new_token.clone()) + .execute_async(&*conn) + .await + .unwrap(); + } + + // This should 401 + + RequestBuilder::new(client, Method::GET, "/scim/v2/Users") + .header(http::header::AUTHORIZATION, String::from("Bearer testpost")) + .allow_non_dropshot_errors() + .expect_status(Some(StatusCode::UNAUTHORIZED)) + .execute() + .await + .expect("expected 401"); +} diff --git a/nexus/tests/integration_tests/unauthorized.rs b/nexus/tests/integration_tests/unauthorized.rs index 0969874fe7d..6448f71610c 100644 --- a/nexus/tests/integration_tests/unauthorized.rs +++ b/nexus/tests/integration_tests/unauthorized.rs @@ -8,6 +8,8 @@ use super::endpoints::*; use crate::integration_tests::saml::SAML_IDP_DESCRIPTOR; use crate::integration_tests::updates::TestTrustRoot; +use async_bb8_diesel::AsyncRunQueryDsl; +use chrono::Utc; use dropshot::HttpErrorResponseBody; use dropshot::test_util::ClientTestContext; use headers::authorization::Credentials; @@ -141,6 +143,35 @@ async fn test_unauthorized() { .await .unwrap(); + // Insert a SCIM client bearer token with a known UUID - normally these are + // completely random. + + { + use nexus_db_model::ScimClientBearerToken; + use nexus_types::silo::DEFAULT_SILO_ID; + + let now = Utc::now(); + + let new_token = ScimClientBearerToken { + id: "7885144e-9c75-47f7-a97d-7dfc58e1186c".parse().unwrap(), + time_created: now, + time_deleted: None, + time_expires: Some(now), + silo_id: DEFAULT_SILO_ID, + bearer_token: String::from("testpost"), + }; + + let nexus = &cptestctx.server.server_context().nexus; + let conn = nexus.datastore().pool_connection_for_tests().await.unwrap(); + + use nexus_db_schema::schema::scim_client_bearer_token::dsl; + diesel::insert_into(dsl::scim_client_bearer_token) + .values(new_token.clone()) + .execute_async(&*conn) + .await + .unwrap(); + } + // Verify the hardcoded endpoints. info!(log, "verifying endpoints"); print!("{}", VERIFY_HEADER); diff --git a/nexus/tests/output/uncovered-authz-endpoints.txt b/nexus/tests/output/uncovered-authz-endpoints.txt index 4b620abd0c2..5bfc5137529 100644 --- a/nexus/tests/output/uncovered-authz-endpoints.txt +++ b/nexus/tests/output/uncovered-authz-endpoints.txt @@ -1,8 +1,6 @@ API endpoints with no coverage in authz tests: probe_delete (delete "/experimental/v1/probes/{probe}") current_user_access_token_delete (delete "/v1/me/access-tokens/{token_id}") -scim_token_delete_all (delete "/v1/system/scim/tokens") -scim_token_delete (delete "/v1/system/scim/tokens/{token_id}") probe_list (get "/experimental/v1/probes") probe_view (get "/experimental/v1/probes/{probe}") support_bundle_download (get "/experimental/v1/system/support-bundles/{bundle_id}/download") @@ -12,8 +10,6 @@ ping (get "/v1/ping") networking_switch_port_lldp_neighbors (get "/v1/system/hardware/rack-switch-port/{rack_id}/{switch_location}/{port}/lldp/neighbors") networking_switch_port_lldp_config_view (get "/v1/system/hardware/switch-port/{port}/lldp/config") networking_switch_port_status (get "/v1/system/hardware/switch-port/{port}/status") -scim_token_list (get "/v1/system/scim/tokens") -scim_token_view (get "/v1/system/scim/tokens/{token_id}") support_bundle_head (head "/experimental/v1/system/support-bundles/{bundle_id}/download") support_bundle_head_file (head "/experimental/v1/system/support-bundles/{bundle_id}/download/{file}") device_auth_request (post "/device/auth") @@ -25,4 +21,3 @@ alert_delivery_resend (post "/v1/alerts/{alert_id}/resend") login_local (post "/v1/login/{silo_name}/local") logout (post "/v1/logout") networking_switch_port_lldp_config_update (post "/v1/system/hardware/switch-port/{port}/lldp/config") -scim_token_create (post "/v1/system/scim/tokens") diff --git a/openapi/nexus.json b/openapi/nexus.json index f4c4dc182ad..0b657a1e796 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -10430,36 +10430,6 @@ "$ref": "#/components/responses/Error" } } - }, - "delete": { - "tags": [ - "system/silos" - ], - "summary": "Delete all SCIM tokens", - "description": "Specify the silo by name or ID using the `silo` query parameter.", - "operationId": "scim_token_delete_all", - "parameters": [ - { - "in": "query", - "name": "silo", - "description": "Name or ID of the silo", - "required": true, - "schema": { - "$ref": "#/components/schemas/NameOrId" - } - } - ], - "responses": { - "204": { - "description": "successful deletion" - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } } }, "/v1/system/scim/tokens/{token_id}": { diff --git a/schema/crdb/dbinit.sql b/schema/crdb/dbinit.sql index a0eb918ee25..2d7e6126934 100644 --- a/schema/crdb/dbinit.sql +++ b/schema/crdb/dbinit.sql @@ -6762,6 +6762,33 @@ CREATE UNIQUE INDEX IF NOT EXISTS lookup_db_metadata_nexus_by_state on omicron.p nexus_id ); +CREATE TABLE IF NOT EXISTS omicron.public.scim_client_bearer_token ( + /* Identity metadata */ + id UUID PRIMARY KEY, + + time_created TIMESTAMPTZ NOT NULL, + time_deleted TIMESTAMPTZ, + time_expires TIMESTAMPTZ, + + silo_id UUID NOT NULL, + + bearer_token TEXT NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS + lookup_scim_client_by_silo_id +ON + omicron.public.scim_client_bearer_token (silo_id, id) +WHERE + time_deleted IS NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS + bearer_token_unique_for_scim_client +ON + omicron.public.scim_client_bearer_token (bearer_token) +WHERE + time_deleted IS NULL; + -- Keep this at the end of file so that the database does not contain a version -- until it is fully populated. INSERT INTO omicron.public.db_metadata ( @@ -6771,7 +6798,7 @@ INSERT INTO omicron.public.db_metadata ( version, target_version ) VALUES - (TRUE, NOW(), NOW(), '197.0.0', NULL) + (TRUE, NOW(), NOW(), '198.0.0', NULL) ON CONFLICT DO NOTHING; COMMIT; diff --git a/schema/crdb/scim-client-bearer-token/up01.sql b/schema/crdb/scim-client-bearer-token/up01.sql new file mode 100644 index 00000000000..6715f9a340f --- /dev/null +++ b/schema/crdb/scim-client-bearer-token/up01.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS omicron.public.scim_client_bearer_token ( + /* Identity metadata */ + id UUID PRIMARY KEY, + time_created TIMESTAMPTZ NOT NULL, + time_deleted TIMESTAMPTZ, + time_expires TIMESTAMPTZ, + silo_id UUID NOT NULL, + bearer_token TEXT NOT NULL +); diff --git a/schema/crdb/scim-client-bearer-token/up02.sql b/schema/crdb/scim-client-bearer-token/up02.sql new file mode 100644 index 00000000000..4ba5ddd075d --- /dev/null +++ b/schema/crdb/scim-client-bearer-token/up02.sql @@ -0,0 +1,6 @@ +CREATE UNIQUE INDEX IF NOT EXISTS + lookup_scim_client_by_silo_id +ON + omicron.public.scim_client_bearer_token (silo_id, id) +WHERE + time_deleted IS NULL; diff --git a/schema/crdb/scim-client-bearer-token/up03.sql b/schema/crdb/scim-client-bearer-token/up03.sql new file mode 100644 index 00000000000..3ae034d15f4 --- /dev/null +++ b/schema/crdb/scim-client-bearer-token/up03.sql @@ -0,0 +1,6 @@ +CREATE UNIQUE INDEX IF NOT EXISTS + bearer_token_unique_for_scim_client +ON + omicron.public.scim_client_bearer_token (bearer_token) +WHERE + time_deleted IS NULL;