From dea9bf9ccecde34accb1df04a8f9c5d30828ffa0 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Wed, 24 Sep 2025 21:11:12 +0000 Subject: [PATCH 01/18] [SCIM 3/4]: SCIM client token CRUD + Bearer auth Implement the CRUD routines for the tokens that will be used to authenticate SCIM clients for SamlScim Silos. Also implement the Bearer based authentication for SCIM clients. Fill in the skeleton of CrdbScimProviderStore, which when implemented will complete the SCIM implementation in Nexus. --- Cargo.lock | 2 + nexus/auth/src/authz/api_resources.rs | 8 + nexus/auth/src/authz/omicron.polar | 38 +- nexus/auth/src/authz/oso_generic.rs | 1 + nexus/db-model/src/lib.rs | 2 + nexus/db-model/src/schema_versions.rs | 3 +- .../db-model/src/scim_client_bearer_token.rs | 47 +++ nexus/db-queries/Cargo.toml | 2 + nexus/db-queries/src/db/datastore/mod.rs | 3 + nexus/db-queries/src/db/datastore/scim.rs | 172 ++++++++ .../src/db/datastore/scim_provider_store.rs | 130 +++++++ nexus/db-queries/src/policy_test/mod.rs | 2 +- .../src/policy_test/resource_builder.rs | 1 + nexus/db-queries/src/policy_test/resources.rs | 9 + nexus/db-queries/tests/output/authz-roles.out | 28 ++ nexus/db-schema/src/schema.rs | 14 + nexus/src/app/scim.rs | 331 +++++++++++----- nexus/test-utils/src/http_testing.rs | 11 + nexus/test-utils/src/resource_helpers.rs | 18 + nexus/tests/integration_tests/endpoints.rs | 32 ++ nexus/tests/integration_tests/scim.rs | 366 +++++++++++++++++- nexus/tests/integration_tests/unauthorized.rs | 31 ++ .../output/uncovered-authz-endpoints.txt | 5 - schema/crdb/dbinit.sql | 29 +- schema/crdb/scim-client-bearer-token/up01.sql | 9 + schema/crdb/scim-client-bearer-token/up02.sql | 6 + schema/crdb/scim-client-bearer-token/up03.sql | 6 + 27 files changed, 1201 insertions(+), 105 deletions(-) create mode 100644 nexus/db-model/src/scim_client_bearer_token.rs create mode 100644 nexus/db-queries/src/db/datastore/scim.rs create mode 100644 nexus/db-queries/src/db/datastore/scim_provider_store.rs create mode 100644 schema/crdb/scim-client-bearer-token/up01.sql create mode 100644 schema/crdb/scim-client-bearer-token/up02.sql create mode 100644 schema/crdb/scim-client-bearer-token/up03.sql 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-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..ecf766f3e04 --- /dev/null +++ b/nexus/db-model/src/scim_client_bearer_token.rs @@ -0,0 +1,47 @@ +// 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 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..751509e8e0f --- /dev/null +++ b/nexus/db-queries/src/db/datastore/scim.rs @@ -0,0 +1,172 @@ +// 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 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, + id: Uuid, + ) -> LookupResult> { + 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 token = dsl::scim_client_bearer_token + .filter(dsl::silo_id.eq(authz_silo.id())) + .filter(dsl::id.eq(id)) + .filter(dsl::time_deleted.is_null()) + .select(ScimClientBearerToken::as_select()) + .first_async::(&*conn) + .await + .optional() + .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, + id: Uuid, + ) -> DeleteResult { + opctx.authorize(authz::Action::Modify, authz_silo).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(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(()) + } + + pub async fn scim_idp_delete_tokens_for_silo( + &self, + opctx: &OpContext, + authz_silo: &authz::Silo, + ) -> DeleteResult { + opctx.authorize(authz::Action::Modify, authz_silo).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::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..088c957ae67 --- /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 patterm 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/src/app/scim.rs b/nexus/src/app/scim.rs index 71aa8a638f7..2ba72bb38f6 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,21 @@ 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 maybe_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) + match maybe_token { + Some(token) => Ok(token.into()), + + None => Err(Error::non_resourcetype_not_found(format!( + "token with id {token_id} not found" + ))), + } } pub(crate) async fn scim_idp_delete_token_by_id( @@ -80,14 +78,14 @@ 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( @@ -95,120 +93,279 @@ impl super::Nexus { opctx: &OpContext, silo_lookup: &lookup::Silo<'_>, ) -> DeleteResult { - 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_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) + self.datastore() + .scim_idp_delete_tokens_for_silo(opctx, &authz_silo) + .await?; + + Ok(()) + } + + // 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, + 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(), + }); + } + } + + // 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/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..fc8d5c65a5a 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,26 @@ 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), + AllowedMethod::Delete, + ], + }, + 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..b2ebf375b64 100644 --- a/nexus/tests/integration_tests/scim.rs +++ b/nexus/tests/integration_tests/scim.rs @@ -2,22 +2,30 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. +use 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::silos::{IdentityProviderType, SamlLoginPost}; +use nexus_db_queries::context::OpContext; +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 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 +212,343 @@ 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; + let nexus = &cptestctx.server.server_context().nexus; + let opctx = OpContext::for_tests( + cptestctx.logctx.log.new(o!()), + nexus.datastore().clone(), + ); + + // Create a Silo, then grant the PrivilegedUser the Admin role on it + + const SILO_NAME: &str = "saml-scim-silo"; + create_silo(&client, SILO_NAME, true, shared::SiloIdentityMode::SamlScim) + .await; + + grant_iam( + client, + &format!("/v1/system/silos/{SILO_NAME}"), + shared::SiloRole::Admin, + opctx.authn.actor().unwrap().silo_user_id().unwrap(), + AuthnMode::PrivilegedUser, + ) + .await; + + // 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)); + + // Delete them all + + object_delete(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(), 0); +} + +#[nexus_test] +async fn test_scim_client_token_tenancy(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.server_context().nexus; + + let opctx = OpContext::for_tests( + cptestctx.logctx.log.new(o!()), + nexus.datastore().clone(), + ); + + // Create 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, + opctx.authn.actor().unwrap().silo_user_id().unwrap(), + AuthnMode::PrivilegedUser, + ) + .await; + + grant_iam( + client, + &format!("/v1/system/silos/{SILO_2_NAME}"), + shared::SiloRole::Admin, + opctx.authn.actor().unwrap().silo_user_id().unwrap(), + 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()); + + // Delete all tokens in Silo 2 - this should not affect Silo 1 + + object_delete( + client, + &format!("/v1/system/scim/tokens?silo={SILO_2_NAME}"), + ) + .await; + + let tokens: Vec = object_get( + client, + &format!("/v1/system/scim/tokens?silo={SILO_1_NAME}"), + ) + .await; + + assert!(!tokens.is_empty()); +} + +#[nexus_test] +async fn test_scim_client_token_bearer_auth( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.server_context().nexus; + let opctx = OpContext::for_tests( + cptestctx.logctx.log.new(o!()), + nexus.datastore().clone(), + ); + + // Create a Silo, then grant the PrivilegedUser the Admin role on it + + const SILO_NAME: &str = "saml-scim-silo"; + create_silo(&client, SILO_NAME, true, shared::SiloIdentityMode::SamlScim) + .await; + + grant_iam( + client, + &format!("/v1/system/silos/{SILO_NAME}"), + shared::SiloRole::Admin, + opctx.authn.actor().unwrap().silo_user_id().unwrap(), + AuthnMode::PrivilegedUser, + ) + .await; + + // Create a token + + let created_token: views::ScimClientBearerTokenValue = + object_create_no_body( + client, + &format!("/v1/system/scim/tokens?silo={SILO_NAME}"), + ) + .await; + + // 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/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; From 56d5288a5f1806534c4a823e73a59d5c7da461a1 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Thu, 9 Oct 2025 13:47:56 +0000 Subject: [PATCH 02/18] remove the "delete all tokens" endpoint --- nexus/db-queries/src/db/datastore/scim.rs | 21 ------------- nexus/external-api/output/nexus_tags.txt | 1 - nexus/external-api/src/lib.rs | 13 --------- nexus/src/app/scim.rs | 15 ---------- nexus/src/external_api/http_entrypoints.rs | 34 ---------------------- nexus/tests/integration_tests/scim.rs | 27 ----------------- openapi/nexus.json | 30 ------------------- 7 files changed, 141 deletions(-) diff --git a/nexus/db-queries/src/db/datastore/scim.rs b/nexus/db-queries/src/db/datastore/scim.rs index 751509e8e0f..17e0ce5467c 100644 --- a/nexus/db-queries/src/db/datastore/scim.rs +++ b/nexus/db-queries/src/db/datastore/scim.rs @@ -128,27 +128,6 @@ impl DataStore { Ok(()) } - pub async fn scim_idp_delete_tokens_for_silo( - &self, - opctx: &OpContext, - authz_silo: &authz::Silo, - ) -> DeleteResult { - opctx.authorize(authz::Action::Modify, authz_silo).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::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. 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 2ba72bb38f6..25e6ad33b19 100644 --- a/nexus/src/app/scim.rs +++ b/nexus/src/app/scim.rs @@ -88,21 +88,6 @@ impl super::Nexus { Ok(()) } - pub(crate) async fn scim_idp_delete_tokens_for_silo( - &self, - opctx: &OpContext, - silo_lookup: &lookup::Silo<'_>, - ) -> DeleteResult { - let (.., authz_silo, _) = - silo_lookup.fetch_for(authz::Action::ListChildren).await?; - - self.datastore() - .scim_idp_delete_tokens_for_silo(opctx, &authz_silo) - .await?; - - Ok(()) - } - // SCIM client authentication /// Authenticate a SCIM client based on a bearer token, and return a SCIM 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/tests/integration_tests/scim.rs b/nexus/tests/integration_tests/scim.rs index b2ebf375b64..a66f7675ef3 100644 --- a/nexus/tests/integration_tests/scim.rs +++ b/nexus/tests/integration_tests/scim.rs @@ -335,17 +335,6 @@ async fn test_scim_client_token_crud(cptestctx: &ControlPlaneTestContext) { 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)); - - // Delete them all - - object_delete(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(), 0); } #[nexus_test] @@ -431,22 +420,6 @@ async fn test_scim_client_token_tenancy(cptestctx: &ControlPlaneTestContext) { .await; assert!(tokens.is_empty()); - - // Delete all tokens in Silo 2 - this should not affect Silo 1 - - object_delete( - client, - &format!("/v1/system/scim/tokens?silo={SILO_2_NAME}"), - ) - .await; - - let tokens: Vec = object_get( - client, - &format!("/v1/system/scim/tokens?silo={SILO_1_NAME}"), - ) - .await; - - assert!(!tokens.is_empty()); } #[nexus_test] 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}": { From 6e67ae94068e8c31f907749297d59852263cb933 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Thu, 9 Oct 2025 16:43:47 +0000 Subject: [PATCH 03/18] remove delete all from VERIFY_ENDPOINTS --- nexus/tests/integration_tests/endpoints.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index fc8d5c65a5a..3df21e99f00 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -3051,7 +3051,6 @@ pub static VERIFY_ENDPOINTS: LazyLock> = LazyLock::new( allowed_methods: vec![ AllowedMethod::Get, AllowedMethod::Post(serde_json::Value::Null), - AllowedMethod::Delete, ], }, VerifyEndpoint { From 9bbb0113b55f209b008d513d84f09a0301594cd5 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Fri, 10 Oct 2025 21:01:15 +0000 Subject: [PATCH 04/18] patterm --- nexus/db-queries/src/db/datastore/scim_provider_store.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nexus/db-queries/src/db/datastore/scim_provider_store.rs b/nexus/db-queries/src/db/datastore/scim_provider_store.rs index 088c957ae67..e07b4c4b67d 100644 --- a/nexus/db-queries/src/db/datastore/scim_provider_store.rs +++ b/nexus/db-queries/src/db/datastore/scim_provider_store.rs @@ -2,7 +2,7 @@ // 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 patterm of implementing a SCIM "provider" over something +//! 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. From ed37a89a5d7ace240619529efe106b399e9d4a2c Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Sat, 11 Oct 2025 01:05:27 +0000 Subject: [PATCH 05/18] Use authz lookup on bearer token itself Instead of silo Also grant silo admin role to USER_TEST_PRIVILEGED.id() instead of OpContext::for_tests in tests. --- nexus/db-lookup/src/lookup.rs | 21 +++++++++++ .../db-model/src/scim_client_bearer_token.rs | 6 ++++ nexus/db-queries/src/db/datastore/scim.rs | 22 +++++++----- nexus/src/app/scim.rs | 10 ++---- nexus/tests/integration_tests/scim.rs | 35 +++++-------------- 5 files changed, 52 insertions(+), 42 deletions(-) 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/scim_client_bearer_token.rs b/nexus/db-model/src/scim_client_bearer_token.rs index ecf766f3e04..3239bc85299 100644 --- a/nexus/db-model/src/scim_client_bearer_token.rs +++ b/nexus/db-model/src/scim_client_bearer_token.rs @@ -25,6 +25,12 @@ pub struct ScimClientBearerToken { 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 { diff --git a/nexus/db-queries/src/db/datastore/scim.rs b/nexus/db-queries/src/db/datastore/scim.rs index 17e0ce5467c..7dbfcbd8d22 100644 --- a/nexus/db-queries/src/db/datastore/scim.rs +++ b/nexus/db-queries/src/db/datastore/scim.rs @@ -13,6 +13,7 @@ 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; @@ -85,21 +86,23 @@ impl DataStore { &self, opctx: &OpContext, authz_silo: &authz::Silo, - id: Uuid, - ) -> LookupResult> { - opctx.authorize(authz::Action::ListChildren, authz_silo).await?; + 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(id)) + .filter(dsl::id.eq(authz_token.id())) .filter(dsl::time_deleted.is_null()) .select(ScimClientBearerToken::as_select()) .first_async::(&*conn) .await - .optional() .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))?; Ok(token) @@ -109,16 +112,19 @@ impl DataStore { &self, opctx: &OpContext, authz_silo: &authz::Silo, - id: Uuid, + token_id: Uuid, ) -> DeleteResult { - opctx.authorize(authz::Action::Modify, authz_silo).await?; + 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(id)) + .filter(dsl::id.eq(authz_token.id())) .filter(dsl::time_deleted.is_null()) .set(dsl::time_deleted.eq(Utc::now())) .execute_async(&*conn) diff --git a/nexus/src/app/scim.rs b/nexus/src/app/scim.rs index 25e6ad33b19..ad868cc8f73 100644 --- a/nexus/src/app/scim.rs +++ b/nexus/src/app/scim.rs @@ -58,18 +58,12 @@ impl super::Nexus { let (.., authz_silo, _) = silo_lookup.fetch_for(authz::Action::ListChildren).await?; - let maybe_token = self + let token = self .datastore() .scim_idp_get_token_by_id(opctx, &authz_silo, token_id) .await?; - match maybe_token { - Some(token) => Ok(token.into()), - - None => Err(Error::non_resourcetype_not_found(format!( - "token with id {token_id} not found" - ))), - } + Ok(token.into()) } pub(crate) async fn scim_idp_delete_token_by_id( diff --git a/nexus/tests/integration_tests/scim.rs b/nexus/tests/integration_tests/scim.rs index a66f7675ef3..0f0c505900a 100644 --- a/nexus/tests/integration_tests/scim.rs +++ b/nexus/tests/integration_tests/scim.rs @@ -10,8 +10,8 @@ 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::context::OpContext; use nexus_db_queries::db::model::ScimClientBearerToken; use nexus_test_utils::http_testing::{AuthnMode, NexusRequest, RequestBuilder}; use nexus_test_utils::resource_helpers::create_silo; @@ -23,6 +23,7 @@ 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 uuid::Uuid; @@ -216,11 +217,6 @@ async fn test_no_jit_for_saml_scim_silos(cptestctx: &ControlPlaneTestContext) { #[nexus_test] async fn test_scim_client_token_crud(cptestctx: &ControlPlaneTestContext) { let client = &cptestctx.external_client; - let nexus = &cptestctx.server.server_context().nexus; - let opctx = OpContext::for_tests( - cptestctx.logctx.log.new(o!()), - nexus.datastore().clone(), - ); // Create a Silo, then grant the PrivilegedUser the Admin role on it @@ -232,18 +228,16 @@ async fn test_scim_client_token_crud(cptestctx: &ControlPlaneTestContext) { client, &format!("/v1/system/silos/{SILO_NAME}"), shared::SiloRole::Admin, - opctx.authn.actor().unwrap().silo_user_id().unwrap(), + 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; + let tokens: Vec = + object_get(client, &format!("/v1/system/scim/tokens?silo={SILO_NAME}")) + .await; assert!(tokens.is_empty()); @@ -340,12 +334,6 @@ async fn test_scim_client_token_crud(cptestctx: &ControlPlaneTestContext) { #[nexus_test] async fn test_scim_client_token_tenancy(cptestctx: &ControlPlaneTestContext) { let client = &cptestctx.external_client; - let nexus = &cptestctx.server.server_context().nexus; - - let opctx = OpContext::for_tests( - cptestctx.logctx.log.new(o!()), - nexus.datastore().clone(), - ); // Create two Silos, then grant the PrivilegedUser the Admin role on both @@ -362,7 +350,7 @@ async fn test_scim_client_token_tenancy(cptestctx: &ControlPlaneTestContext) { client, &format!("/v1/system/silos/{SILO_1_NAME}"), shared::SiloRole::Admin, - opctx.authn.actor().unwrap().silo_user_id().unwrap(), + USER_TEST_PRIVILEGED.id(), AuthnMode::PrivilegedUser, ) .await; @@ -371,7 +359,7 @@ async fn test_scim_client_token_tenancy(cptestctx: &ControlPlaneTestContext) { client, &format!("/v1/system/silos/{SILO_2_NAME}"), shared::SiloRole::Admin, - opctx.authn.actor().unwrap().silo_user_id().unwrap(), + USER_TEST_PRIVILEGED.id(), AuthnMode::PrivilegedUser, ) .await; @@ -427,11 +415,6 @@ async fn test_scim_client_token_bearer_auth( cptestctx: &ControlPlaneTestContext, ) { let client = &cptestctx.external_client; - let nexus = &cptestctx.server.server_context().nexus; - let opctx = OpContext::for_tests( - cptestctx.logctx.log.new(o!()), - nexus.datastore().clone(), - ); // Create a Silo, then grant the PrivilegedUser the Admin role on it @@ -443,7 +426,7 @@ async fn test_scim_client_token_bearer_auth( client, &format!("/v1/system/silos/{SILO_NAME}"), shared::SiloRole::Admin, - opctx.authn.actor().unwrap().silo_user_id().unwrap(), + USER_TEST_PRIVILEGED.id(), AuthnMode::PrivilegedUser, ) .await; From 880fb63c7051d337427465b020b0ce17cd8cb9b6 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Fri, 17 Oct 2025 16:40:57 +0000 Subject: [PATCH 06/18] rename to scim_get_provider_from_bearer_token --- nexus/src/app/scim.rs | 38 +++++++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/nexus/src/app/scim.rs b/nexus/src/app/scim.rs index ad868cc8f73..1920dae50c1 100644 --- a/nexus/src/app/scim.rs +++ b/nexus/src/app/scim.rs @@ -86,7 +86,7 @@ impl super::Nexus { /// 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( + pub(crate) async fn scim_get_provider_from_bearer_token( &self, request: &dropshot::RequestInfo, ) -> LookupResult> { @@ -167,7 +167,8 @@ impl super::Nexus { request: &dropshot::RequestInfo, query: scim2_rs::QueryParams, ) -> Result, HttpError> { - let provider = self.scim_idp_get_provider(&request).await?; + let provider = + self.scim_get_provider_from_bearer_token(&request).await?; let result = match provider.list_users(query).await { Ok(response) => response.to_http_response(), @@ -183,7 +184,8 @@ impl super::Nexus { query: scim2_rs::QueryParams, user_id: String, ) -> Result, HttpError> { - let provider = self.scim_idp_get_provider(&request).await?; + let provider = + self.scim_get_provider_from_bearer_token(&request).await?; let result = match provider.get_user_by_id(query, &user_id).await { Ok(response) => response.to_http_response(StatusCode::OK), @@ -198,7 +200,8 @@ impl super::Nexus { request: &dropshot::RequestInfo, body: scim2_rs::CreateUserRequest, ) -> Result, HttpError> { - let provider = self.scim_idp_get_provider(&request).await?; + let provider = + self.scim_get_provider_from_bearer_token(&request).await?; let result = match provider.create_user(body).await { Ok(response) => response.to_http_response(StatusCode::CREATED), @@ -214,7 +217,8 @@ impl super::Nexus { user_id: String, body: scim2_rs::CreateUserRequest, ) -> Result, HttpError> { - let provider = self.scim_idp_get_provider(&request).await?; + let provider = + self.scim_get_provider_from_bearer_token(&request).await?; let result = match provider.replace_user(&user_id, body).await { Ok(response) => response.to_http_response(StatusCode::OK), @@ -230,7 +234,8 @@ impl super::Nexus { user_id: String, body: scim2_rs::PatchRequest, ) -> Result, HttpError> { - let provider = self.scim_idp_get_provider(&request).await?; + let provider = + self.scim_get_provider_from_bearer_token(&request).await?; let result = match provider.patch_user(&user_id, body).await { Ok(response) => response.to_http_response(StatusCode::OK), @@ -245,7 +250,8 @@ impl super::Nexus { request: &dropshot::RequestInfo, user_id: String, ) -> Result, HttpError> { - let provider = self.scim_idp_get_provider(&request).await?; + let provider = + self.scim_get_provider_from_bearer_token(&request).await?; let result = match provider.delete_user(&user_id).await { Ok(response) => Ok(response), @@ -260,7 +266,8 @@ impl super::Nexus { request: &dropshot::RequestInfo, query: scim2_rs::QueryParams, ) -> Result, HttpError> { - let provider = self.scim_idp_get_provider(&request).await?; + let provider = + self.scim_get_provider_from_bearer_token(&request).await?; let result = match provider.list_groups(query).await { Ok(response) => response.to_http_response(), @@ -276,7 +283,8 @@ impl super::Nexus { query: scim2_rs::QueryParams, group_id: String, ) -> Result, HttpError> { - let provider = self.scim_idp_get_provider(&request).await?; + let provider = + self.scim_get_provider_from_bearer_token(&request).await?; let result = match provider.get_group_by_id(query, &group_id).await { Ok(response) => response.to_http_response(StatusCode::OK), @@ -291,7 +299,8 @@ impl super::Nexus { request: &dropshot::RequestInfo, body: scim2_rs::CreateGroupRequest, ) -> Result, HttpError> { - let provider = self.scim_idp_get_provider(&request).await?; + let provider = + self.scim_get_provider_from_bearer_token(&request).await?; let result = match provider.create_group(body).await { Ok(response) => response.to_http_response(StatusCode::CREATED), @@ -307,7 +316,8 @@ impl super::Nexus { group_id: String, body: scim2_rs::CreateGroupRequest, ) -> Result, HttpError> { - let provider = self.scim_idp_get_provider(&request).await?; + let provider = + self.scim_get_provider_from_bearer_token(&request).await?; let result = match provider.replace_group(&group_id, body).await { Ok(response) => response.to_http_response(StatusCode::OK), @@ -323,7 +333,8 @@ impl super::Nexus { group_id: String, body: scim2_rs::PatchRequest, ) -> Result, HttpError> { - let provider = self.scim_idp_get_provider(&request).await?; + let provider = + self.scim_get_provider_from_bearer_token(&request).await?; let result = match provider.patch_group(&group_id, body).await { Ok(response) => response.to_http_response(StatusCode::OK), @@ -338,7 +349,8 @@ impl super::Nexus { request: &dropshot::RequestInfo, group_id: String, ) -> Result, HttpError> { - let provider = self.scim_idp_get_provider(&request).await?; + let provider = + self.scim_get_provider_from_bearer_token(&request).await?; let result = match provider.delete_group(&group_id).await { Ok(response) => Ok(response), From 76b452e3cc959a8300e82d4dfcc1de272930fd41 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Fri, 17 Oct 2025 16:41:39 +0000 Subject: [PATCH 07/18] remove list-children grant for external-scim, not necessary --- nexus/auth/src/authz/omicron.polar | 3 --- 1 file changed, 3 deletions(-) diff --git a/nexus/auth/src/authz/omicron.polar b/nexus/auth/src/authz/omicron.polar index a7a29bfbf8e..c60d6f55641 100644 --- a/nexus/auth/src/authz/omicron.polar +++ b/nexus/auth/src/authz/omicron.polar @@ -150,9 +150,6 @@ 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) From 5cebf49b55aa965697412a8ad3ee94c7cc88df11 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Fri, 17 Oct 2025 16:42:00 +0000 Subject: [PATCH 08/18] just fetch --- nexus/src/app/scim.rs | 17 ++++++++--------- nexus/src/external_api/http_entrypoints.rs | 2 +- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/nexus/src/app/scim.rs b/nexus/src/app/scim.rs index 1920dae50c1..bba003b3488 100644 --- a/nexus/src/app/scim.rs +++ b/nexus/src/app/scim.rs @@ -11,7 +11,6 @@ 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; @@ -30,10 +29,11 @@ impl super::Nexus { opctx: &OpContext, silo_lookup: &lookup::Silo<'_>, ) -> ListResultVec { - let (.., authz_silo, _) = - silo_lookup.fetch_for(authz::Action::ListChildren).await?; + let (.., authz_silo, _) = silo_lookup.fetch().await?; + let tokens = self.datastore().scim_idp_get_tokens(opctx, &authz_silo).await?; + Ok(tokens.into_iter().map(|t| t.into()).collect()) } @@ -42,10 +42,11 @@ impl super::Nexus { opctx: &OpContext, silo_lookup: &lookup::Silo<'_>, ) -> CreateResult { - let (.., authz_silo, _) = - silo_lookup.fetch_for(authz::Action::ListChildren).await?; + let (.., authz_silo, _) = silo_lookup.fetch().await?; + let token = self.datastore().scim_idp_create_token(opctx, &authz_silo).await?; + Ok(token.into()) } @@ -55,8 +56,7 @@ impl super::Nexus { silo_lookup: &lookup::Silo<'_>, token_id: Uuid, ) -> LookupResult { - let (.., authz_silo, _) = - silo_lookup.fetch_for(authz::Action::ListChildren).await?; + let (.., authz_silo, _) = silo_lookup.fetch().await?; let token = self .datastore() @@ -72,8 +72,7 @@ impl super::Nexus { silo_lookup: &lookup::Silo<'_>, token_id: Uuid, ) -> DeleteResult { - let (.., authz_silo, _) = - silo_lookup.fetch_for(authz::Action::ListChildren).await?; + let (.., authz_silo, _) = silo_lookup.fetch().await?; self.datastore() .scim_idp_delete_token_by_id(opctx, &authz_silo, token_id) diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 48d5818107a..307f23f1600 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -1042,7 +1042,7 @@ impl NexusExternalApi for NexusExternalApiImpl { // SCIM operations are authenticated by resolving a token (that does // _not_ resolve to any Actor) to a silo-specific SCIM server // implementation. There isn't any opctx to use, so the "external - // authentication" one is used here for audit purposes. + // scim" one is used here for audit purposes. let opctx = nexus.opctx_external_scim(); let audit = nexus.audit_log_entry_init_unauthed(&opctx, &rqctx).await?; From 3b3b891908cdfe3a4a42041e3902f157625d1e19 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Sat, 18 Oct 2025 03:23:48 +0000 Subject: [PATCH 09/18] add new Actor::Scim variant create opctx that uses that actor create new authn scheme for "Bearer oxide-scim-... make opctx.authorize calls make more sense remove external-scim create synthetic resource ScimClientBearerTokenList for tighter permission structure: - external-auth cannot list children on ScimClientBearerTokenList for example --- nexus-config/src/nexus_config.rs | 3 + nexus/auth/src/authn/external/mod.rs | 1 + nexus/auth/src/authn/external/scim.rs | 99 +++++++++++ nexus/auth/src/authn/mod.rs | 54 +++--- nexus/auth/src/authz/actor.rs | 24 ++- nexus/auth/src/authz/api_resources.rs | 52 ++++++ nexus/auth/src/authz/omicron.polar | 72 ++++---- nexus/auth/src/authz/oso_generic.rs | 1 + nexus/auth/src/authz/roles.rs | 26 +-- nexus/auth/src/context.rs | 8 + nexus/db-fixed-data/src/role_assignment.rs | 8 - nexus/db-fixed-data/src/user_builtin.rs | 12 -- nexus/db-lookup/src/lookup.rs | 10 +- nexus/db-model/src/audit_log.rs | 13 ++ .../src/db/datastore/device_auth.rs | 3 +- nexus/db-queries/src/db/datastore/scim.rs | 65 +++++-- .../db-queries/src/db/datastore/silo_user.rs | 1 - .../src/policy_test/resource_builder.rs | 20 +++ nexus/db-queries/src/policy_test/resources.rs | 1 + nexus/examples/config-second.toml | 2 +- nexus/examples/config.toml | 2 +- nexus/src/app/audit_log.rs | 6 +- nexus/src/app/mod.rs | 11 -- nexus/src/app/scim.rs | 163 ++++++++---------- nexus/src/context.rs | 13 ++ nexus/src/external_api/http_entrypoints.rs | 113 ++++++------ nexus/tests/config.test.toml | 2 +- nexus/tests/integration_tests/authn_http.rs | 2 + nexus/tests/integration_tests/scim.rs | 62 ++++++- .../tests/integration_tests/users_builtin.rs | 3 - nexus/types/src/external_api/views.rs | 4 + schema/crdb/dbinit.sql | 3 +- schema/crdb/scim-client-bearer-token/up04.sql | 6 + smf/nexus/multi-sled/config-partial.toml | 2 +- smf/nexus/single-sled/config-partial.toml | 2 +- 35 files changed, 584 insertions(+), 285 deletions(-) create mode 100644 nexus/auth/src/authn/external/scim.rs create mode 100644 schema/crdb/scim-client-bearer-token/up04.sql diff --git a/nexus-config/src/nexus_config.rs b/nexus-config/src/nexus_config.rs index be98e15f3b3..588374407b0 100644 --- a/nexus-config/src/nexus_config.rs +++ b/nexus-config/src/nexus_config.rs @@ -922,6 +922,7 @@ pub enum SchemeName { Spoof, SessionCookie, AccessToken, + ScimToken, } impl std::str::FromStr for SchemeName { @@ -932,6 +933,7 @@ impl std::str::FromStr for SchemeName { "spoof" => Ok(SchemeName::Spoof), "session_cookie" => Ok(SchemeName::SessionCookie), "access_token" => Ok(SchemeName::AccessToken), + "scim_token" => Ok(SchemeName::ScimToken), _ => Err(anyhow!("unsupported authn scheme: {:?}", s)), } } @@ -943,6 +945,7 @@ impl std::fmt::Display for SchemeName { SchemeName::Spoof => "spoof", SchemeName::SessionCookie => "session_cookie", SchemeName::AccessToken => "access_token", + SchemeName::ScimToken => "scim", }) } } diff --git a/nexus/auth/src/authn/external/mod.rs b/nexus/auth/src/authn/external/mod.rs index f420b690673..e951819830e 100644 --- a/nexus/auth/src/authn/external/mod.rs +++ b/nexus/auth/src/authn/external/mod.rs @@ -15,6 +15,7 @@ use slog::trace; use std::borrow::Borrow; use uuid::Uuid; +pub mod scim; pub mod session_cookie; pub mod spoof; pub mod token; diff --git a/nexus/auth/src/authn/external/scim.rs b/nexus/auth/src/authn/external/scim.rs new file mode 100644 index 00000000000..d8b502f17ba --- /dev/null +++ b/nexus/auth/src/authn/external/scim.rs @@ -0,0 +1,99 @@ +// 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/. + +//! SCIM-only bearer tokens + +use super::super::Details; +use super::HttpAuthnScheme; +use super::Reason; +use super::SchemeResult; +use crate::authn; +use async_trait::async_trait; +use headers::HeaderMapExt; +use headers::authorization::{Authorization, Bearer}; + +// This scheme is intended only for SCIM provisioning clients. +// +// For ease of integration into existing clients, we use RFC 6750 bearer tokens. +// This mechanism in turn uses HTTP's "Authorization" header. In practice, it +// looks like this: +// +// Authorization: Bearer oxide-scim-01c90c58085fed6a230d137b9b9b5e7501d0a523 +// ^^^^^^^^^^^^^ ^^^^^^ ^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +// | | | | +// | | | +--- specifies the token itself +// | | +--------------- specifies this "token" mechanism +// | +---------------------- specifies RFC 6750 bearer tokens +// +------------------------------------- standard HTTP authentication hdr +// +// (That's not a typo -- the "authorization" header is generally used to specify +// _authentication_ information. Similarly, the "Unauthorized" HTTP response +// code usually describes an _authentication_ error.) + +pub const SCIM_TOKEN_SCHEME_NAME: authn::SchemeName = + authn::SchemeName("scim_token"); + +/// Prefix used on the bearer token to identify this scheme +// RFC 6750 expects bearer tokens to be opaque base64-encoded data. In our case, +// the data we want to represent (this prefix, plus valid tokens) are subsets of +// the base64 character set, so we do not bother encoding them. +const TOKEN_PREFIX: &str = "oxide-scim-"; + +/// Implements a SCIM provisioning client specific bearer-token-based +/// authentication scheme. +#[derive(Debug)] +pub struct HttpAuthnScimToken; + +#[async_trait] +impl HttpAuthnScheme for HttpAuthnScimToken +where + T: ScimTokenContext + Send + Sync + 'static, +{ + fn name(&self) -> authn::SchemeName { + SCIM_TOKEN_SCHEME_NAME + } + + async fn authn( + &self, + ctx: &T, + _log: &slog::Logger, + request: &dropshot::RequestInfo, + ) -> SchemeResult { + let headers = request.headers(); + match parse_token(headers.typed_get().as_ref()) { + Err(error) => SchemeResult::Failed(error), + Ok(None) => SchemeResult::NotRequested, + Ok(Some(token)) => match ctx.scim_token_actor(token).await { + Err(error) => SchemeResult::Failed(error), + Ok(actor) => SchemeResult::Authenticated(Details { actor }), + }, + } + } +} + +fn parse_token( + raw_value: Option<&Authorization>, +) -> Result, Reason> { + let token = match raw_value { + None => return Ok(None), + Some(bearer) => bearer.token(), + }; + + if !token.starts_with(TOKEN_PREFIX) { + // This is some other kind of bearer token. Maybe another scheme knows + // how to deal with it. + return Ok(None); + } + + Ok(Some(token[TOKEN_PREFIX.len()..].to_string())) +} + +/// A context that can look up a Actor::Scim from a token. +#[async_trait] +pub trait ScimTokenContext { + async fn scim_token_actor( + &self, + token: String, + ) -> Result; +} diff --git a/nexus/auth/src/authn/mod.rs b/nexus/auth/src/authn/mod.rs index c1469df0f7d..ff955fb1619 100644 --- a/nexus/auth/src/authn/mod.rs +++ b/nexus/auth/src/authn/mod.rs @@ -32,7 +32,6 @@ pub use nexus_db_fixed_data::silo_user::USER_TEST_PRIVILEGED; pub use nexus_db_fixed_data::silo_user::USER_TEST_UNPRIVILEGED; pub use nexus_db_fixed_data::user_builtin::USER_DB_INIT; pub use nexus_db_fixed_data::user_builtin::USER_EXTERNAL_AUTHN; -pub use nexus_db_fixed_data::user_builtin::USER_EXTERNAL_SCIM; pub use nexus_db_fixed_data::user_builtin::USER_INTERNAL_API; pub use nexus_db_fixed_data::user_builtin::USER_INTERNAL_READ; pub use nexus_db_fixed_data::user_builtin::USER_SAGA_RECOVERY; @@ -46,6 +45,7 @@ use nexus_types::external_api::shared::SiloRole; use nexus_types::identity::Asset; use omicron_common::api::external::LookupType; use omicron_uuid_kinds::BuiltInUserUuid; +use omicron_uuid_kinds::GenericUuid; use omicron_uuid_kinds::SiloUserUuid; use serde::Deserialize; use serde::Serialize; @@ -126,6 +126,8 @@ impl Context { /// Built-in users have no Silo, and so they usually can't do anything that /// might use a Silo. You usually want to use [`Context::silo_required()`] /// if you don't expect to be looking at a built-in user. + /// + /// Additionally, non-user Actors may also be associated with a Silo. pub fn silo_or_builtin( &self, ) -> Result, omicron_common::api::external::Error> { @@ -136,6 +138,11 @@ impl Context { LookupType::ById(*silo_id), )), Actor::UserBuiltin { .. } => None, + Actor::Scim { silo_id } => Some(authz::Silo::new( + authz::FLEET, + *silo_id, + LookupType::ById(*silo_id), + )), }) } @@ -200,12 +207,6 @@ impl Context { Context::context_for_builtin_user(USER_SERVICE_BALANCER.id) } - /// Returns an authenticated context for use for authenticating SCIM - /// requests - pub fn external_scim() -> Context { - Context::context_for_builtin_user(USER_EXTERNAL_SCIM.id) - } - fn context_for_builtin_user(user_builtin_id: BuiltInUserUuid) -> Context { Context { kind: Kind::Authenticated( @@ -307,7 +308,6 @@ mod test { use super::USER_TEST_PRIVILEGED; use super::USER_TEST_UNPRIVILEGED; use nexus_db_fixed_data::user_builtin::USER_EXTERNAL_AUTHN; - use nexus_db_fixed_data::user_builtin::USER_EXTERNAL_SCIM; use nexus_types::identity::Asset; #[test] @@ -350,10 +350,6 @@ mod test { let authn = Context::internal_api(); let actor = authn.actor().unwrap(); assert_eq!(actor.built_in_user_id(), Some(USER_INTERNAL_API.id)); - - let authn = Context::external_scim(); - let actor = authn.actor().unwrap(); - assert_eq!(actor.built_in_user_id(), Some(USER_EXTERNAL_SCIM.id)); } } @@ -382,6 +378,7 @@ pub struct Details { pub enum Actor { UserBuiltin { user_builtin_id: BuiltInUserUuid }, SiloUser { silo_user_id: SiloUserUuid, silo_id: Uuid }, + Scim { silo_id: Uuid }, } impl Actor { @@ -389,6 +386,7 @@ impl Actor { match self { Actor::UserBuiltin { .. } => None, Actor::SiloUser { silo_id, .. } => Some(*silo_id), + Actor::Scim { .. } => None, // XXX scim actor does have a silo id? } } @@ -396,6 +394,7 @@ impl Actor { match self { Actor::UserBuiltin { .. } => None, Actor::SiloUser { silo_user_id, .. } => Some(*silo_user_id), + Actor::Scim { .. } => None, } } @@ -403,17 +402,28 @@ impl Actor { match self { Actor::UserBuiltin { user_builtin_id } => Some(*user_builtin_id), Actor::SiloUser { .. } => None, + Actor::Scim { .. } => None, } } -} -impl From<&Actor> for nexus_db_model::IdentityType { - fn from(actor: &Actor) -> nexus_db_model::IdentityType { - match actor { - Actor::UserBuiltin { .. } => { - nexus_db_model::IdentityType::UserBuiltin - } - Actor::SiloUser { .. } => nexus_db_model::IdentityType::SiloUser, + /// Return a generic UUID and db-model IdentityType for use with looking up + /// role assignments, or None if a role assignment for this type of Actor is + /// invalid. + pub fn id_and_type_for_role_assignment( + &self, + ) -> Option<(Uuid, nexus_db_model::IdentityType)> { + match &self { + Actor::UserBuiltin { user_builtin_id } => Some(( + user_builtin_id.into_untyped_uuid(), + nexus_db_model::IdentityType::UserBuiltin, + )), + Actor::SiloUser { silo_user_id, .. } => Some(( + silo_user_id.into_untyped_uuid(), + nexus_db_model::IdentityType::SiloUser, + )), + // a role assignment for this Actor is invalid, they have a fixed + // policy. + Actor::Scim { .. } => None, } } } @@ -437,6 +447,10 @@ impl std::fmt::Debug for Actor { .field("silo_user_id", &silo_user_id) .field("silo_id", &silo_id) .finish_non_exhaustive(), + Actor::Scim { silo_id } => f + .debug_struct("Actor::Scim") + .field("silo_id", &silo_id) + .finish_non_exhaustive(), } } } diff --git a/nexus/auth/src/authz/actor.rs b/nexus/auth/src/authz/actor.rs index 26f7458b3b8..f0f409de090 100644 --- a/nexus/auth/src/authz/actor.rs +++ b/nexus/auth/src/authz/actor.rs @@ -122,15 +122,23 @@ impl oso::PolarClass for AuthenticatedActor { }, "USER_INTERNAL_API", ) + .add_attribute_getter("is_user", |a: &AuthenticatedActor| { + match a.actor { + authn::Actor::SiloUser { .. } => true, + + authn::Actor::UserBuiltin { .. } => true, + + authn::Actor::Scim { .. } => false, + } + }) .add_attribute_getter("silo", |a: &AuthenticatedActor| { match a.actor { - authn::Actor::SiloUser { silo_id, .. } => { - Some(super::Silo::new( - super::FLEET, - silo_id, - LookupType::ById(silo_id), - )) - } + authn::Actor::SiloUser { silo_id, .. } + | authn::Actor::Scim { silo_id } => Some(super::Silo::new( + super::FLEET, + silo_id, + LookupType::ById(silo_id), + )), authn::Actor::UserBuiltin { .. } => None, } @@ -149,6 +157,8 @@ impl oso::PolarClass for AuthenticatedActor { } authn::Actor::UserBuiltin { .. } => false, + + authn::Actor::Scim { .. } => false, }, ) } diff --git a/nexus/auth/src/authz/api_resources.rs b/nexus/auth/src/authz/api_resources.rs index 817212ee762..415b5507ae6 100644 --- a/nexus/auth/src/authz/api_resources.rs +++ b/nexus/auth/src/authz/api_resources.rs @@ -1015,6 +1015,58 @@ impl AuthorizedResource for AlertClassList { } } +/// Synthetic resource describing the list of SCIM client bearer tokens +/// associated with a Silo +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ScimClientBearerTokenList(Silo); + +impl ScimClientBearerTokenList { + pub fn new(silo: Silo) -> ScimClientBearerTokenList { + ScimClientBearerTokenList(silo) + } + + pub fn silo(&self) -> &Silo { + &self.0 + } +} + +impl oso::PolarClass for ScimClientBearerTokenList { + fn get_polar_class_builder() -> oso::ClassBuilder { + oso::Class::builder() + .with_equality_check() + .add_attribute_getter("silo", |list: &ScimClientBearerTokenList| { + list.0.clone() + }) + } +} + +impl AuthorizedResource for ScimClientBearerTokenList { + fn load_roles<'fut>( + &'fut self, + opctx: &'fut OpContext, + authn: &'fut authn::Context, + roleset: &'fut mut RoleSet, + ) -> futures::future::BoxFuture<'fut, Result<(), Error>> { + // There are no roles on this resource, but we still need to load the + // Silo-related roles. + self.silo().load_roles(opctx, authn, roleset) + } + + fn on_unauthorized( + &self, + _: &Authz, + error: Error, + _: AnyActor, + _: Action, + ) -> Error { + error + } + + fn polar_class(&self) -> oso::Class { + Self::get_polar_class() + } +} + // Main resource hierarchy: Projects and their resources authz_resource! { diff --git a/nexus/auth/src/authz/omicron.polar b/nexus/auth/src/authz/omicron.polar index c60d6f55641..b1384587b16 100644 --- a/nexus/auth/src/authz/omicron.polar +++ b/nexus/auth/src/authz/omicron.polar @@ -97,8 +97,7 @@ resource Fleet { "viewer", # Internal-only roles - "external-authenticator", - "external-scim" + "external-authenticator" ]; # Roles implied by other roles on this resource @@ -113,6 +112,8 @@ resource Fleet { } # For fleets specifically, roles can be conferred by roles on the user's Silo. +# Note that certain Actors may not ever have any roles assigned to them, like +# SCIM Actors. has_role(actor: AuthenticatedActor, role: String, _: Fleet) if silo_role in actor.confers_fleet_role(role) and has_role(actor, silo_role, actor.silo.unwrap()); @@ -174,7 +175,7 @@ has_relation(fleet: Fleet, "parent_fleet", silo: Silo) # # It's unclear what else would break if users couldn't see their own Silo. has_permission(actor: AuthenticatedActor, "read", silo: Silo) - if silo in actor.silo; + if actor.is_user and silo in actor.silo; resource Project { permissions = [ @@ -256,7 +257,7 @@ 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; + if actor.is_user and silo_user.silo in actor.silo; resource SiloGroup { permissions = [ @@ -337,6 +338,27 @@ has_relation(silo: Silo, "parent_silo", saml_identity_provider: SamlIdentityProv has_relation(fleet: Fleet, "parent_fleet", collection: SamlIdentityProvider) if collection.silo.fleet = fleet; +resource ScimClientBearerToken { + permissions = [ "read", "modify" ]; + relations = { parent_silo: Silo, parent_fleet: Fleet }; + + # necessary to authenticate SCIM actors + "read" if "external-authenticator" on "parent_fleet"; + + # Silo-level roles grant privileges for SCIM client tokens. + "read" if "admin" on "parent_silo"; + "modify" if "admin" on "parent_silo"; + + # Fleet-level roles also grant privileges for SCIM client tokens. + "read" if "admin" on "parent_fleet"; + "modify" 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; + + # # SYNTHETIC RESOURCES OUTSIDE THE SILO HIERARCHY # @@ -454,7 +476,7 @@ has_relation(fleet: Fleet, "parent_fleet", ip_pool_list: IpPoolList) # Any authenticated user can create a child of a provided IP Pool. # This is necessary to use the pools when provisioning instances. has_permission(actor: AuthenticatedActor, "create_child", ip_pool: IpPool) - if silo in actor.silo and silo.fleet = ip_pool.fleet; + if actor.is_user and silo in actor.silo and silo.fleet = ip_pool.fleet; # Describes the policy for reading and writing the audit log resource AuditLog { @@ -632,8 +654,10 @@ has_permission(actor: AuthenticatedActor, "modify", session: ConsoleSession) # even Silo) the device auth is associated with. Any user can claim a device # auth request with the right user code (that's how it works) -- it's the user # code and associated logic that prevents unauthorized access here. -has_permission(_actor: AuthenticatedActor, "read", _device_auth: DeviceAuthRequest); -has_permission(_actor: AuthenticatedActor, "modify", _device_auth: DeviceAuthRequest); +has_permission(actor: AuthenticatedActor, "read", _device_auth: DeviceAuthRequest) + if actor.is_user; +has_permission(actor: AuthenticatedActor, "modify", _device_auth: DeviceAuthRequest) + if actor.is_user; has_permission(actor: AuthenticatedActor, "read", device_token: DeviceAccessToken) if has_role(actor, "external-authenticator", device_token.fleet); @@ -705,34 +729,22 @@ 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", - ]; +resource ScimClientBearerTokenList { + permissions = [ "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"; + # These are all admin because being able to create these tokens would allow + # a user to grant themselves admin by modifying group membership through SCIM calls "create_child" if "admin" on "parent_silo"; + "list_children" 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"; + # Fleet-level roles also grant privileges for SCIM client tokens, for + # configuration before silo admins are present. "create_child" if "admin" on "parent_fleet"; + "list_children" 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) +has_relation(silo: Silo, "parent_silo", scim_client_bearer_token_list: ScimClientBearerTokenList) + if scim_client_bearer_token_list.silo = silo; +has_relation(fleet: Fleet, "parent_fleet", collection: ScimClientBearerTokenList) if collection.silo.fleet = fleet; diff --git a/nexus/auth/src/authz/oso_generic.rs b/nexus/auth/src/authz/oso_generic.rs index 6d80a7eff23..86e94a224e4 100644 --- a/nexus/auth/src/authz/oso_generic.rs +++ b/nexus/auth/src/authz/oso_generic.rs @@ -121,6 +121,7 @@ pub fn make_omicron_oso(log: &slog::Logger) -> Result { UpdateTrustRootList::get_polar_class(), TargetReleaseConfig::get_polar_class(), AlertClassList::get_polar_class(), + ScimClientBearerTokenList::get_polar_class(), ]; for c in classes { oso_builder = oso_builder.register_class(c)?; diff --git a/nexus/auth/src/authz/roles.rs b/nexus/auth/src/authz/roles.rs index 114da7e3baf..6c12754670f 100644 --- a/nexus/auth/src/authz/roles.rs +++ b/nexus/auth/src/authz/roles.rs @@ -39,7 +39,6 @@ use crate::authn; use crate::context::OpContext; use omicron_common::api::external::Error; use omicron_common::api::external::ResourceType; -use omicron_uuid_kinds::GenericUuid; use slog::trace; use std::collections::BTreeSet; use uuid::Uuid; @@ -156,19 +155,26 @@ async fn load_directly_attached_roles( "resource_id" => resource_id.to_string(), ); + let Some((identity_id, identity_type)) = + actor.id_and_type_for_role_assignment() + else { + trace!( + opctx.log, + "actor cannot have roles"; + "actor" => ?actor, + "resource_type" => ?resource_type, + "resource_id" => resource_id.to_string(), + ); + // XXX Ok, or an error? + return Ok(()); + }; + let roles = opctx .datastore() .role_asgn_list_for( opctx, - actor.into(), - match &actor { - authn::Actor::SiloUser { silo_user_id, .. } => { - silo_user_id.into_untyped_uuid() - } - authn::Actor::UserBuiltin { user_builtin_id, .. } => { - user_builtin_id.into_untyped_uuid() - } - }, + identity_type, + identity_id, resource_type, resource_id, ) diff --git a/nexus/auth/src/context.rs b/nexus/auth/src/context.rs index 8f666cbb0e2..797f80c2778 100644 --- a/nexus/auth/src/context.rs +++ b/nexus/auth/src/context.rs @@ -136,6 +136,7 @@ impl OpContext { authn::Actor::SiloUser { silo_user_id, silo_id } => { log.new(o!( "authenticated" => true, + "type" => "silo_user", "silo_user_id" => silo_user_id.to_string(), "silo_id" => silo_id.to_string(), )) @@ -143,8 +144,15 @@ impl OpContext { authn::Actor::UserBuiltin { user_builtin_id } => log.new(o!( "authenticated" => true, + "type" => "user_builtin", "user_builtin_id" => user_builtin_id.to_string(), )), + + authn::Actor::Scim { silo_id } => log.new(o!( + "authenticated" => true, + "type" => "scim", + "silo_id" => silo_id.to_string(), + )), } } else { metadata diff --git a/nexus/db-fixed-data/src/role_assignment.rs b/nexus/db-fixed-data/src/role_assignment.rs index f91094c4ef1..aae06d4b7ae 100644 --- a/nexus/db-fixed-data/src/role_assignment.rs +++ b/nexus/db-fixed-data/src/role_assignment.rs @@ -53,13 +53,5 @@ pub static BUILTIN_ROLE_ASSIGNMENTS: LazyLock> = *FLEET_ID, "external-authenticator", ), - // The "external-scim" user gets the "external-scim" role on the - // sole fleet. This grants them the ability to read SCIM tokens. - RoleAssignment::new_for_builtin_user( - user_builtin::USER_EXTERNAL_SCIM.id, - ResourceType::Fleet, - *FLEET_ID, - "external-scim", - ), ] }); diff --git a/nexus/db-fixed-data/src/user_builtin.rs b/nexus/db-fixed-data/src/user_builtin.rs index ba9fdca769c..1194fe23a53 100644 --- a/nexus/db-fixed-data/src/user_builtin.rs +++ b/nexus/db-fixed-data/src/user_builtin.rs @@ -94,22 +94,11 @@ pub static USER_EXTERNAL_AUTHN: LazyLock = ) }); -/// Internal user used by Nexus when authenticating SCIM requests -pub static USER_EXTERNAL_SCIM: LazyLock = - LazyLock::new(|| { - UserBuiltinConfig::new_static( - "001de000-05e4-4000-8000-000000000004", - "external-scim", - "used by Nexus when authenticating SCIM requests", - ) - }); - #[cfg(test)] mod test { use super::super::assert_valid_typed_uuid; use super::USER_DB_INIT; use super::USER_EXTERNAL_AUTHN; - use super::USER_EXTERNAL_SCIM; use super::USER_INTERNAL_API; use super::USER_INTERNAL_READ; use super::USER_SAGA_RECOVERY; @@ -123,6 +112,5 @@ mod test { assert_valid_typed_uuid(&USER_EXTERNAL_AUTHN.id); assert_valid_typed_uuid(&USER_INTERNAL_READ.id); assert_valid_typed_uuid(&USER_SAGA_RECOVERY.id); - assert_valid_typed_uuid(&USER_EXTERNAL_SCIM.id); } } diff --git a/nexus/db-lookup/src/lookup.rs b/nexus/db-lookup/src/lookup.rs index a8a5d79029b..6fc641a5044 100644 --- a/nexus/db-lookup/src/lookup.rs +++ b/nexus/db-lookup/src/lookup.rs @@ -262,9 +262,11 @@ impl<'a> LookupPath<'a> { SiloUser::PrimaryKey(Root { lookup_root: self }, *silo_user_id), ), - authn::Actor::UserBuiltin { .. } => Err( - Error::non_resourcetype_not_found("could not find silo user"), - ), + authn::Actor::UserBuiltin { .. } | authn::Actor::Scim { .. } => { + Err(Error::non_resourcetype_not_found( + "could not find silo user", + )) + } } } @@ -927,7 +929,7 @@ lookup_resource! { lookup_by_name = false, soft_deletes = true, primary_key_columns = [ { column_name = "id", rust_type = Uuid } ], - visible_outside_silo = true + visible_outside_silo = true // XXX needed? } // Helpers for unifying the interfaces around images diff --git a/nexus/db-model/src/audit_log.rs b/nexus/db-model/src/audit_log.rs index 6541f64516e..5708fe7359a 100644 --- a/nexus/db-model/src/audit_log.rs +++ b/nexus/db-model/src/audit_log.rs @@ -24,6 +24,7 @@ use uuid::Uuid; pub enum AuditLogActor { UserBuiltin { user_builtin_id: BuiltInUserUuid }, SiloUser { silo_user_id: SiloUserUuid, silo_id: Uuid }, + Scim { silo_id: Uuid }, Unauthenticated, } @@ -60,6 +61,7 @@ impl_enum_type!( UserBuiltin => b"user_builtin" SiloUser => b"silo_user" Unauthenticated => b"unauthenticated" + Scim => b"scim" ); impl_enum_type!( @@ -139,6 +141,9 @@ impl From for AuditLogEntryInit { Some(silo_id), AuditLogActorKind::SiloUser, ), + AuditLogActor::Scim { silo_id } => { + (None, Some(silo_id), AuditLogActorKind::Scim) + } AuditLogActor::Unauthenticated => { (None, None, AuditLogActorKind::Unauthenticated) } @@ -303,6 +308,14 @@ impl TryFrom for views::AuditLogEntry { silo_id, } } + AuditLogActorKind::Scim => { + let silo_id = entry.actor_silo_id.ok_or_else(|| { + Error::internal_error( + "Scim actor missing actor_silo_id", + ) + })?; + views::AuditLogEntryActor::Scim { silo_id } + } AuditLogActorKind::Unauthenticated => { views::AuditLogEntryActor::Unauthenticated } diff --git a/nexus/db-queries/src/db/datastore/device_auth.rs b/nexus/db-queries/src/db/datastore/device_auth.rs index c49042c7f81..07e7fbc31df 100644 --- a/nexus/db-queries/src/db/datastore/device_auth.rs +++ b/nexus/db-queries/src/db/datastore/device_auth.rs @@ -25,7 +25,6 @@ use omicron_common::api::external::ListResultVec; use omicron_common::api::external::LookupResult; use omicron_common::api::external::LookupType; use omicron_common::api::external::ResourceType; -use omicron_uuid_kinds::GenericUuid; use uuid::Uuid; impl DataStore { @@ -49,7 +48,7 @@ impl DataStore { let authz_token = authz::DeviceAccessToken::new( authz::FLEET, db_token.id(), - LookupType::ById(db_token.id().into_untyped_uuid()), + LookupType::by_id(db_token.id()), ); // This check might seem superfluous, but (for now at least) only the diff --git a/nexus/db-queries/src/db/datastore/scim.rs b/nexus/db-queries/src/db/datastore/scim.rs index 7dbfcbd8d22..751a75f5fff 100644 --- a/nexus/db-queries/src/db/datastore/scim.rs +++ b/nexus/db-queries/src/db/datastore/scim.rs @@ -18,6 +18,7 @@ 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 omicron_common::api::external::LookupType; use rand::{RngCore, SeedableRng, rngs::StdRng}; use uuid::Uuid; @@ -37,7 +38,14 @@ impl DataStore { opctx: &OpContext, authz_silo: &authz::Silo, ) -> ListResultVec { - opctx.authorize(authz::Action::ListChildren, authz_silo).await?; + let authz_scim_client_bearer_token_list = + authz::ScimClientBearerTokenList::new(authz_silo.clone()); + opctx + .authorize( + authz::Action::ListChildren, + &authz_scim_client_bearer_token_list, + ) + .await?; let conn = self.pool_connection_authorized(opctx).await?; @@ -58,7 +66,14 @@ impl DataStore { opctx: &OpContext, authz_silo: &authz::Silo, ) -> CreateResult { - opctx.authorize(authz::Action::CreateChild, authz_silo).await?; + let authz_scim_client_bearer_token_list = + authz::ScimClientBearerTokenList::new(authz_silo.clone()); + opctx + .authorize( + authz::Action::CreateChild, + &authz_scim_client_bearer_token_list, + ) + .await?; let conn = self.pool_connection_authorized(opctx).await?; @@ -134,24 +149,44 @@ impl DataStore { 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( + pub async fn scim_lookup_token_by_bearer( &self, + opctx: &OpContext, bearer_token: String, ) -> LookupResult> { - let conn = self.pool_connection_unauthorized().await?; + let conn = self.pool_connection_authorized(opctx).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))?; + let maybe_token: Option = + dsl::scim_client_bearer_token + .filter(dsl::bearer_token.eq(bearer_token)) + .filter(dsl::time_deleted.is_null()) + .select(ScimClientBearerToken::as_select()) + .first_async(&*conn) + .await + .optional() + .map_err(|e| { + public_error_from_diesel(e, ErrorHandler::Server) + })?; + + let Some(token) = maybe_token else { + return Ok(None); + }; - Ok(maybe_token) + // we have to construct the authz resource after the lookup because we + // don't have its ID on hand until then + let authz_token = authz::ScimClientBearerToken::new( + authz::Silo::new( + authz::FLEET, + token.silo_id, + LookupType::by_id(token.silo_id), + ), + token.id(), + LookupType::ById(token.id()), + ); + + opctx.authorize(authz::Action::Read, &authz_token).await?; + + Ok(Some(token)) } } diff --git a/nexus/db-queries/src/db/datastore/silo_user.rs b/nexus/db-queries/src/db/datastore/silo_user.rs index b2a9832df04..0bdc8125fc5 100644 --- a/nexus/db-queries/src/db/datastore/silo_user.rs +++ b/nexus/db-queries/src/db/datastore/silo_user.rs @@ -860,7 +860,6 @@ impl DataStore { &authn::USER_INTERNAL_READ, &authn::USER_EXTERNAL_AUTHN, &authn::USER_SAGA_RECOVERY, - &authn::USER_EXTERNAL_SCIM, ] .iter() .map(|u| { diff --git a/nexus/db-queries/src/policy_test/resource_builder.rs b/nexus/db-queries/src/policy_test/resource_builder.rs index ee1bcf6fc69..631b657f24b 100644 --- a/nexus/db-queries/src/policy_test/resource_builder.rs +++ b/nexus/db-queries/src/policy_test/resource_builder.rs @@ -381,3 +381,23 @@ impl DynAuthorizedResource for authz::SiloUserTokenList { format!("{}: token list", self.silo_user().resource_name()) } } + +impl DynAuthorizedResource for authz::ScimClientBearerTokenList { + 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!( + "{}: scim client bearer token list", + self.silo().resource_name() + ) + } +} diff --git a/nexus/db-queries/src/policy_test/resources.rs b/nexus/db-queries/src/policy_test/resources.rs index a2173dbf95a..dcb77e100a7 100644 --- a/nexus/db-queries/src/policy_test/resources.rs +++ b/nexus/db-queries/src/policy_test/resources.rs @@ -311,6 +311,7 @@ async fn make_silo( scim_client_bearer_token_id, LookupType::by_id(scim_client_bearer_token_id), )); + builder.new_resource(authz::ScimClientBearerTokenList::new(silo.clone())); } /// Helper for `make_resources()` that constructs a small Project hierarchy diff --git a/nexus/examples/config-second.toml b/nexus/examples/config-second.toml index 138c3f4e1d2..2de7bb187ca 100644 --- a/nexus/examples/config-second.toml +++ b/nexus/examples/config-second.toml @@ -17,7 +17,7 @@ session_absolute_timeout_minutes = 1440 # 24 hours # List of authentication schemes to support. [authn] -schemes_external = ["session_cookie", "access_token"] +schemes_external = ["session_cookie", "access_token", "scim_token"] [log] # Show log messages of this level and more severe diff --git a/nexus/examples/config.toml b/nexus/examples/config.toml index e314d8f6152..e95e40496d7 100644 --- a/nexus/examples/config.toml +++ b/nexus/examples/config.toml @@ -10,7 +10,7 @@ session_absolute_timeout_minutes = 1440 # 24 hours # List of authentication schemes to support. [authn] -schemes_external = ["session_cookie", "access_token"] +schemes_external = ["session_cookie", "access_token", "scim_token"] [log] # Show log messages of this level and more severe diff --git a/nexus/src/app/audit_log.rs b/nexus/src/app/audit_log.rs index 654feade95b..55d77b718c2 100644 --- a/nexus/src/app/audit_log.rs +++ b/nexus/src/app/audit_log.rs @@ -64,6 +64,9 @@ impl super::Nexus { silo_user_id: *silo_user_id, silo_id: *silo_id, }, + Some(nexus_auth::authn::Actor::Scim { silo_id }) => { + AuditLogActor::Scim { silo_id: *silo_id } + } None => AuditLogActor::Unauthenticated, }; @@ -121,7 +124,8 @@ impl super::Nexus { // practically speaking, there is currently no operation that will // cause this method to be called with a built-in user AuditLogActor::UserBuiltin { .. } - | AuditLogActor::SiloUser { .. } => { + | AuditLogActor::SiloUser { .. } + | AuditLogActor::Scim { .. } => { opctx.authn.scheme_used().map(|s| s.to_string()) } // if we tried to pull it off the opctx this would be None anyway, diff --git a/nexus/src/app/mod.rs b/nexus/src/app/mod.rs index 9b5f0494455..f4cf081305b 100644 --- a/nexus/src/app/mod.rs +++ b/nexus/src/app/mod.rs @@ -876,17 +876,6 @@ impl Nexus { ) } - /// Returns an [`OpContext`] used for authenticating SCIM requests - pub fn opctx_external_scim(&self) -> OpContext { - OpContext::for_background( - self.log.new(o!("component" => "ExternalScim")), - Arc::clone(&self.authz), - authn::Context::external_scim(), - Arc::clone(&self.db_datastore) - as Arc, - ) - } - /// Used as the body of a "stub" endpoint -- one that's currently /// unimplemented but that we eventually intend to implement /// diff --git a/nexus/src/app/scim.rs b/nexus/src/app/scim.rs index bba003b3488..d9b6a3bc5e1 100644 --- a/nexus/src/app/scim.rs +++ b/nexus/src/app/scim.rs @@ -5,12 +5,15 @@ //! SCIM endpoints use crate::db::model::UserProvisionType; + +use anyhow::anyhow; use chrono::Utc; use dropshot::Body; use dropshot::HttpError; use http::Response; use http::StatusCode; use nexus_db_lookup::lookup; +use nexus_db_queries::authn::{Actor, Reason}; use nexus_db_queries::context::OpContext; use nexus_db_queries::db::datastore::CrdbScimProviderStore; use nexus_types::external_api::views; @@ -83,91 +86,86 @@ impl super::Nexus { // 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_get_provider_from_bearer_token( + pub(crate) async fn scim_token_actor( &self, - 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(), - }); - } - + opctx: &OpContext, + token: String, + ) -> Result { let Some(bearer_token) = self .datastore() - .scim_idp_lookup_token_by_bearer(token[BEARER.len()..].to_string()) - .await? + .scim_lookup_token_by_bearer(opctx, token.clone()) + .await + .map_err(|e| Reason::UnknownError { source: e })? else { - return Err(Error::Unauthenticated { - internal_message: "Invalid bearer token".to_string(), + return Err(Reason::UnknownActor { + actor: "scim 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 now = Utc::now(); + if now > *time_expires { + return Err(Reason::BadCredentials { + actor: Actor::Scim { silo_id: bearer_token.silo_id }, + source: anyhow!( + "token expired at {time_expires} (current time: {now})" + ), }); } } // 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())? + self.silo_lookup(opctx, bearer_token.silo_id.into()) + .map_err(|e| Reason::UnknownError { source: e })? .fetch() - .await? + .await + .map_err(|e| Reason::UnknownError { source: e })? }; if db_silo.user_provision_type != UserProvisionType::Scim { - return Err(Error::invalid_request( - "silo is not provisioned with scim", - )); + // This should basically be impossible if the bearer token lookup + // returned something, but double check anyway. + return Err(Reason::BadCredentials { + actor: Actor::Scim { silo_id: bearer_token.silo_id }, + source: anyhow!( + "silo {} not a SCIM silo!", + bearer_token.silo_id, + ), + }); } - let provider = scim2_rs::Provider::new( - self.log.new(slog::o!( - "component" => "scim2_rs::Provider", - "silo" => bearer_token.silo_id.to_string(), + Ok(Actor::Scim { silo_id: bearer_token.silo_id }) + } + + /// For an authenticataed Actor::Scim, return a scim2_rs::Provider + pub(crate) async fn scim_get_provider_from_opctx( + &self, + opctx: &OpContext, + ) -> LookupResult> { + match opctx.authn.actor() { + Some(Actor::Scim { silo_id }) => Ok(scim2_rs::Provider::new( + self.log.new(slog::o!( + "component" => "scim2_rs::Provider", + "silo" => silo_id.to_string(), + )), + CrdbScimProviderStore::new(*silo_id, self.datastore().clone()), )), - CrdbScimProviderStore::new( - bearer_token.silo_id, - self.datastore().clone(), - ), - ); - Ok(provider) + _ => Err(Error::Unauthenticated { + internal_message: "not an Actor::Scim".to_string(), + }), + } } // SCIM implementation pub async fn scim_v2_list_users( &self, - request: &dropshot::RequestInfo, + opctx: &OpContext, query: scim2_rs::QueryParams, ) -> Result, HttpError> { - let provider = - self.scim_get_provider_from_bearer_token(&request).await?; + let provider = self.scim_get_provider_from_opctx(opctx).await?; let result = match provider.list_users(query).await { Ok(response) => response.to_http_response(), @@ -179,12 +177,11 @@ impl super::Nexus { pub async fn scim_v2_get_user_by_id( &self, - request: &dropshot::RequestInfo, + opctx: &OpContext, query: scim2_rs::QueryParams, user_id: String, ) -> Result, HttpError> { - let provider = - self.scim_get_provider_from_bearer_token(&request).await?; + let provider = self.scim_get_provider_from_opctx(opctx).await?; let result = match provider.get_user_by_id(query, &user_id).await { Ok(response) => response.to_http_response(StatusCode::OK), @@ -196,11 +193,10 @@ impl super::Nexus { pub async fn scim_v2_create_user( &self, - request: &dropshot::RequestInfo, + opctx: &OpContext, body: scim2_rs::CreateUserRequest, ) -> Result, HttpError> { - let provider = - self.scim_get_provider_from_bearer_token(&request).await?; + let provider = self.scim_get_provider_from_opctx(opctx).await?; let result = match provider.create_user(body).await { Ok(response) => response.to_http_response(StatusCode::CREATED), @@ -212,12 +208,11 @@ impl super::Nexus { pub async fn scim_v2_replace_user( &self, - request: &dropshot::RequestInfo, + opctx: &OpContext, user_id: String, body: scim2_rs::CreateUserRequest, ) -> Result, HttpError> { - let provider = - self.scim_get_provider_from_bearer_token(&request).await?; + let provider = self.scim_get_provider_from_opctx(opctx).await?; let result = match provider.replace_user(&user_id, body).await { Ok(response) => response.to_http_response(StatusCode::OK), @@ -229,12 +224,11 @@ impl super::Nexus { pub async fn scim_v2_patch_user( &self, - request: &dropshot::RequestInfo, + opctx: &OpContext, user_id: String, body: scim2_rs::PatchRequest, ) -> Result, HttpError> { - let provider = - self.scim_get_provider_from_bearer_token(&request).await?; + let provider = self.scim_get_provider_from_opctx(opctx).await?; let result = match provider.patch_user(&user_id, body).await { Ok(response) => response.to_http_response(StatusCode::OK), @@ -246,11 +240,10 @@ impl super::Nexus { pub async fn scim_v2_delete_user( &self, - request: &dropshot::RequestInfo, + opctx: &OpContext, user_id: String, ) -> Result, HttpError> { - let provider = - self.scim_get_provider_from_bearer_token(&request).await?; + let provider = self.scim_get_provider_from_opctx(opctx).await?; let result = match provider.delete_user(&user_id).await { Ok(response) => Ok(response), @@ -262,11 +255,10 @@ impl super::Nexus { pub async fn scim_v2_list_groups( &self, - request: &dropshot::RequestInfo, + opctx: &OpContext, query: scim2_rs::QueryParams, ) -> Result, HttpError> { - let provider = - self.scim_get_provider_from_bearer_token(&request).await?; + let provider = self.scim_get_provider_from_opctx(opctx).await?; let result = match provider.list_groups(query).await { Ok(response) => response.to_http_response(), @@ -278,12 +270,11 @@ impl super::Nexus { pub async fn scim_v2_get_group_by_id( &self, - request: &dropshot::RequestInfo, + opctx: &OpContext, query: scim2_rs::QueryParams, group_id: String, ) -> Result, HttpError> { - let provider = - self.scim_get_provider_from_bearer_token(&request).await?; + let provider = self.scim_get_provider_from_opctx(opctx).await?; let result = match provider.get_group_by_id(query, &group_id).await { Ok(response) => response.to_http_response(StatusCode::OK), @@ -295,11 +286,10 @@ impl super::Nexus { pub async fn scim_v2_create_group( &self, - request: &dropshot::RequestInfo, + opctx: &OpContext, body: scim2_rs::CreateGroupRequest, ) -> Result, HttpError> { - let provider = - self.scim_get_provider_from_bearer_token(&request).await?; + let provider = self.scim_get_provider_from_opctx(opctx).await?; let result = match provider.create_group(body).await { Ok(response) => response.to_http_response(StatusCode::CREATED), @@ -311,12 +301,11 @@ impl super::Nexus { pub async fn scim_v2_replace_group( &self, - request: &dropshot::RequestInfo, + opctx: &OpContext, group_id: String, body: scim2_rs::CreateGroupRequest, ) -> Result, HttpError> { - let provider = - self.scim_get_provider_from_bearer_token(&request).await?; + let provider = self.scim_get_provider_from_opctx(opctx).await?; let result = match provider.replace_group(&group_id, body).await { Ok(response) => response.to_http_response(StatusCode::OK), @@ -328,12 +317,11 @@ impl super::Nexus { pub async fn scim_v2_patch_group( &self, - request: &dropshot::RequestInfo, + opctx: &OpContext, group_id: String, body: scim2_rs::PatchRequest, ) -> Result, HttpError> { - let provider = - self.scim_get_provider_from_bearer_token(&request).await?; + let provider = self.scim_get_provider_from_opctx(opctx).await?; let result = match provider.patch_group(&group_id, body).await { Ok(response) => response.to_http_response(StatusCode::OK), @@ -345,11 +333,10 @@ impl super::Nexus { pub async fn scim_v2_delete_group( &self, - request: &dropshot::RequestInfo, + opctx: &OpContext, group_id: String, ) -> Result, HttpError> { - let provider = - self.scim_get_provider_from_bearer_token(&request).await?; + let provider = self.scim_get_provider_from_opctx(opctx).await?; let result = match provider.delete_group(&group_id).await { Ok(response) => Ok(response), diff --git a/nexus/src/context.rs b/nexus/src/context.rs index aaab00036cd..b2684b1cc05 100644 --- a/nexus/src/context.rs +++ b/nexus/src/context.rs @@ -6,6 +6,7 @@ use super::Nexus; use crate::saga_interface::SagaContext; use async_trait::async_trait; use authn::external::HttpAuthnScheme; +use authn::external::scim::HttpAuthnScimToken; use authn::external::session_cookie::HttpAuthnSessionCookie; use authn::external::spoof::HttpAuthnSpoof; use authn::external::token::HttpAuthnToken; @@ -141,6 +142,7 @@ impl ServerContext { Box::new(HttpAuthnSessionCookie) } SchemeName::AccessToken => Box::new(HttpAuthnToken), + SchemeName::ScimToken => Box::new(HttpAuthnScimToken), }, ) .collect(); @@ -495,3 +497,14 @@ impl SessionStore for ServerContext { self.console_config.session_absolute_timeout } } + +#[async_trait] +impl authn::external::scim::ScimTokenContext for ServerContext { + async fn scim_token_actor( + &self, + token: String, + ) -> Result { + let opctx = self.nexus.opctx_external_authn(); + self.nexus.scim_token_actor(opctx, token).await + } +} diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 307f23f1600..6b1b75f15c8 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -1037,19 +1037,14 @@ impl NexusExternalApi for NexusExternalApiImpl { ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - - // SCIM operations are authenticated by resolving a token (that does - // _not_ resolve to any Actor) to a silo-specific SCIM server - // implementation. There isn't any opctx to use, so the "external - // scim" one is used here for audit purposes. - let opctx = nexus.opctx_external_scim(); - let audit = - nexus.audit_log_entry_init_unauthed(&opctx, &rqctx).await?; + let audit = nexus.audit_log_entry_init(&opctx, &rqctx).await?; let result = async { let query = query_params.into_inner(); - nexus.scim_v2_list_users(&rqctx.request, query).await + nexus.scim_v2_list_users(&opctx, query).await } .await; @@ -1071,21 +1066,17 @@ impl NexusExternalApi for NexusExternalApiImpl { ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - let opctx = nexus.opctx_external_scim(); - let audit = - nexus.audit_log_entry_init_unauthed(&opctx, &rqctx).await?; + let audit = nexus.audit_log_entry_init(&opctx, &rqctx).await?; let result = async { let query = query_params.into_inner(); let path_params = path_params.into_inner(); nexus - .scim_v2_get_user_by_id( - &rqctx.request, - query, - path_params.user_id, - ) + .scim_v2_get_user_by_id(&opctx, query, path_params.user_id) .await } .await; @@ -1107,15 +1098,13 @@ impl NexusExternalApi for NexusExternalApiImpl { ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - let opctx = nexus.opctx_external_scim(); - let audit = - nexus.audit_log_entry_init_unauthed(&opctx, &rqctx).await?; + let audit = nexus.audit_log_entry_init(&opctx, &rqctx).await?; let result = async { - nexus - .scim_v2_create_user(&rqctx.request, body.into_inner()) - .await + nexus.scim_v2_create_user(&opctx, body.into_inner()).await } .await; @@ -1137,17 +1126,17 @@ impl NexusExternalApi for NexusExternalApiImpl { ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - let opctx = nexus.opctx_external_scim(); - let audit = - nexus.audit_log_entry_init_unauthed(&opctx, &rqctx).await?; + let audit = nexus.audit_log_entry_init(&opctx, &rqctx).await?; let result = async { let path_params = path_params.into_inner(); nexus .scim_v2_replace_user( - &rqctx.request, + &opctx, path_params.user_id, body.into_inner(), ) @@ -1173,17 +1162,17 @@ impl NexusExternalApi for NexusExternalApiImpl { ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - let opctx = nexus.opctx_external_scim(); - let audit = - nexus.audit_log_entry_init_unauthed(&opctx, &rqctx).await?; + let audit = nexus.audit_log_entry_init(&opctx, &rqctx).await?; let result = async { let path_params = path_params.into_inner(); nexus .scim_v2_patch_user( - &rqctx.request, + &opctx, path_params.user_id, body.into_inner(), ) @@ -1208,17 +1197,15 @@ impl NexusExternalApi for NexusExternalApiImpl { ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - let opctx = nexus.opctx_external_scim(); - let audit = - nexus.audit_log_entry_init_unauthed(&opctx, &rqctx).await?; + let audit = nexus.audit_log_entry_init(&opctx, &rqctx).await?; let result = async { let path_params = path_params.into_inner(); - nexus - .scim_v2_delete_user(&rqctx.request, path_params.user_id) - .await + nexus.scim_v2_delete_user(&opctx, path_params.user_id).await } .await; @@ -1239,15 +1226,15 @@ impl NexusExternalApi for NexusExternalApiImpl { ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - let opctx = nexus.opctx_external_scim(); - let audit = - nexus.audit_log_entry_init_unauthed(&opctx, &rqctx).await?; + let audit = nexus.audit_log_entry_init(&opctx, &rqctx).await?; let result = async { let query = query_params.into_inner(); - nexus.scim_v2_list_groups(&rqctx.request, query).await + nexus.scim_v2_list_groups(&opctx, query).await } .await; @@ -1269,10 +1256,10 @@ impl NexusExternalApi for NexusExternalApiImpl { ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - let opctx = nexus.opctx_external_scim(); - let audit = - nexus.audit_log_entry_init_unauthed(&opctx, &rqctx).await?; + let audit = nexus.audit_log_entry_init(&opctx, &rqctx).await?; let result = async { let query = query_params.into_inner(); @@ -1280,7 +1267,7 @@ impl NexusExternalApi for NexusExternalApiImpl { nexus .scim_v2_get_group_by_id( - &rqctx.request, + &opctx, query, path_params.group_id, ) @@ -1305,15 +1292,13 @@ impl NexusExternalApi for NexusExternalApiImpl { ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - let opctx = nexus.opctx_external_scim(); - let audit = - nexus.audit_log_entry_init_unauthed(&opctx, &rqctx).await?; + let audit = nexus.audit_log_entry_init(&opctx, &rqctx).await?; let result = async { - nexus - .scim_v2_create_group(&rqctx.request, body.into_inner()) - .await + nexus.scim_v2_create_group(&opctx, body.into_inner()).await } .await; @@ -1335,17 +1320,17 @@ impl NexusExternalApi for NexusExternalApiImpl { ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - let opctx = nexus.opctx_external_scim(); - let audit = - nexus.audit_log_entry_init_unauthed(&opctx, &rqctx).await?; + let audit = nexus.audit_log_entry_init(&opctx, &rqctx).await?; let result = async { let path_params = path_params.into_inner(); nexus .scim_v2_replace_group( - &rqctx.request, + &opctx, path_params.group_id, body.into_inner(), ) @@ -1371,17 +1356,17 @@ impl NexusExternalApi for NexusExternalApiImpl { ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - let opctx = nexus.opctx_external_scim(); - let audit = - nexus.audit_log_entry_init_unauthed(&opctx, &rqctx).await?; + let audit = nexus.audit_log_entry_init(&opctx, &rqctx).await?; let result = async { let path_params = path_params.into_inner(); nexus .scim_v2_patch_group( - &rqctx.request, + &opctx, path_params.group_id, body.into_inner(), ) @@ -1406,17 +1391,15 @@ impl NexusExternalApi for NexusExternalApiImpl { ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - let opctx = nexus.opctx_external_scim(); - let audit = - nexus.audit_log_entry_init_unauthed(&opctx, &rqctx).await?; + let audit = nexus.audit_log_entry_init(&opctx, &rqctx).await?; let result = async { let path_params = path_params.into_inner(); - nexus - .scim_v2_delete_group(&rqctx.request, path_params.group_id) - .await + nexus.scim_v2_delete_group(&opctx, path_params.group_id).await } .await; diff --git a/nexus/tests/config.test.toml b/nexus/tests/config.test.toml index 9ea7467c417..e629fd435c9 100644 --- a/nexus/tests/config.test.toml +++ b/nexus/tests/config.test.toml @@ -10,7 +10,7 @@ session_absolute_timeout_minutes = 1440 # 24 hours # List of authentication schemes to support. [authn] -schemes_external = ["spoof", "session_cookie", "access_token"] +schemes_external = ["spoof", "session_cookie", "access_token", "scim_token"] # # NOTE: for the test suite, if mode = "file", the file path MUST be the sentinel diff --git a/nexus/tests/integration_tests/authn_http.rs b/nexus/tests/integration_tests/authn_http.rs index 7a50c137f61..f9262490090 100644 --- a/nexus/tests/integration_tests/authn_http.rs +++ b/nexus/tests/integration_tests/authn_http.rs @@ -438,6 +438,8 @@ async fn whoami_get( Actor::SiloUser { silo_user_id, .. } => silo_user_id.to_string(), Actor::UserBuiltin { user_builtin_id } => user_builtin_id.to_string(), + + Actor::Scim { silo_id } => format!("scim for {silo_id}"), }); let authenticated = actor.is_some(); let schemes_tried = diff --git a/nexus/tests/integration_tests/scim.rs b/nexus/tests/integration_tests/scim.rs index 0f0c505900a..f63918848a4 100644 --- a/nexus/tests/integration_tests/scim.rs +++ b/nexus/tests/integration_tests/scim.rs @@ -446,7 +446,7 @@ async fn test_scim_client_token_bearer_auth( RequestBuilder::new(client, Method::GET, "/scim/v2/Users") .header( http::header::AUTHORIZATION, - format!("Bearer {}", created_token.bearer_token), + format!("Bearer oxide-scim-{}", created_token.bearer_token), ) .allow_non_dropshot_errors() .expect_status(Some(StatusCode::INTERNAL_SERVER_ERROR)) @@ -501,10 +501,68 @@ async fn test_scim_client_no_auth_with_expired_token( // This should 401 RequestBuilder::new(client, Method::GET, "/scim/v2/Users") - .header(http::header::AUTHORIZATION, String::from("Bearer testpost")) + .header( + http::header::AUTHORIZATION, + String::from("Bearer oxide-scim-testpost"), + ) .allow_non_dropshot_errors() .expect_status(Some(StatusCode::UNAUTHORIZED)) .execute() .await .expect("expected 401"); } + +/// Test that a SCIM authenticated actor cannot read a Silo's projects +#[nexus_test] +async fn test_scim_client_no_read_project(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 a token + + { + let now = Utc::now(); + + let new_token = ScimClientBearerToken { + id: Uuid::new_v4(), + time_created: now, + time_deleted: None, + time_expires: None, + 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 404 + + RequestBuilder::new(client, Method::GET, "/v1/projects") + .header( + http::header::AUTHORIZATION, + String::from("Bearer oxide-scim-testpost"), + ) + .expect_status(Some(StatusCode::NOT_FOUND)) + .execute() + .await + .expect("expected 404"); +} diff --git a/nexus/tests/integration_tests/users_builtin.rs b/nexus/tests/integration_tests/users_builtin.rs index 3c5337b5584..23a1858593e 100644 --- a/nexus/tests/integration_tests/users_builtin.rs +++ b/nexus/tests/integration_tests/users_builtin.rs @@ -54,9 +54,6 @@ async fn test_users_builtin(cptestctx: &ControlPlaneTestContext) { let u = users.remove(&authn::USER_SAGA_RECOVERY.name.to_string()).unwrap(); assert_eq!(u.identity.id, authn::USER_SAGA_RECOVERY.id.into_untyped_uuid()); - let u = users.remove(&authn::USER_EXTERNAL_SCIM.name.to_string()).unwrap(); - assert_eq!(u.identity.id, authn::USER_EXTERNAL_SCIM.id.into_untyped_uuid()); - assert!(users.is_empty(), "found unexpected built-in users"); // TODO-coverage add test for fetching individual users, including invalid diff --git a/nexus/types/src/external_api/views.rs b/nexus/types/src/external_api/views.rs index 397265204da..fb1c5ac1402 100644 --- a/nexus/types/src/external_api/views.rs +++ b/nexus/types/src/external_api/views.rs @@ -1728,6 +1728,10 @@ pub enum AuditLogEntryActor { silo_id: Uuid, }, + Scim { + silo_id: Uuid, + }, + Unauthenticated, } diff --git a/schema/crdb/dbinit.sql b/schema/crdb/dbinit.sql index 782fabf0d4b..d23ee08aea8 100644 --- a/schema/crdb/dbinit.sql +++ b/schema/crdb/dbinit.sql @@ -6016,7 +6016,8 @@ CREATE UNIQUE INDEX IF NOT EXISTS one_record_per_volume_resource_usage on omicro CREATE TYPE IF NOT EXISTS omicron.public.audit_log_actor_kind AS ENUM ( 'user_builtin', 'silo_user', - 'unauthenticated' + 'unauthenticated', + 'scim' ); CREATE TYPE IF NOT EXISTS omicron.public.audit_log_result_kind AS ENUM ( diff --git a/schema/crdb/scim-client-bearer-token/up04.sql b/schema/crdb/scim-client-bearer-token/up04.sql new file mode 100644 index 00000000000..58d217491b6 --- /dev/null +++ b/schema/crdb/scim-client-bearer-token/up04.sql @@ -0,0 +1,6 @@ +ALTER TYPE + omicron.public.audit_log_actor_kind +ADD VALUE IF NOT EXISTS + 'scim' +AFTER + 'unauthenticated'; diff --git a/smf/nexus/multi-sled/config-partial.toml b/smf/nexus/multi-sled/config-partial.toml index 5f835b712fa..14b0281cc24 100644 --- a/smf/nexus/multi-sled/config-partial.toml +++ b/smf/nexus/multi-sled/config-partial.toml @@ -9,7 +9,7 @@ session_idle_timeout_minutes = 480 # 8 hours session_absolute_timeout_minutes = 1440 # 24 hours [authn] -schemes_external = ["session_cookie", "access_token"] +schemes_external = ["session_cookie", "access_token", "scim_token"] [log] # Show log messages of this level and more severe diff --git a/smf/nexus/single-sled/config-partial.toml b/smf/nexus/single-sled/config-partial.toml index e72748febb0..32e20ee79f0 100644 --- a/smf/nexus/single-sled/config-partial.toml +++ b/smf/nexus/single-sled/config-partial.toml @@ -9,7 +9,7 @@ session_idle_timeout_minutes = 480 # 8 hours session_absolute_timeout_minutes = 1440 # 24 hours [authn] -schemes_external = ["session_cookie", "access_token"] +schemes_external = ["session_cookie", "access_token", "scim_token"] [log] # Show log messages of this level and more severe From 456cdc0f7e07d12e3257c79aab896cf0350c346c Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Mon, 20 Oct 2025 01:25:28 +0000 Subject: [PATCH 10/18] test failures --- nexus/db-queries/tests/output/authz-roles.out | 34 +++++++++++++++++-- nexus/tests/integration_tests/endpoints.rs | 2 +- openapi/nexus.json | 19 +++++++++++ 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/nexus/db-queries/tests/output/authz-roles.out b/nexus/db-queries/tests/output/authz-roles.out index d85802d2c61..903699e5b02 100644 --- a/nexus/db-queries/tests/output/authz-roles.out +++ b/nexus/db-queries/tests/output/authz-roles.out @@ -771,10 +771,24 @@ resource: InternetGatewayIpAddress "silo1-proj2-igw1-address1" resource: ScimClientBearerToken id "7885144e-9c75-47f7-a97d-7dfc58e1186c" USER Q R LC RP M MP CC D - fleet-admin ✘ ✔ ✔ ✔ ✔ ✔ ✔ ✔ + fleet-admin ✘ ✔ ✘ ✔ ✔ ✔ ✘ ✔ fleet-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ fleet-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ - silo1-admin ✘ ✔ ✔ ✔ ✔ ✔ ✔ ✔ + silo1-admin ✘ ✔ ✘ ✔ ✔ ✔ ✘ ✔ + silo1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-proj1-admin ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + unauthenticated ! ! ! ! ! ! ! ! + +resource: Silo "silo1": scim client bearer token list + + USER Q R LC RP M MP CC D + fleet-admin ✘ ✘ ✔ ✘ ✘ ✘ ✔ ✘ + fleet-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + fleet-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-admin ✘ ✘ ✔ ✘ ✘ ✘ ✔ ✘ silo1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-admin ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ @@ -1177,7 +1191,21 @@ resource: InternetGatewayIpAddress "silo2-proj1-igw1-address1" resource: ScimClientBearerToken id "7885144e-9c75-47f7-a97d-7dfc58e1186c" USER Q R LC RP M MP CC D - fleet-admin ✘ ✔ ✔ ✔ ✔ ✔ ✔ ✔ + 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": scim client bearer token list + + USER Q R LC RP M MP CC D + fleet-admin ✘ ✘ ✔ ✘ ✘ ✘ ✔ ✘ fleet-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ fleet-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-admin ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index cb9e9a82b61..dd2ebc92b33 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -3069,7 +3069,7 @@ pub static VERIFY_ENDPOINTS: LazyLock> = LazyLock::new( }, VerifyEndpoint { url: &SCIM_TOKEN_URL, - visibility: Visibility::Public, + visibility: Visibility::Protected, unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, diff --git a/openapi/nexus.json b/openapi/nexus.json index e7b3662de05..1428001ec44 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -14929,6 +14929,25 @@ "silo_user_id" ] }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "scim" + ] + }, + "silo_id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "kind", + "silo_id" + ] + }, { "type": "object", "properties": { From 3b52803e98c8dbff3072f729869936fcc27d7bdc Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Mon, 20 Oct 2025 17:12:16 +0000 Subject: [PATCH 11/18] test_iam_roles_behavior is a genius idea --- nexus/auth/src/authn/mod.rs | 12 ++ nexus/db-queries/src/policy_test/mod.rs | 14 +++ nexus/db-queries/tests/output/authz-roles.out | 105 ++++++++++++++++++ nexus/tests/integration_tests/scim.rs | 55 --------- 4 files changed, 131 insertions(+), 55 deletions(-) diff --git a/nexus/auth/src/authn/mod.rs b/nexus/auth/src/authn/mod.rs index ff955fb1619..77c6a5ce685 100644 --- a/nexus/auth/src/authn/mod.rs +++ b/nexus/auth/src/authn/mod.rs @@ -261,6 +261,18 @@ impl Context { schemes_tried: Vec::new(), } } + + /// Returns an authenticated context for a Silo's SCIM Actor. Not marked as + /// #[cfg(test)] so that this is available in integration tests. + pub fn for_scim(silo_id: Uuid) -> Context { + Context { + kind: Kind::Authenticated( + Details { actor: Actor::Scim { silo_id } }, + None, + ), + schemes_tried: Vec::new(), + } + } } /// Authentication-related policy derived from a user's Silo diff --git a/nexus/db-queries/src/policy_test/mod.rs b/nexus/db-queries/src/policy_test/mod.rs index 15f67857aec..6ec50e7f5b3 100644 --- a/nexus/db-queries/src/policy_test/mod.rs +++ b/nexus/db-queries/src/policy_test/mod.rs @@ -209,6 +209,20 @@ async fn test_iam_roles_behavior() { ), ))); + // Create a SCIM Actor for this silo. + let user_log = logctx.log.new(o!( + "actor" => "scim", + )); + user_contexts.push(Arc::new(( + String::from("scim"), + OpContext::for_background( + user_log, + Arc::clone(&authz), + authn::Context::for_scim(main_silo_id), + Arc::clone(&datastore) as Arc, + ), + ))); + // Create an output stream that writes to stdout as well as an in-memory // buffer. The test run will write a textual summary to the stream. Then // we'll use use expectorate to verify it. We do this rather than assert diff --git a/nexus/db-queries/tests/output/authz-roles.out b/nexus/db-queries/tests/output/authz-roles.out index 903699e5b02..84317563bad 100644 --- a/nexus/db-queries/tests/output/authz-roles.out +++ b/nexus/db-queries/tests/output/authz-roles.out @@ -11,6 +11,7 @@ resource: authz::Database silo1-proj1-collaborator ✔ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✔ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✔ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Fleet id "001de000-1334-4000-8000-000000000000" @@ -25,6 +26,7 @@ resource: Fleet id "001de000-1334-4000-8000-000000000000" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: authz::BlueprintConfig @@ -39,6 +41,7 @@ resource: authz::BlueprintConfig silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: authz::ConsoleSessionList @@ -53,6 +56,7 @@ resource: authz::ConsoleSessionList silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: authz::DnsConfig @@ -67,6 +71,7 @@ resource: authz::DnsConfig silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: authz::DeviceAuthRequestList @@ -81,6 +86,7 @@ resource: authz::DeviceAuthRequestList silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: authz::Inventory @@ -95,6 +101,7 @@ resource: authz::Inventory silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: authz::IpPoolList @@ -109,6 +116,7 @@ resource: authz::IpPoolList silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: authz::QuiesceState @@ -123,6 +131,7 @@ resource: authz::QuiesceState silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: authz::UpdateTrustRootList @@ -137,6 +146,7 @@ resource: authz::UpdateTrustRootList silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: authz::TargetReleaseConfig @@ -151,6 +161,7 @@ resource: authz::TargetReleaseConfig silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: authz::AlertClassList @@ -165,6 +176,7 @@ resource: authz::AlertClassList silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: authz::AuditLog @@ -179,6 +191,7 @@ resource: authz::AuditLog silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✔ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✔ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✔ ✘ resource: Silo "silo1" @@ -193,6 +206,7 @@ resource: Silo "silo1" silo1-proj1-collaborator ✘ ✔ ✘ ✔ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✔ ✘ ✔ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Silo "silo1": certificate list @@ -207,6 +221,7 @@ resource: Silo "silo1": certificate list silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Certificate "silo1-certificate" @@ -221,6 +236,7 @@ resource: Certificate "silo1-certificate" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Silo "silo1": identity provider list @@ -235,6 +251,7 @@ resource: Silo "silo1": identity provider list silo1-proj1-collaborator ✘ ✘ ✔ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✔ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: IdentityProvider "silo1-identity-provider" @@ -249,6 +266,7 @@ resource: IdentityProvider "silo1-identity-provider" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: SamlIdentityProvider "silo1-saml-identity-provider" @@ -263,6 +281,7 @@ resource: SamlIdentityProvider "silo1-saml-identity-provider" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Silo "silo1": user list @@ -277,6 +296,7 @@ resource: Silo "silo1": user list silo1-proj1-collaborator ✘ ✘ ✔ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✔ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: SiloUser "silo1-user" @@ -291,6 +311,7 @@ resource: SiloUser "silo1-user" silo1-proj1-collaborator ✘ ✔ ✔ ✔ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✔ ✔ ✔ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: SshKey "silo1-user-ssh-key" @@ -305,6 +326,7 @@ resource: SshKey "silo1-user-ssh-key" silo1-proj1-collaborator ✘ ✔ ✘ ✔ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✔ ✘ ✔ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: SiloGroup "silo1-group" @@ -319,6 +341,7 @@ resource: SiloGroup "silo1-group" silo1-proj1-collaborator ✘ ✔ ✔ ✔ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✔ ✔ ✔ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: SiloImage "silo1-silo-image" @@ -333,6 +356,7 @@ resource: SiloImage "silo1-silo-image" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: SiloUser "silo1-user": session list @@ -347,6 +371,7 @@ resource: SiloUser "silo1-user": session list silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: SiloUser "silo1-user": token list @@ -361,6 +386,7 @@ resource: SiloUser "silo1-user": token list silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Image "silo1-image" @@ -375,6 +401,7 @@ resource: Image "silo1-image" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Project "silo1-proj1" @@ -389,6 +416,7 @@ resource: Project "silo1-proj1" silo1-proj1-collaborator ✘ ✔ ✔ ✔ ✘ ✘ ✔ ✘ silo1-proj1-viewer ✘ ✔ ✔ ✔ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Disk "silo1-proj1-disk1" @@ -403,6 +431,7 @@ resource: Disk "silo1-proj1-disk1" silo1-proj1-collaborator ✘ ✔ ✔ ✔ ✔ ✔ ✔ ✔ silo1-proj1-viewer ✘ ✔ ✔ ✔ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: AffinityGroup "silo1-proj1-affinity-group1" @@ -417,6 +446,7 @@ resource: AffinityGroup "silo1-proj1-affinity-group1" silo1-proj1-collaborator ✘ ✔ ✔ ✔ ✔ ✔ ✔ ✔ silo1-proj1-viewer ✘ ✔ ✔ ✔ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: AntiAffinityGroup "silo1-proj1-anti-affinity-group1" @@ -431,6 +461,7 @@ resource: AntiAffinityGroup "silo1-proj1-anti-affinity-group1" silo1-proj1-collaborator ✘ ✔ ✔ ✔ ✔ ✔ ✔ ✔ silo1-proj1-viewer ✘ ✔ ✔ ✔ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Instance "silo1-proj1-instance1" @@ -445,6 +476,7 @@ resource: Instance "silo1-proj1-instance1" silo1-proj1-collaborator ✘ ✔ ✔ ✔ ✔ ✔ ✔ ✔ silo1-proj1-viewer ✘ ✔ ✔ ✔ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: InstanceNetworkInterface "silo1-proj1-instance1-nic1" @@ -459,6 +491,7 @@ resource: InstanceNetworkInterface "silo1-proj1-instance1-nic1" silo1-proj1-collaborator ✘ ✔ ✔ ✔ ✔ ✔ ✔ ✔ silo1-proj1-viewer ✘ ✔ ✔ ✔ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Vpc "silo1-proj1-vpc1" @@ -473,6 +506,7 @@ resource: Vpc "silo1-proj1-vpc1" silo1-proj1-collaborator ✘ ✔ ✔ ✔ ✔ ✔ ✔ ✔ silo1-proj1-viewer ✘ ✔ ✔ ✔ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: VpcSubnet "silo1-proj1-vpc1-subnet1" @@ -487,6 +521,7 @@ resource: VpcSubnet "silo1-proj1-vpc1-subnet1" silo1-proj1-collaborator ✘ ✔ ✔ ✔ ✔ ✔ ✔ ✔ silo1-proj1-viewer ✘ ✔ ✔ ✔ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Snapshot "silo1-proj1-disk1-snapshot1" @@ -501,6 +536,7 @@ resource: Snapshot "silo1-proj1-disk1-snapshot1" silo1-proj1-collaborator ✘ ✔ ✔ ✔ ✔ ✔ ✔ ✔ silo1-proj1-viewer ✘ ✔ ✔ ✔ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: ProjectImage "silo1-proj1-image1" @@ -515,6 +551,7 @@ resource: ProjectImage "silo1-proj1-image1" silo1-proj1-collaborator ✘ ✔ ✔ ✔ ✔ ✔ ✔ ✔ silo1-proj1-viewer ✘ ✔ ✔ ✔ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: FloatingIp "silo1-proj1-fip1" @@ -529,6 +566,7 @@ resource: FloatingIp "silo1-proj1-fip1" silo1-proj1-collaborator ✘ ✔ ✔ ✔ ✔ ✔ ✔ ✔ silo1-proj1-viewer ✘ ✔ ✔ ✔ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: InternetGateway "silo1-proj1-igw1" @@ -543,6 +581,7 @@ resource: InternetGateway "silo1-proj1-igw1" silo1-proj1-collaborator ✘ ✔ ✔ ✔ ✔ ✔ ✔ ✔ silo1-proj1-viewer ✘ ✔ ✔ ✔ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: InternetGatewayIpPool "silo1-proj1-igw1-pool1" @@ -557,6 +596,7 @@ resource: InternetGatewayIpPool "silo1-proj1-igw1-pool1" silo1-proj1-collaborator ✘ ✔ ✔ ✔ ✔ ✔ ✔ ✔ silo1-proj1-viewer ✘ ✔ ✔ ✔ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: InternetGatewayIpAddress "silo1-proj1-igw1-address1" @@ -571,6 +611,7 @@ resource: InternetGatewayIpAddress "silo1-proj1-igw1-address1" silo1-proj1-collaborator ✘ ✔ ✔ ✔ ✔ ✔ ✔ ✔ silo1-proj1-viewer ✘ ✔ ✔ ✔ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Project "silo1-proj2" @@ -585,6 +626,7 @@ resource: Project "silo1-proj2" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Disk "silo1-proj2-disk1" @@ -599,6 +641,7 @@ resource: Disk "silo1-proj2-disk1" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: AffinityGroup "silo1-proj2-affinity-group1" @@ -613,6 +656,7 @@ resource: AffinityGroup "silo1-proj2-affinity-group1" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: AntiAffinityGroup "silo1-proj2-anti-affinity-group1" @@ -627,6 +671,7 @@ resource: AntiAffinityGroup "silo1-proj2-anti-affinity-group1" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Instance "silo1-proj2-instance1" @@ -641,6 +686,7 @@ resource: Instance "silo1-proj2-instance1" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: InstanceNetworkInterface "silo1-proj2-instance1-nic1" @@ -655,6 +701,7 @@ resource: InstanceNetworkInterface "silo1-proj2-instance1-nic1" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Vpc "silo1-proj2-vpc1" @@ -669,6 +716,7 @@ resource: Vpc "silo1-proj2-vpc1" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: VpcSubnet "silo1-proj2-vpc1-subnet1" @@ -683,6 +731,7 @@ resource: VpcSubnet "silo1-proj2-vpc1-subnet1" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Snapshot "silo1-proj2-disk1-snapshot1" @@ -697,6 +746,7 @@ resource: Snapshot "silo1-proj2-disk1-snapshot1" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: ProjectImage "silo1-proj2-image1" @@ -711,6 +761,7 @@ resource: ProjectImage "silo1-proj2-image1" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: FloatingIp "silo1-proj2-fip1" @@ -725,6 +776,7 @@ resource: FloatingIp "silo1-proj2-fip1" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: InternetGateway "silo1-proj2-igw1" @@ -739,6 +791,7 @@ resource: InternetGateway "silo1-proj2-igw1" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: InternetGatewayIpPool "silo1-proj2-igw1-pool1" @@ -753,6 +806,7 @@ resource: InternetGatewayIpPool "silo1-proj2-igw1-pool1" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: InternetGatewayIpAddress "silo1-proj2-igw1-address1" @@ -767,6 +821,7 @@ resource: InternetGatewayIpAddress "silo1-proj2-igw1-address1" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: ScimClientBearerToken id "7885144e-9c75-47f7-a97d-7dfc58e1186c" @@ -781,6 +836,7 @@ resource: ScimClientBearerToken id "7885144e-9c75-47f7-a97d-7dfc58e1186c" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Silo "silo1": scim client bearer token list @@ -795,6 +851,7 @@ resource: Silo "silo1": scim client bearer token list silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Silo "silo2" @@ -809,6 +866,7 @@ resource: Silo "silo2" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Silo "silo2": certificate list @@ -823,6 +881,7 @@ resource: Silo "silo2": certificate list silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Certificate "silo2-certificate" @@ -837,6 +896,7 @@ resource: Certificate "silo2-certificate" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Silo "silo2": identity provider list @@ -851,6 +911,7 @@ resource: Silo "silo2": identity provider list silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: IdentityProvider "silo2-identity-provider" @@ -865,6 +926,7 @@ resource: IdentityProvider "silo2-identity-provider" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: SamlIdentityProvider "silo2-saml-identity-provider" @@ -879,6 +941,7 @@ resource: SamlIdentityProvider "silo2-saml-identity-provider" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Silo "silo2": user list @@ -893,6 +956,7 @@ resource: Silo "silo2": user list silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: SiloUser "silo2-user" @@ -907,6 +971,7 @@ resource: SiloUser "silo2-user" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: SshKey "silo2-user-ssh-key" @@ -921,6 +986,7 @@ resource: SshKey "silo2-user-ssh-key" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: SiloGroup "silo2-group" @@ -935,6 +1001,7 @@ resource: SiloGroup "silo2-group" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: SiloImage "silo2-silo-image" @@ -949,6 +1016,7 @@ resource: SiloImage "silo2-silo-image" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: SiloUser "silo2-user": session list @@ -963,6 +1031,7 @@ resource: SiloUser "silo2-user": session list silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: SiloUser "silo2-user": token list @@ -977,6 +1046,7 @@ resource: SiloUser "silo2-user": token list silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Image "silo2-image" @@ -991,6 +1061,7 @@ resource: Image "silo2-image" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Project "silo2-proj1" @@ -1005,6 +1076,7 @@ resource: Project "silo2-proj1" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Disk "silo2-proj1-disk1" @@ -1019,6 +1091,7 @@ resource: Disk "silo2-proj1-disk1" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: AffinityGroup "silo2-proj1-affinity-group1" @@ -1033,6 +1106,7 @@ resource: AffinityGroup "silo2-proj1-affinity-group1" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: AntiAffinityGroup "silo2-proj1-anti-affinity-group1" @@ -1047,6 +1121,7 @@ resource: AntiAffinityGroup "silo2-proj1-anti-affinity-group1" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Instance "silo2-proj1-instance1" @@ -1061,6 +1136,7 @@ resource: Instance "silo2-proj1-instance1" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: InstanceNetworkInterface "silo2-proj1-instance1-nic1" @@ -1075,6 +1151,7 @@ resource: InstanceNetworkInterface "silo2-proj1-instance1-nic1" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Vpc "silo2-proj1-vpc1" @@ -1089,6 +1166,7 @@ resource: Vpc "silo2-proj1-vpc1" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: VpcSubnet "silo2-proj1-vpc1-subnet1" @@ -1103,6 +1181,7 @@ resource: VpcSubnet "silo2-proj1-vpc1-subnet1" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Snapshot "silo2-proj1-disk1-snapshot1" @@ -1117,6 +1196,7 @@ resource: Snapshot "silo2-proj1-disk1-snapshot1" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: ProjectImage "silo2-proj1-image1" @@ -1131,6 +1211,7 @@ resource: ProjectImage "silo2-proj1-image1" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: FloatingIp "silo2-proj1-fip1" @@ -1145,6 +1226,7 @@ resource: FloatingIp "silo2-proj1-fip1" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: InternetGateway "silo2-proj1-igw1" @@ -1159,6 +1241,7 @@ resource: InternetGateway "silo2-proj1-igw1" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: InternetGatewayIpPool "silo2-proj1-igw1-pool1" @@ -1173,6 +1256,7 @@ resource: InternetGatewayIpPool "silo2-proj1-igw1-pool1" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: InternetGatewayIpAddress "silo2-proj1-igw1-address1" @@ -1187,6 +1271,7 @@ resource: InternetGatewayIpAddress "silo2-proj1-igw1-address1" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: ScimClientBearerToken id "7885144e-9c75-47f7-a97d-7dfc58e1186c" @@ -1201,6 +1286,7 @@ resource: ScimClientBearerToken id "7885144e-9c75-47f7-a97d-7dfc58e1186c" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Silo "silo2": scim client bearer token list @@ -1215,6 +1301,7 @@ resource: Silo "silo2": scim client bearer token list silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Rack id "c037e882-8b6d-c8b5-bef4-97e848eb0a50" @@ -1229,6 +1316,7 @@ resource: Rack id "c037e882-8b6d-c8b5-bef4-97e848eb0a50" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Sled id "8a785566-adaf-c8d8-e886-bee7f9b73ca7" @@ -1243,6 +1331,7 @@ resource: Sled id "8a785566-adaf-c8d8-e886-bee7f9b73ca7" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Zpool id "aaaaaaaa-1233-af7d-9220-afe1d8090900" @@ -1257,6 +1346,7 @@ resource: Zpool id "aaaaaaaa-1233-af7d-9220-afe1d8090900" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Service id "6b1f15ee-d6b3-424c-8436-94413a0b682d" @@ -1271,6 +1361,7 @@ resource: Service id "6b1f15ee-d6b3-424c-8436-94413a0b682d" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Service id "7f7bb301-5dc9-41f1-ab29-d369f4835079" @@ -1285,6 +1376,7 @@ resource: Service id "7f7bb301-5dc9-41f1-ab29-d369f4835079" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: PhysicalDisk id "c9f923f6-caf3-4c83-96f9-8ffe8c627dd2" @@ -1299,6 +1391,7 @@ resource: PhysicalDisk id "c9f923f6-caf3-4c83-96f9-8ffe8c627dd2" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: SupportBundle id "d9f923f6-caf3-4c83-96f9-8ffe8c627dd2" @@ -1313,6 +1406,7 @@ resource: SupportBundle id "d9f923f6-caf3-4c83-96f9-8ffe8c627dd2" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: DeviceAuthRequest "a-device-user-code" @@ -1327,6 +1421,7 @@ resource: DeviceAuthRequest "a-device-user-code" silo1-proj1-collaborator ✘ ✔ ✘ ✔ ✔ ✔ ✘ ✔ silo1-proj1-viewer ✘ ✔ ✘ ✔ ✔ ✔ ✘ ✔ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: DeviceAccessToken id "3b80c7f9-bee0-4b42-8550-6cdfc74dafdb" @@ -1341,6 +1436,7 @@ resource: DeviceAccessToken id "3b80c7f9-bee0-4b42-8550-6cdfc74dafdb" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Blueprint id "b9e923f6-caf3-4c83-96f9-8ffe8c627dd2" @@ -1355,6 +1451,7 @@ resource: Blueprint id "b9e923f6-caf3-4c83-96f9-8ffe8c627dd2" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: TufRepo id "3c52d72f-cbf7-4951-a62f-a4154e74da87" @@ -1369,6 +1466,7 @@ resource: TufRepo id "3c52d72f-cbf7-4951-a62f-a4154e74da87" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: TufArtifact id "6827813e-bfaa-4205-9b9f-9f7901e4aab1" @@ -1383,6 +1481,7 @@ resource: TufArtifact id "6827813e-bfaa-4205-9b9f-9f7901e4aab1" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: TufTrustRoot id "b2c043c7-5eaa-40b5-a0a2-cdf97b2e66b3" @@ -1397,6 +1496,7 @@ resource: TufTrustRoot id "b2c043c7-5eaa-40b5-a0a2-cdf97b2e66b3" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: AddressLot id "43259fdc-c5c0-4a21-8b1d-2f673ad00d93" @@ -1411,6 +1511,7 @@ resource: AddressLot id "43259fdc-c5c0-4a21-8b1d-2f673ad00d93" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: LoopbackAddress id "9efbf1b1-16f9-45ab-864a-f7ebe501ae5b" @@ -1425,6 +1526,7 @@ resource: LoopbackAddress id "9efbf1b1-16f9-45ab-864a-f7ebe501ae5b" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: Alert id "31cb17da-4164-4cbf-b9a3-b3e4a687c08b" @@ -1439,6 +1541,7 @@ resource: Alert id "31cb17da-4164-4cbf-b9a3-b3e4a687c08b" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: AlertReceiver "webhooked-on-phonics" @@ -1453,6 +1556,7 @@ resource: AlertReceiver "webhooked-on-phonics" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ resource: WebhookSecret id "0c3e55cb-fcee-46e9-a2e3-0901dbd3b997" @@ -1467,6 +1571,7 @@ resource: WebhookSecret id "0c3e55cb-fcee-46e9-a2e3-0901dbd3b997" silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! + scim ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ ACTIONS: diff --git a/nexus/tests/integration_tests/scim.rs b/nexus/tests/integration_tests/scim.rs index f63918848a4..4946f586e6f 100644 --- a/nexus/tests/integration_tests/scim.rs +++ b/nexus/tests/integration_tests/scim.rs @@ -511,58 +511,3 @@ async fn test_scim_client_no_auth_with_expired_token( .await .expect("expected 401"); } - -/// Test that a SCIM authenticated actor cannot read a Silo's projects -#[nexus_test] -async fn test_scim_client_no_read_project(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 a token - - { - let now = Utc::now(); - - let new_token = ScimClientBearerToken { - id: Uuid::new_v4(), - time_created: now, - time_deleted: None, - time_expires: None, - 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 404 - - RequestBuilder::new(client, Method::GET, "/v1/projects") - .header( - http::header::AUTHORIZATION, - String::from("Bearer oxide-scim-testpost"), - ) - .expect_status(Some(StatusCode::NOT_FOUND)) - .execute() - .await - .expect("expected 404"); -} From 8995f83454e1702885b19f8e100b22ed9562317e Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Mon, 20 Oct 2025 17:23:14 +0000 Subject: [PATCH 12/18] more comment --- nexus/db-queries/src/policy_test/mod.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/nexus/db-queries/src/policy_test/mod.rs b/nexus/db-queries/src/policy_test/mod.rs index 6ec50e7f5b3..e823a675124 100644 --- a/nexus/db-queries/src/policy_test/mod.rs +++ b/nexus/db-queries/src/policy_test/mod.rs @@ -209,7 +209,9 @@ async fn test_iam_roles_behavior() { ), ))); - // Create a SCIM Actor for this silo. + // Create a SCIM Actor for this silo. It should have permission to access + // none of the resources in a silo (other than query the database and use + // the audit log). let user_log = logctx.log.new(o!( "actor" => "scim", )); From 723848d4b6d51d977e04d43156d6e9aec5f5764f Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Tue, 21 Oct 2025 16:16:06 +0000 Subject: [PATCH 13/18] Scim actor does have a silo! --- nexus/auth/src/authn/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nexus/auth/src/authn/mod.rs b/nexus/auth/src/authn/mod.rs index 77c6a5ce685..38511e89335 100644 --- a/nexus/auth/src/authn/mod.rs +++ b/nexus/auth/src/authn/mod.rs @@ -398,7 +398,7 @@ impl Actor { match self { Actor::UserBuiltin { .. } => None, Actor::SiloUser { silo_id, .. } => Some(*silo_id), - Actor::Scim { .. } => None, // XXX scim actor does have a silo id? + Actor::Scim { .. } => Some(*silo_id), } } From 84ae83b294596edcc6e019a0228a14117e0b4951 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Tue, 21 Oct 2025 16:18:03 +0000 Subject: [PATCH 14/18] fleet admins do need to create and retrieve scim client bearer tokens --- nexus/db-lookup/src/lookup.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nexus/db-lookup/src/lookup.rs b/nexus/db-lookup/src/lookup.rs index 6fc641a5044..65a320050fe 100644 --- a/nexus/db-lookup/src/lookup.rs +++ b/nexus/db-lookup/src/lookup.rs @@ -929,7 +929,7 @@ lookup_resource! { lookup_by_name = false, soft_deletes = true, primary_key_columns = [ { column_name = "id", rust_type = Uuid } ], - visible_outside_silo = true // XXX needed? + visible_outside_silo = true } // Helpers for unifying the interfaces around images From 493af8816e0a1650d755b2e7f15b92e24266d355 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Tue, 21 Oct 2025 16:22:30 +0000 Subject: [PATCH 15/18] remove wrong comment --- nexus/auth/src/authz/roles.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/nexus/auth/src/authz/roles.rs b/nexus/auth/src/authz/roles.rs index 6c12754670f..c510d1d5917 100644 --- a/nexus/auth/src/authz/roles.rs +++ b/nexus/auth/src/authz/roles.rs @@ -165,7 +165,6 @@ async fn load_directly_attached_roles( "resource_type" => ?resource_type, "resource_id" => resource_id.to_string(), ); - // XXX Ok, or an error? return Ok(()); }; From 5d4026b30b58f6245c4413e78e1f502e3abcc5fa Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Tue, 21 Oct 2025 16:48:08 +0000 Subject: [PATCH 16/18] actually commit stuff --- Cargo.lock | 2 +- nexus/auth/src/authn/mod.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8190cf5e590..05eaad7ae57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6676,8 +6676,8 @@ dependencies = [ "ref-cast", "regex", "rustls 0.22.4", - "scim2-rs", "schemars 0.8.22", + "scim2-rs", "semver 1.0.27", "serde", "serde_json", diff --git a/nexus/auth/src/authn/mod.rs b/nexus/auth/src/authn/mod.rs index 38511e89335..e77bb8df3dd 100644 --- a/nexus/auth/src/authn/mod.rs +++ b/nexus/auth/src/authn/mod.rs @@ -398,7 +398,7 @@ impl Actor { match self { Actor::UserBuiltin { .. } => None, Actor::SiloUser { silo_id, .. } => Some(*silo_id), - Actor::Scim { .. } => Some(*silo_id), + Actor::Scim { silo_id } => Some(*silo_id), } } From 01520c8fef6f3e0e1ca448fd1d7d149049a4fca9 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Wed, 22 Oct 2025 20:49:54 +0000 Subject: [PATCH 17/18] scim actors need to have a blank SiloAuthnPolicy --- nexus/auth/src/authn/mod.rs | 14 +++++++++++--- nexus/db-queries/src/policy_test/mod.rs | 6 +++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/nexus/auth/src/authn/mod.rs b/nexus/auth/src/authn/mod.rs index e77bb8df3dd..e9e9a25848c 100644 --- a/nexus/auth/src/authn/mod.rs +++ b/nexus/auth/src/authn/mod.rs @@ -146,11 +146,17 @@ impl Context { }) } - /// Returns the `SiloAuthnPolicy` for the authenticated actor's Silo, if any + /// Returns the `SiloAuthnPolicy` for the authenticated actor's Silo, if + /// any. pub fn silo_authn_policy(&self) -> Option<&SiloAuthnPolicy> { match &self.kind { Kind::Unauthenticated => None, - Kind::Authenticated(_, policy) => policy.as_ref(), + Kind::Authenticated(_, policy) => { + // note: must be Some if `details.actor` is also Some, otherwise + // you'll see a 500 in the impl of + // `ApiResourceWithRoles::conferred_roles_by` for Fleet. + policy.as_ref() + } } } @@ -268,7 +274,9 @@ impl Context { Context { kind: Kind::Authenticated( Details { actor: Actor::Scim { silo_id } }, - None, + // This should never be non-empty, we don't want the SCIM user + // to ever have associated roles. + Some(SiloAuthnPolicy::new(BTreeMap::default())), ), schemes_tried: Vec::new(), } diff --git a/nexus/db-queries/src/policy_test/mod.rs b/nexus/db-queries/src/policy_test/mod.rs index e823a675124..b99a653e37e 100644 --- a/nexus/db-queries/src/policy_test/mod.rs +++ b/nexus/db-queries/src/policy_test/mod.rs @@ -336,11 +336,11 @@ async fn authorize_one_resource( "result" => ?result, ); let summary = match result { - Ok(_) => '\u{2714}', + Ok(_) => '\u{2714}', // ✔ Err(Error::Forbidden) - | Err(Error::ObjectNotFound { .. }) => '\u{2718}', + | Err(Error::ObjectNotFound { .. }) => '\u{2718}', // ✘ Err(Error::Unauthenticated { .. }) => '!', - Err(_) => '\u{26a0}', + Err(_) => '\u{26a0}', // ⚠ }; write!(out, " {:>2}", summary)?; } From bb98724619e0e7e74e12e102ffcf5968c375ad09 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Wed, 22 Oct 2025 21:11:21 +0000 Subject: [PATCH 18/18] good enough comment --- nexus/auth/src/authz/actor.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nexus/auth/src/authz/actor.rs b/nexus/auth/src/authz/actor.rs index f0f409de090..440064532a6 100644 --- a/nexus/auth/src/authz/actor.rs +++ b/nexus/auth/src/authz/actor.rs @@ -122,6 +122,9 @@ impl oso::PolarClass for AuthenticatedActor { }, "USER_INTERNAL_API", ) + // This is meant to guard against the SCIM actor being able to see + // the full resource hierarchy due to implicit grants in the Polar + // file. There are "if actor.is_user" guards to prevent this. .add_attribute_getter("is_user", |a: &AuthenticatedActor| { match a.actor { authn::Actor::SiloUser { .. } => true,