diff --git a/crates/core-types/src/identity/group.rs b/crates/core-types/src/identity/group.rs index 6e582a46c..5ea599a70 100644 --- a/crates/core-types/src/identity/group.rs +++ b/crates/core-types/src/identity/group.rs @@ -68,6 +68,24 @@ pub struct GroupListParameters { pub name: Option, } +#[derive(Builder, Clone, Debug, Default, PartialEq)] +#[builder(build_fn(error = "BuilderError"))] +#[builder(setter(strip_option, into))] +pub struct GroupUpdate { + /// New description. `None` = unchanged, `Some(None)` = clear. + #[builder(default)] + pub description: Option>, + + /// Additional group properties. The provider merges this into the + /// existing `extra` before persisting; an empty map means unchanged. + #[builder(default)] + pub extra: HashMap, + + /// New name. `None` = unchanged. + #[builder(default)] + pub name: Option, +} + #[derive(Builder, Clone, Debug, Default, PartialEq)] #[builder(build_fn(error = "BuilderError"))] #[builder(setter(strip_option, into))] diff --git a/crates/core/src/identity/backend.rs b/crates/core/src/identity/backend.rs index 9b74c32bd..d84b1678d 100644 --- a/crates/core/src/identity/backend.rs +++ b/crates/core/src/identity/backend.rs @@ -266,6 +266,44 @@ pub trait IdentityBackend: Send + Sync { user_id: &'a str, ) -> Result, IdentityProviderError>; + /// List the IDs of users that are members of a group. + /// + /// # Parameters + /// - `state`: The service state. + /// - `group_id`: The ID of the group. + async fn list_users_of_group<'a>( + &self, + state: &ServiceState, + group_id: &'a str, + ) -> Result, IdentityProviderError>; + + /// Find any group in `domain_id` whose name matches `name`, + /// case-insensitively, regardless of which realm (or nothing) created it. + /// + /// # Parameters + /// - `state`: The service state. + /// - `domain_id`: The domain to search within. + /// - `name`: The name to match, case-insensitively. + async fn find_group_by_name_ci<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + name: &'a str, + ) -> Result, IdentityProviderError>; + + /// Update group. + /// + /// # Parameters + /// - `state`: The service state. + /// - `group_id`: The ID of the group to update. + /// - `group`: The group update request. + async fn update_group<'a>( + &self, + state: &ServiceState, + group_id: &'a str, + group: GroupUpdate, + ) -> Result; + /// Remove the user from the group. /// /// # Parameters diff --git a/crates/core/src/identity/provider_api.rs b/crates/core/src/identity/provider_api.rs index 2db07dd52..bccb4fb13 100644 --- a/crates/core/src/identity/provider_api.rs +++ b/crates/core/src/identity/provider_api.rs @@ -281,6 +281,44 @@ pub trait IdentityApi: Send + Sync { user_id: &'a str, ) -> Result, IdentityProviderError>; + /// List the IDs of users that are members of a group. + /// + /// # Parameters + /// - `state`: The service state. + /// - `group_id`: The ID of the group. + async fn list_users_of_group<'a>( + &self, + ctx: &ExecutionContext<'a>, + group_id: &'a str, + ) -> Result, IdentityProviderError>; + + /// Find any group in `domain_id` whose name matches `name`, + /// case-insensitively, regardless of which realm (or nothing) created it. + /// + /// # Parameters + /// - `state`: The service state. + /// - `domain_id`: The domain to search within. + /// - `name`: The name to match, case-insensitively. + async fn find_group_by_name_ci<'a>( + &self, + ctx: &ExecutionContext<'a>, + domain_id: &'a str, + name: &'a str, + ) -> Result, IdentityProviderError>; + + /// Update group. + /// + /// # Parameters + /// - `state`: The service state. + /// - `group_id`: The ID of the group to update. + /// - `group`: The group update request. + async fn update_group<'a>( + &self, + ctx: &ExecutionContext<'a>, + group_id: &'a str, + group: GroupUpdate, + ) -> Result; + /// Remove the user from the single group. /// /// # Parameters diff --git a/crates/core/src/identity/service.rs b/crates/core/src/identity/service.rs index 20cbb0e9c..e2cec1698 100644 --- a/crates/core/src/identity/service.rs +++ b/crates/core/src/identity/service.rs @@ -906,6 +906,87 @@ impl IdentityApi for IdentityService { .await } + /// List the IDs of users that are members of a group. + /// + /// # Parameters + /// - `state`: The service state. + /// - `group_id`: The ID of the group. + async fn list_users_of_group<'a>( + &self, + ctx: &ExecutionContext<'a>, + group_id: &'a str, + ) -> Result, IdentityProviderError> { + self.backend_driver + .list_users_of_group(ctx.state(), group_id) + .await + } + + /// Find any group in `domain_id` whose name matches `name`, + /// case-insensitively, regardless of which realm (or nothing) created it. + /// + /// # Parameters + /// - `state`: The service state. + /// - `domain_id`: The domain to search within. + /// - `name`: The name to match, case-insensitively. + async fn find_group_by_name_ci<'a>( + &self, + ctx: &ExecutionContext<'a>, + domain_id: &'a str, + name: &'a str, + ) -> Result, IdentityProviderError> { + self.backend_driver + .find_group_by_name_ci(ctx.state(), domain_id, name) + .await + } + + /// Update group. + /// + /// # Parameters + /// - `state`: The service state. + /// - `group_id`: The ID of the group to update. + /// - `group`: The group update request. + async fn update_group<'a>( + &self, + ctx: &ExecutionContext<'a>, + group_id: &'a str, + group: GroupUpdate, + ) -> Result { + let group = if let Some(vsc) = ctx.ctx() { + let backend_driver = &self.backend_driver; + let state = ctx.state(); + let group_id_clone = group_id.to_string(); + crate::audited_op! { + dispatcher: &ctx.state().event_dispatcher, + ctx: vsc, + event: Event::new( + Operation::Update, + EventPayload::Group { id: group_id_clone }, + ), + operation: async { + backend_driver.update_group(state, group_id, group).await + }, + on_audit_error: |_: AuditDispatchError| IdentityProviderError::Driver("audit dispatch failed".into()), + }? + } else { + let group = self + .backend_driver + .update_group(ctx.state(), group_id, group) + .await?; + ctx.state() + .event_dispatcher + .emit(Event::new( + Operation::Update, + EventPayload::Group { + id: group.id.clone(), + }, + )) + .await; + group + }; + + Ok(group) + } + /// Remove the user from the group. /// /// # Parameters diff --git a/crates/core/src/mocks.rs b/crates/core/src/mocks.rs index 16ff8d8c9..40e9ff3a5 100644 --- a/crates/core/src/mocks.rs +++ b/crates/core/src/mocks.rs @@ -613,6 +613,26 @@ mod identity { user_id: &'a str, ) -> Result, IdentityProviderError>; + async fn list_users_of_group<'a>( + &self, + ctx: &ExecutionContext<'a>, + group_id: &'a str, + ) -> Result, IdentityProviderError>; + + async fn find_group_by_name_ci<'a>( + &self, + ctx: &ExecutionContext<'a>, + domain_id: &'a str, + name: &'a str, + ) -> Result, IdentityProviderError>; + + async fn update_group<'a>( + &self, + ctx: &ExecutionContext<'a>, + group_id: &'a str, + group: GroupUpdate, + ) -> Result; + async fn list_users<'a>( &self, ctx: &ExecutionContext<'a>, diff --git a/crates/identity-driver-sql/src/group.rs b/crates/identity-driver-sql/src/group.rs index d43cc68b8..044f5ccf3 100644 --- a/crates/identity-driver-sql/src/group.rs +++ b/crates/identity-driver-sql/src/group.rs @@ -19,13 +19,17 @@ use crate::entity::group; mod create; mod delete; +mod find_by_name; mod get; mod list; +mod update; pub use create::create; pub use delete::delete; +pub use find_by_name::find_by_name_ci; pub use get::get; pub use list::list; +pub use update::update; impl From for Group { fn from(value: group::Model) -> Self { diff --git a/crates/identity-driver-sql/src/group/find_by_name.rs b/crates/identity-driver-sql/src/group/find_by_name.rs new file mode 100644 index 000000000..cfd15f059 --- /dev/null +++ b/crates/identity-driver-sql/src/group/find_by_name.rs @@ -0,0 +1,83 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! Domain-wide, case-insensitive group name lookup (ADR 0024 §3.D). + +use sea_orm::DatabaseConnection; +use sea_orm::entity::*; +use sea_orm::query::*; +use sea_orm::sea_query::{Expr, Func}; + +use openstack_keystone_core::error::DbContextExt; +use openstack_keystone_core::identity::IdentityProviderError; + +use crate::entity::group as db_group; + +/// Find a group in `domain_id` whose `name` matches `name` case-insensitively, +/// regardless of which realm (or nothing) created it. +/// +/// # Returns +/// A `Result` containing the matched group's ID, if any, or an `Error`. +#[tracing::instrument(skip(db))] +pub async fn find_by_name_ci( + db: &DatabaseConnection, + domain_id: &str, + name: &str, +) -> Result, IdentityProviderError> { + let name_lower = name.to_lowercase(); + let found = db_group::Entity::find() + .filter(db_group::Column::DomainId.eq(domain_id)) + .filter(Expr::expr(Func::lower(Expr::col(db_group::Column::Name))).eq(name_lower)) + .one(db) + .await + .context("checking group name collision")?; + Ok(found.map(|g| g.id)) +} + +#[cfg(test)] +mod tests { + use sea_orm::{DatabaseBackend, MockDatabase}; + + use super::*; + use crate::group::tests::get_group_mock; + + #[tokio::test] + async fn test_find_by_name_ci_match() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![get_group_mock("1")]]) + .into_connection(); + + assert_eq!( + find_by_name_ci(&db, "foo_domain", "GROUP").await.unwrap(), + Some("1".to_string()) + ); + + // Verify the comparison is actually done case-insensitively via + // LOWER(), against the lowercased input value. + let log = db.into_transaction_log(); + let sql = &log[0].statements()[0].sql; + assert!(sql.contains("LOWER"), "query must lower() the name column"); + } + + #[tokio::test] + async fn test_find_by_name_ci_no_match() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([Vec::::new()]) + .into_connection(); + + assert_eq!( + find_by_name_ci(&db, "foo_domain", "missing").await.unwrap(), + None + ); + } +} diff --git a/crates/identity-driver-sql/src/group/update.rs b/crates/identity-driver-sql/src/group/update.rs new file mode 100644 index 000000000..7384b1df3 --- /dev/null +++ b/crates/identity-driver-sql/src/group/update.rs @@ -0,0 +1,109 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! Update group properties. + +use sea_orm::DatabaseConnection; +use sea_orm::entity::*; + +use openstack_keystone_core::error::DbContextExt; +use openstack_keystone_core::identity::IdentityProviderError; +use openstack_keystone_core_types::identity::{Group, GroupUpdate}; + +use crate::entity::group as db_group; + +/// Update an existing group. +/// +/// # Parameters +/// - `db`: The database connection. +/// - `group_id`: The ID of the group to update. +/// - `group`: The group update request. +/// +/// # Returns +/// A `Result` containing the updated `Group` if successful, or an `Error`. +#[tracing::instrument(skip(db))] +pub async fn update( + db: &DatabaseConnection, + group_id: &str, + group: GroupUpdate, +) -> Result { + let existing = db_group::Entity::find_by_id(group_id) + .one(db) + .await + .context("fetching group for update")? + .ok_or_else(|| IdentityProviderError::GroupNotFound(group_id.to_string()))?; + + let mut update_model: db_group::ActiveModel = existing.into(); + + if let Some(name) = group.name { + update_model.name = Set(name); + } + + if let Some(description) = group.description { + update_model.description = Set(description); + } + + // The provider passes the full desired `extra`; the driver only + // persists it (PUT is a full-replace, not a patch). + if !group.extra.is_empty() { + update_model.extra = Set(Some(serde_json::to_string(&group.extra)?)); + } + + let updated: db_group::Model = update_model + .update(db) + .await + .context("updating group entry")?; + + Ok(updated.into()) +} + +#[cfg(test)] +mod tests { + use sea_orm::{DatabaseBackend, MockDatabase}; + + use super::*; + use crate::group::tests::get_group_mock; + + #[tokio::test] + async fn test_update() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![get_group_mock("1")], vec![get_group_mock("1")]]) + .into_connection(); + + let req = GroupUpdate { + name: Some("new_name".into()), + description: None, + extra: Default::default(), + }; + let result = update(&db, "1", req).await.unwrap(); + assert_eq!(result, get_group_mock("1").into()); + } + + #[tokio::test] + async fn test_update_not_found() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([Vec::::new()]) + .into_connection(); + + let req = GroupUpdate { + name: Some("new_name".into()), + description: None, + extra: Default::default(), + }; + let result = update(&db, "missing", req).await; + assert!(matches!( + result, + Err(IdentityProviderError::GroupNotFound(_)) + )); + } +} diff --git a/crates/identity-driver-sql/src/lib.rs b/crates/identity-driver-sql/src/lib.rs index 78344e3a6..b8663db31 100644 --- a/crates/identity-driver-sql/src/lib.rs +++ b/crates/identity-driver-sql/src/lib.rs @@ -399,6 +399,69 @@ impl IdentityBackend for SqlBackend { Ok(user_group::list_user_groups(&state.db, user_id, &cutoff_time).await?) } + /// List the IDs of users that are members of a group. + /// + /// # Parameters + /// - `state`: The service state. + /// - `group_id`: The ID of the group. + /// + /// # Returns + /// A `Result` containing a list of member user IDs, or an `Error`. + #[tracing::instrument(skip(self, state))] + async fn list_users_of_group<'a>( + &self, + state: &ServiceState, + group_id: &'a str, + ) -> Result, IdentityProviderError> { + let cutoff_time = state + .config_manager + .config + .read() + .await + .federation + .get_expiring_user_group_membership_cutof_datetime(); + Ok(user_group::list_group_user_ids(&state.db, group_id, &cutoff_time).await?) + } + + /// Find any group in `domain_id` whose name matches `name`, + /// case-insensitively. + /// + /// # Parameters + /// - `state`: The service state. + /// - `domain_id`: The domain to search within. + /// - `name`: The name to match, case-insensitively. + /// + /// # Returns + /// A `Result` containing the matched group's ID, if any, or an `Error`. + #[tracing::instrument(skip(self, state))] + async fn find_group_by_name_ci<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + name: &'a str, + ) -> Result, IdentityProviderError> { + group::find_by_name_ci(&state.db, domain_id, name).await + } + + /// Update group. + /// + /// # Parameters + /// - `state`: The service state. + /// - `group_id`: The ID of the group to update. + /// - `group`: The group update request. + /// + /// # Returns + /// A `Result` containing the updated `Group`, or an `Error`. + #[tracing::instrument(skip(self, state, group))] + async fn update_group<'a>( + &self, + state: &ServiceState, + group_id: &'a str, + group: GroupUpdate, + ) -> Result { + Ok(group::update(&state.db, group_id, group).await?) + } + /// Fetch users from the database. /// /// # Parameters diff --git a/crates/identity-driver-sql/src/user_group/list.rs b/crates/identity-driver-sql/src/user_group/list.rs index 1c94ea68a..b79aeaf73 100644 --- a/crates/identity-driver-sql/src/user_group/list.rs +++ b/crates/identity-driver-sql/src/user_group/list.rs @@ -26,6 +26,54 @@ use crate::entity::{ user_group_membership, }; +/// List the IDs of users that are members of a group (direct plus +/// not-yet-expired federated memberships), mirroring [`list_user_groups`]'s +/// reversed direction. +/// +/// # Parameters +/// - `db`: Database connection. +/// - `group_id`: Group ID. +/// - `last_verified_cutof`: Cutoff date for verifying expiring memberships. +/// +/// # Returns +/// A `Result` containing a `Vec` of member `User.id`s, or an `Error`. +#[tracing::instrument(skip_all)] +pub async fn list_group_user_ids>( + db: &DatabaseConnection, + group_id: S, + last_verified_cutof: &DateTime, +) -> Result, IdentityProviderError> { + let rows: Vec<(String,)> = user_group_membership::Entity::find() + .select_only() + .column(user_group_membership::Column::UserId) + .filter(user_group_membership::Column::GroupId.eq(group_id.as_ref())) + .into_tuple() + .all(db) + .await + .context("listing direct members of a group")?; + + let expiring_rows: Vec<(String,)> = expiring_user_group_membership::Entity::find() + .select_only() + .column(expiring_user_group_membership::Column::UserId) + .filter(expiring_user_group_membership::Column::GroupId.eq(group_id.as_ref())) + .filter( + expiring_user_group_membership::Column::LastVerified + .gt(last_verified_cutof.naive_utc()), + ) + .into_tuple() + .all(db) + .await + .context("listing expiring members of a group")?; + + let mut user_ids: Vec = rows.into_iter().map(|(id,)| id).collect(); + for (id,) in expiring_rows { + if !user_ids.contains(&id) { + user_ids.push(id); + } + } + Ok(user_ids) +} + /// List all groups the user is member of. /// /// Selects all groups with the ID in the list of user group memberships and @@ -80,7 +128,9 @@ pub async fn list_user_groups>( #[cfg(test)] mod tests { - use sea_orm::{DatabaseBackend, MockDatabase, Transaction}; + use std::collections::BTreeMap; + + use sea_orm::{DatabaseBackend, IntoMockRow, MockDatabase, Transaction, Value}; use openstack_keystone_config::Config; @@ -116,4 +166,22 @@ mod tests { ),] ); } + + #[tokio::test] + async fn test_list_group_user_ids() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([ + vec![BTreeMap::from([("user_id", Into::::into("user-1"))]).into_mock_row()], + vec![BTreeMap::from([("user_id", Into::::into("user-2"))]).into_mock_row()], + ]) + .into_connection(); + let expiring_datetime = Config::default() + .federation + .get_expiring_user_group_membership_cutof_datetime(); + + let ids = list_group_user_ids(&db, "group-1", &expiring_datetime) + .await + .unwrap(); + assert_eq!(ids, vec!["user-1".to_string(), "user-2".to_string()]); + } } diff --git a/crates/keystone/src/scim.rs b/crates/keystone/src/scim.rs index 689e33061..c5540a65d 100644 --- a/crates/keystone/src/scim.rs +++ b/crates/keystone/src/scim.rs @@ -30,6 +30,7 @@ use openstack_keystone_core::api::api_key_auth::ApiKeyAuth; use openstack_keystone_core::keystone::ServiceState; pub mod error; +mod group; pub mod types; mod user; @@ -56,4 +57,5 @@ pub fn router() -> Router { Router::new() .route("/{domain_id}/whoami", get(whoami)) .nest("/{domain_id}/Users", user::router()) + .nest("/{domain_id}/Groups", group::router()) } diff --git a/crates/keystone/src/scim/error.rs b/crates/keystone/src/scim/error.rs index 2877b80de..0f71dc00c 100644 --- a/crates/keystone/src/scim/error.rs +++ b/crates/keystone/src/scim/error.rs @@ -51,6 +51,10 @@ pub enum ScimApiError { /// `userName`/`displayName`/`externalId` collision (409, `scimType: /// "uniqueness"`, ADR 0024 §3.C/§3.D). Uniqueness(String), + /// Cross-realm/manual-user membership reference, or membership count + /// exceeding the §11 cap (400, `scimType: "invalidValue"`, ADR 0024 §7, + /// §11). + InvalidValue(String), /// Everything else — reuses the generic Keystone error envelope. Api(KeystoneApiError), } @@ -68,6 +72,16 @@ impl IntoResponse for ScimApiError { }), ) .into_response(), + Self::InvalidValue(detail) => ( + StatusCode::BAD_REQUEST, + Json(ScimErrorBody { + schemas: vec![SCIM_ERROR_SCHEMA.to_string()], + status: StatusCode::BAD_REQUEST.as_str().to_string(), + scim_type: Some("invalidValue".to_string()), + detail, + }), + ) + .into_response(), Self::Api(e) => e.into_response(), } } diff --git a/crates/keystone/src/scim/group.rs b/crates/keystone/src/scim/group.rs new file mode 100644 index 000000000..a364b40c1 --- /dev/null +++ b/crates/keystone/src/scim/group.rs @@ -0,0 +1,35 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! # `/SCIM/v2/{domain_id}/Groups` (ADR 0024 §3, §4, §6.B, §7) + +use axum::{Router, routing::get}; + +use openstack_keystone_core::keystone::ServiceState; + +mod create; +mod delete; +mod list; +mod membership; +mod show; +mod update; + +/// `Groups` sub-router, nested at `/{domain_id}/Groups` in [`super::router`]. +pub fn router() -> Router { + Router::new() + .route("/", get(list::list).post(create::create)) + .route( + "/{id}", + get(show::show).put(update::update).delete(delete::delete), + ) +} diff --git a/crates/keystone/src/scim/group/create.rs b/crates/keystone/src/scim/group/create.rs new file mode 100644 index 000000000..17d881a85 --- /dev/null +++ b/crates/keystone/src/scim/group/create.rs @@ -0,0 +1,477 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! `POST /SCIM/v2/{domain_id}/Groups` (ADR 0024 §3.C, §3.D, §4, §7, §11). + +use axum::{Json, extract::State, http::StatusCode}; +use serde_json::json; + +use openstack_keystone_core::api::KeystoneApiError; +use openstack_keystone_core::api::api_key_auth::ScimRealmAuth; +use openstack_keystone_core::auth::ExecutionContext; +use openstack_keystone_core_types::scim::{ + ScimResourceIndexCreate, ScimResourceProviderError, ScimResourceType, +}; + +use crate::keystone::ServiceState; +use crate::scim::error::ScimApiError; +use crate::scim::group::membership::validate_members_owned_by_realm; +use crate::scim::types::{MAX_GROUP_MEMBERS, ScimGroup, ScimGroupWrite}; + +pub(super) async fn create( + ScimRealmAuth { ctx, realm }: ScimRealmAuth, + State(state): State, + Json(req): Json, +) -> Result<(StatusCode, Json), ScimApiError> { + if req.display_name.trim().is_empty() { + return Err(KeystoneApiError::BadRequest("displayName is required".to_string()).into()); + } + + let member_ids = req.member_ids(); + // ADR 0024 §11: reject an oversized membership push before any storage + // mutation, not after partial application. + if member_ids.len() > MAX_GROUP_MEMBERS { + return Err(ScimApiError::InvalidValue(format!( + "members must not exceed {MAX_GROUP_MEMBERS} entries" + ))); + } + + let exec = ExecutionContext::from_auth(&state, &ctx); + + state + .policy_enforcer + .enforce( + "identity/scim/group/create", + &ctx, + json!({"group": {"domain_id": realm.domain_id}}), + None, + ) + .await?; + + // ADR 0024 §3.D: domain-wide, case-insensitive `displayName` collision + // check — regardless of which realm (or nothing) created the existing + // group. + if state + .provider + .get_identity_provider() + .find_group_by_name_ci(&exec, &realm.domain_id, &req.display_name) + .await? + .is_some() + { + return Err(ScimApiError::Uniqueness( + "displayName already exists within this domain".to_string(), + )); + } + + // ADR 0024 §7: every referenced member must already be owned by this + // same realm (a `ScimResourceIndex` for `ScimResourceType::User` under + // this `provider_id`) — checked before creating the group so a rejected + // membership reference doesn't leave an orphaned empty group behind. + validate_members_owned_by_realm( + &state, + &exec, + &realm.domain_id, + &realm.provider_id, + &member_ids, + ) + .await?; + + let group = state + .provider + .get_identity_provider() + .create_group(&exec, req.to_group_create(&realm.domain_id)) + .await?; + + let index = match state + .provider + .get_scim_resource_provider() + .create_index( + &exec, + ScimResourceIndexCreate { + domain_id: realm.domain_id.clone(), + provider_id: realm.provider_id.clone(), + resource_type: ScimResourceType::Group, + keystone_id: group.id.clone(), + external_id: req.external_id.clone(), + }, + ) + .await + { + Ok(index) => index, + Err(e) => { + // The index write failed (most likely a realm-scoped + // `externalId` collision, ADR 0024 §3.C) after the Identity + // group was already created. Best-effort compensating delete so + // the orphaned group isn't left dangling. + let _ = state + .provider + .get_identity_provider() + .delete_group(&exec, &group.id) + .await; + return Err(match e { + ScimResourceProviderError::Conflict(msg) => ScimApiError::Uniqueness(msg), + other => other.into(), + }); + } + }; + + if !member_ids.is_empty() { + let memberships = member_ids + .iter() + .map(|uid| (uid.as_str(), group.id.as_str())) + .collect(); + state + .provider + .get_identity_provider() + .add_users_to_groups(&exec, memberships) + .await?; + } + + Ok(( + StatusCode::CREATED, + Json(ScimGroup::from_domain(&group, &index, &member_ids)), + )) +} + +#[cfg(test)] +mod tests { + use axum::http::StatusCode; + use openstack_keystone_core::api::api_key_auth::ScimRealmContext; + use openstack_keystone_core::auth::ValidatedSecurityContext; + use openstack_keystone_core_types::auth::{ + AuthenticationContext, AuthzInfoBuilder, IdentityInfo, PrincipalInfo, ScopeInfo, + SecurityContext, UserIdentityInfoBuilder, + }; + use openstack_keystone_core_types::identity::Group; + use openstack_keystone_core_types::resource::Domain; + use openstack_keystone_core_types::scim::{ScimResourceIndex, ScimResourceProviderError}; + + use super::*; + use crate::api::tests::get_mocked_state; + use crate::identity::MockIdentityProvider; + use crate::provider::Provider; + use crate::scim::types::ScimGroupMember; + use crate::scim_resource::MockScimResourceProvider; + + fn domain_scoped_auth(domain_id: &str) -> ScimRealmAuth { + let user = UserIdentityInfoBuilder::default() + .user_id("scim-provisioner") + .build() + .unwrap(); + let authz = AuthzInfoBuilder::default() + .roles(Vec::new()) + .scope(ScopeInfo::Domain(Domain { + id: domain_id.to_string(), + name: String::new(), + description: None, + enabled: true, + extra: Default::default(), + })) + .build() + .unwrap(); + let sc = SecurityContext::test_build() + .authentication_context(AuthenticationContext::Password) + .principal(PrincipalInfo { + identity: IdentityInfo::User(user), + }) + .authorization(authz) + .build(); + ScimRealmAuth { + ctx: ValidatedSecurityContext::test_new(sc), + realm: ScimRealmContext { + domain_id: domain_id.to_string(), + provider_id: "okta-1".to_string(), + }, + } + } + + fn req(display_name: &str, members: Vec<&str>) -> ScimGroupWrite { + ScimGroupWrite { + schemas: vec![], + external_id: Some("ext-1".to_string()), + display_name: display_name.to_string(), + members: members + .into_iter() + .map(|v| ScimGroupMember { + value: v.to_string(), + }) + .collect(), + } + } + + #[tokio::test] + async fn test_create() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock + .expect_find_group_by_name_ci() + .returning(|_, _, _| Ok(None)); + identity_mock.expect_create_group().returning(|_, req| { + Ok(Group { + id: "group-1".to_string(), + domain_id: req.domain_id.clone(), + name: req.name.clone(), + description: None, + extra: Default::default(), + }) + }); + + let mut resource_mock = MockScimResourceProvider::default(); + resource_mock.expect_create_index().returning(|_, data| { + Ok(ScimResourceIndex { + domain_id: data.domain_id, + provider_id: data.provider_id, + resource_type: data.resource_type, + keystone_id: data.keystone_id, + external_id: data.external_id, + version: 0, + deprovisioned_at: None, + created_at: 1, + updated_at: 1, + }) + }); + + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_scim_resource(resource_mock), + true, + None, + ) + .await; + + let (status, Json(body)) = create( + domain_scoped_auth("domain-1"), + State(state), + Json(req("engineers", vec![])), + ) + .await + .unwrap(); + assert_eq!(status, StatusCode::CREATED); + assert_eq!(body.display_name, "engineers"); + assert_eq!(body.id, "group-1"); + } + + #[tokio::test] + async fn test_create_with_members() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock + .expect_find_group_by_name_ci() + .returning(|_, _, _| Ok(None)); + identity_mock.expect_create_group().returning(|_, req| { + Ok(Group { + id: "group-1".to_string(), + domain_id: req.domain_id.clone(), + name: req.name.clone(), + description: None, + extra: Default::default(), + }) + }); + identity_mock + .expect_add_users_to_groups() + .withf(|_, memberships| memberships == &vec![("user-1", "group-1")]) + .returning(|_, _| Ok(())); + + let mut resource_mock = MockScimResourceProvider::default(); + resource_mock + .expect_get_index() + .withf(|_, _, _, rt, id| *rt == ScimResourceType::User && id == "user-1") + .returning(|_, domain_id, provider_id, _, id| { + Ok(Some(ScimResourceIndex { + domain_id: domain_id.to_string(), + provider_id: provider_id.to_string(), + resource_type: ScimResourceType::User, + keystone_id: id.to_string(), + external_id: None, + version: 0, + deprovisioned_at: None, + created_at: 1, + updated_at: 1, + })) + }); + resource_mock.expect_create_index().returning(|_, data| { + Ok(ScimResourceIndex { + domain_id: data.domain_id, + provider_id: data.provider_id, + resource_type: data.resource_type, + keystone_id: data.keystone_id, + external_id: data.external_id, + version: 0, + deprovisioned_at: None, + created_at: 1, + updated_at: 1, + }) + }); + + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_scim_resource(resource_mock), + true, + None, + ) + .await; + + let (status, Json(body)) = create( + domain_scoped_auth("domain-1"), + State(state), + Json(req("engineers", vec!["user-1"])), + ) + .await + .unwrap(); + assert_eq!(status, StatusCode::CREATED); + assert_eq!(body.members.len(), 1); + assert_eq!(body.members[0].value, "user-1"); + } + + #[tokio::test] + async fn test_create_rejects_member_not_owned_by_realm() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock + .expect_find_group_by_name_ci() + .returning(|_, _, _| Ok(None)); + identity_mock.expect_create_group().never(); + + let mut resource_mock = MockScimResourceProvider::default(); + resource_mock + .expect_get_index() + .returning(|_, _, _, _, _| Ok(None)); + + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_scim_resource(resource_mock), + true, + None, + ) + .await; + + let result = create( + domain_scoped_auth("domain-1"), + State(state), + Json(req("engineers", vec!["user-from-elsewhere"])), + ) + .await; + assert!(matches!(result, Err(ScimApiError::InvalidValue(_)))); + } + + #[tokio::test] + async fn test_create_rejects_membership_cap_exceeded() { + let state = get_mocked_state(Provider::mocked_builder(), true, None).await; + + let members: Vec<&str> = Vec::new(); + let mut write = req("engineers", members); + write.members = (0..1001) + .map(|i| ScimGroupMember { + value: format!("user-{i}"), + }) + .collect(); + + let result = create(domain_scoped_auth("domain-1"), State(state), Json(write)).await; + assert!(matches!(result, Err(ScimApiError::InvalidValue(_)))); + } + + #[tokio::test] + async fn test_create_rejects_domain_wide_duplicate_display_name() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock + .expect_find_group_by_name_ci() + .returning(|_, _, _| Ok(Some("other-group".to_string()))); + + let state = get_mocked_state( + Provider::mocked_builder().mock_identity(identity_mock), + true, + None, + ) + .await; + + let result = create( + domain_scoped_auth("domain-1"), + State(state), + Json(req("engineers", vec![])), + ) + .await; + assert!(matches!(result, Err(ScimApiError::Uniqueness(_)))); + } + + #[tokio::test] + async fn test_create_rejects_realm_scoped_external_id_conflict() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock + .expect_find_group_by_name_ci() + .returning(|_, _, _| Ok(None)); + identity_mock.expect_create_group().returning(|_, req| { + Ok(Group { + id: "group-1".to_string(), + domain_id: req.domain_id.clone(), + name: req.name.clone(), + description: None, + extra: Default::default(), + }) + }); + identity_mock.expect_delete_group().returning(|_, _| Ok(())); + + let mut resource_mock = MockScimResourceProvider::default(); + resource_mock + .expect_create_index() + .returning(|_, _| Err(ScimResourceProviderError::Conflict("dup".to_string()))); + + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_scim_resource(resource_mock), + true, + None, + ) + .await; + + let result = create( + domain_scoped_auth("domain-1"), + State(state), + Json(req("engineers", vec![])), + ) + .await; + assert!(matches!(result, Err(ScimApiError::Uniqueness(_)))); + } + + #[tokio::test] + async fn test_create_policy_denied() { + let state = get_mocked_state(Provider::mocked_builder(), false, None).await; + + let result = create( + domain_scoped_auth("domain-1"), + State(state), + Json(req("engineers", vec![])), + ) + .await; + assert!(matches!( + result, + Err(ScimApiError::Api(KeystoneApiError::Forbidden { .. })) + )); + } + + #[tokio::test] + async fn test_create_rejects_empty_display_name() { + let state = get_mocked_state(Provider::mocked_builder(), true, None).await; + + let result = create( + domain_scoped_auth("domain-1"), + State(state), + Json(req(" ", vec![])), + ) + .await; + assert!(matches!( + result, + Err(ScimApiError::Api(KeystoneApiError::BadRequest(_))) + )); + } +} diff --git a/crates/keystone/src/scim/group/delete.rs b/crates/keystone/src/scim/group/delete.rs new file mode 100644 index 000000000..3a5a13a16 --- /dev/null +++ b/crates/keystone/src/scim/group/delete.rs @@ -0,0 +1,291 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! `DELETE /SCIM/v2/{domain_id}/Groups/{id}` — neutralize + tombstone (ADR +//! 0024 §6.B). Role assignments are cleared immediately; membership rows are +//! deliberately retained (forensic snapshot) until the janitor purge (a +//! later PR) reclaims the group past the retention window. + +use axum::{extract::Path, extract::State, http::StatusCode}; +use chrono::Utc; +use serde_json::json; + +use openstack_keystone_core::api::api_key_auth::ScimRealmAuth; +use openstack_keystone_core::auth::ExecutionContext; +use openstack_keystone_core_types::assignment::RoleAssignmentListParametersBuilder; +use openstack_keystone_core_types::events::{Event, EventPayload, Operation}; +use openstack_keystone_core_types::scim::{ScimResourceIndexUpdate, ScimResourceType}; + +use crate::keystone::ServiceState; +use crate::scim::error::ScimApiError; +use crate::scim::group::show::fetch_owned; + +pub(super) async fn delete( + ScimRealmAuth { ctx, realm }: ScimRealmAuth, + Path((_domain_id, id)): Path<(String, String)>, + State(state): State, +) -> Result { + let exec = ExecutionContext::from_auth(&state, &ctx); + let (existing_group, _existing_index) = + fetch_owned(&state, &exec, &realm.domain_id, &realm.provider_id, &id).await?; + + state + .policy_enforcer + .enforce( + "identity/scim/group/delete", + &ctx, + serde_json::Value::Null, + Some(json!({"group": existing_group})), + ) + .await?; + + // §6.B step 1: clear the group's role assignments — this is the + // security-relevant action, since a "deleted-looking" group that still + // grants roles would be a silent escalation path. + let params = RoleAssignmentListParametersBuilder::default() + .group_id(id.clone()) + .build()?; + let assignments = state + .provider + .get_assignment_provider() + .list_role_assignments(&exec, ¶ms) + .await?; + for assignment in assignments { + state + .provider + .get_assignment_provider() + .revoke_grant(&exec, assignment) + .await?; + } + + // §6.B step 2: stamp `deprovisioned_at` so subsequent reads under this + // realm treat the resource as absent (§3.C). Membership rows are + // deliberately left intact — clearing them would destroy the forensic + // snapshot (who belonged to the group at the moment of deletion) that + // the "preserve audit trail" rationale for not hard-deleting is meant to + // protect. + let now = Utc::now().timestamp(); + state + .provider + .get_scim_resource_provider() + .update_index( + &exec, + &realm.domain_id, + &realm.provider_id, + ScimResourceType::Group, + &id, + ScimResourceIndexUpdate { + external_id: None, + deprovisioned_at: Some(Some(now)), + }, + ) + .await?; + + // §9: emit a CADF `disable` event for the SCIM deprovisioning semantics. + state + .event_dispatcher + .emit(Event::new( + Operation::Disable, + EventPayload::Group { id: id.clone() }, + )) + .await; + + Ok(StatusCode::NO_CONTENT) +} + +#[cfg(test)] +mod tests { + use openstack_keystone_core::api::KeystoneApiError; + use openstack_keystone_core::api::api_key_auth::ScimRealmContext; + use openstack_keystone_core::auth::ValidatedSecurityContext; + use openstack_keystone_core_types::assignment::AssignmentBuilder; + use openstack_keystone_core_types::assignment::AssignmentType; + use openstack_keystone_core_types::auth::{ + AuthenticationContext, AuthzInfoBuilder, IdentityInfo, PrincipalInfo, ScopeInfo, + SecurityContext, UserIdentityInfoBuilder, + }; + use openstack_keystone_core_types::identity::Group; + use openstack_keystone_core_types::resource::Domain; + use openstack_keystone_core_types::scim::ScimResourceIndex; + + use super::*; + use crate::api::tests::get_mocked_state; + use crate::assignment::MockAssignmentProvider; + use crate::identity::MockIdentityProvider; + use crate::provider::Provider; + use crate::scim_resource::MockScimResourceProvider; + + fn domain_scoped_auth(domain_id: &str) -> ScimRealmAuth { + let user = UserIdentityInfoBuilder::default() + .user_id("scim-provisioner") + .build() + .unwrap(); + let authz = AuthzInfoBuilder::default() + .roles(Vec::new()) + .scope(ScopeInfo::Domain(Domain { + id: domain_id.to_string(), + name: String::new(), + description: None, + enabled: true, + extra: Default::default(), + })) + .build() + .unwrap(); + let sc = SecurityContext::test_build() + .authentication_context(AuthenticationContext::Password) + .principal(PrincipalInfo { + identity: IdentityInfo::User(user), + }) + .authorization(authz) + .build(); + ScimRealmAuth { + ctx: ValidatedSecurityContext::test_new(sc), + realm: ScimRealmContext { + domain_id: domain_id.to_string(), + provider_id: "okta-1".to_string(), + }, + } + } + + fn make_index(keystone_id: &str) -> ScimResourceIndex { + ScimResourceIndex { + domain_id: "domain-1".to_string(), + provider_id: "okta-1".to_string(), + resource_type: ScimResourceType::Group, + keystone_id: keystone_id.to_string(), + external_id: None, + version: 0, + deprovisioned_at: None, + created_at: 1, + updated_at: 1, + } + } + + #[tokio::test] + async fn test_delete_neutralizes_and_tombstones() { + let mut resource_mock = MockScimResourceProvider::default(); + resource_mock + .expect_get_index() + .returning(|_, _, _, _, id| Ok(Some(make_index(id)))); + resource_mock + .expect_update_index() + .withf(|_, _, _, _, _, update| matches!(update.deprovisioned_at, Some(Some(_)))) + .returning(|_, _, _, _, id, _| Ok(make_index(id))); + + let mut identity_mock = MockIdentityProvider::default(); + identity_mock.expect_get_group().returning(|_, id| { + Ok(Some(Group { + id: id.to_string(), + domain_id: "domain-1".to_string(), + name: "engineers".to_string(), + description: None, + extra: Default::default(), + })) + }); + + let mut assignment_mock = MockAssignmentProvider::default(); + assignment_mock + .expect_list_role_assignments() + .returning(|_, _| { + Ok(vec![ + AssignmentBuilder::default() + .actor_id("group-1") + .role_id("role-1") + .target_id("project-1") + .r#type(AssignmentType::GroupProject) + .inherited(false) + .build() + .unwrap(), + ]) + }); + assignment_mock + .expect_revoke_grant() + .returning(|_, _| Ok(())); + + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_scim_resource(resource_mock) + .mock_assignment(assignment_mock), + true, + None, + ) + .await; + + let status = delete( + domain_scoped_auth("domain-1"), + Path(("domain-1".to_string(), "group-1".to_string())), + State(state), + ) + .await + .unwrap(); + assert_eq!(status, StatusCode::NO_CONTENT); + } + + #[tokio::test] + async fn test_delete_not_owned_returns_404() { + let mut resource_mock = MockScimResourceProvider::default(); + resource_mock + .expect_get_index() + .returning(|_, _, _, _, _| Ok(None)); + + let state = get_mocked_state( + Provider::mocked_builder().mock_scim_resource(resource_mock), + true, + None, + ) + .await; + + let result = delete( + domain_scoped_auth("domain-1"), + Path(("domain-1".to_string(), "group-1".to_string())), + State(state), + ) + .await; + assert!(matches!( + result, + Err(ScimApiError::Api(KeystoneApiError::NotFound { .. })) + )); + } + + #[tokio::test] + async fn test_delete_idempotent_on_repeat() { + let mut resource_mock = MockScimResourceProvider::default(); + resource_mock + .expect_get_index() + .returning(|_, _, _, _, id| { + Ok(Some(ScimResourceIndex { + deprovisioned_at: Some(123), + ..make_index(id) + })) + }); + + let state = get_mocked_state( + Provider::mocked_builder().mock_scim_resource(resource_mock), + true, + None, + ) + .await; + + let result = delete( + domain_scoped_auth("domain-1"), + Path(("domain-1".to_string(), "group-1".to_string())), + State(state), + ) + .await; + assert!(matches!( + result, + Err(ScimApiError::Api(KeystoneApiError::NotFound { .. })) + )); + } +} diff --git a/crates/keystone/src/scim/group/list.rs b/crates/keystone/src/scim/group/list.rs new file mode 100644 index 000000000..c6e7d8028 --- /dev/null +++ b/crates/keystone/src/scim/group/list.rs @@ -0,0 +1,224 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! `GET /SCIM/v2/{domain_id}/Groups` (ADR 0024 §3, §5.D bare pagination). + +use axum::{Json, extract::Query, extract::State}; +use serde::Deserialize; +use serde_json::json; + +use openstack_keystone_core::api::api_key_auth::ScimRealmAuth; +use openstack_keystone_core::auth::ExecutionContext; +use openstack_keystone_core_types::scim::ScimResourceType; + +use crate::keystone::ServiceState; +use crate::scim::error::ScimApiError; +use crate::scim::types::{LIST_RESPONSE_SCHEMA, ScimGroup, ScimGroupListResponse}; + +#[derive(Debug, Deserialize, Default)] +pub(super) struct ListParams { + #[serde(default)] + start_index: Option, + #[serde(default)] + count: Option, +} + +pub(super) async fn list( + ScimRealmAuth { ctx, realm }: ScimRealmAuth, + State(state): State, + Query(params): Query, +) -> Result, ScimApiError> { + state + .policy_enforcer + .enforce( + "identity/scim/group/list", + &ctx, + json!({"group": {"domain_id": realm.domain_id}}), + None, + ) + .await?; + + let exec = ExecutionContext::from_auth(&state, &ctx); + + let indexes = state + .provider + .get_scim_resource_provider() + .list_index( + &exec, + &realm.domain_id, + &realm.provider_id, + ScimResourceType::Group, + ) + .await?; + + let start_index = params.start_index.unwrap_or(1).max(1); + let count = params.count.unwrap_or(200).min(200); + + let active_indexes: Vec<_> = indexes + .into_iter() + .filter(|i| i.deprovisioned_at.is_none()) + .collect(); + let total_results = active_indexes.len(); + + let mut resources = Vec::new(); + for index in active_indexes + .into_iter() + .skip(start_index.saturating_sub(1)) + .take(count) + { + if let Some(group) = state + .provider + .get_identity_provider() + .get_group(&exec, &index.keystone_id) + .await? + { + let member_ids = state + .provider + .get_identity_provider() + .list_users_of_group(&exec, &group.id) + .await?; + resources.push(ScimGroup::from_domain(&group, &index, &member_ids)); + } + } + + Ok(Json(ScimGroupListResponse { + schemas: vec![LIST_RESPONSE_SCHEMA.to_string()], + total_results, + start_index, + items_per_page: count, + resources, + })) +} + +#[cfg(test)] +mod tests { + use openstack_keystone_core::api::api_key_auth::ScimRealmContext; + use openstack_keystone_core::auth::ValidatedSecurityContext; + use openstack_keystone_core_types::auth::{ + AuthenticationContext, AuthzInfoBuilder, IdentityInfo, PrincipalInfo, ScopeInfo, + SecurityContext, UserIdentityInfoBuilder, + }; + use openstack_keystone_core_types::identity::Group; + use openstack_keystone_core_types::resource::Domain; + use openstack_keystone_core_types::scim::ScimResourceIndex; + + use super::*; + use crate::api::tests::get_mocked_state; + use crate::identity::MockIdentityProvider; + use crate::provider::Provider; + use crate::scim_resource::MockScimResourceProvider; + + fn domain_scoped_auth(domain_id: &str) -> ScimRealmAuth { + let user = UserIdentityInfoBuilder::default() + .user_id("scim-provisioner") + .build() + .unwrap(); + let authz = AuthzInfoBuilder::default() + .roles(Vec::new()) + .scope(ScopeInfo::Domain(Domain { + id: domain_id.to_string(), + name: String::new(), + description: None, + enabled: true, + extra: Default::default(), + })) + .build() + .unwrap(); + let sc = SecurityContext::test_build() + .authentication_context(AuthenticationContext::Password) + .principal(PrincipalInfo { + identity: IdentityInfo::User(user), + }) + .authorization(authz) + .build(); + ScimRealmAuth { + ctx: ValidatedSecurityContext::test_new(sc), + realm: ScimRealmContext { + domain_id: domain_id.to_string(), + provider_id: "okta-1".to_string(), + }, + } + } + + fn make_index(keystone_id: &str, deprovisioned: bool) -> ScimResourceIndex { + ScimResourceIndex { + domain_id: "domain-1".to_string(), + provider_id: "okta-1".to_string(), + resource_type: ScimResourceType::Group, + keystone_id: keystone_id.to_string(), + external_id: None, + version: 0, + deprovisioned_at: if deprovisioned { Some(1) } else { None }, + created_at: 1, + updated_at: 1, + } + } + + #[tokio::test] + async fn test_list_filters_deprovisioned() { + let mut resource_mock = MockScimResourceProvider::default(); + resource_mock.expect_list_index().returning(|_, _, _, _| { + Ok(vec![ + make_index("group-1", false), + make_index("group-2", true), + ]) + }); + + let mut identity_mock = MockIdentityProvider::default(); + identity_mock.expect_get_group().returning(|_, id| { + Ok(Some(Group { + id: id.to_string(), + domain_id: "domain-1".to_string(), + name: "engineers".to_string(), + description: None, + extra: Default::default(), + })) + }); + identity_mock + .expect_list_users_of_group() + .returning(|_, _| Ok(vec![])); + + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_scim_resource(resource_mock), + true, + None, + ) + .await; + + let result = list( + domain_scoped_auth("domain-1"), + State(state), + Query(ListParams::default()), + ) + .await + .unwrap(); + assert_eq!(result.resources.len(), 1); + assert_eq!(result.resources[0].id, "group-1"); + assert_eq!(result.total_results, 1); + } + + #[tokio::test] + async fn test_list_policy_denied() { + let state = get_mocked_state(Provider::mocked_builder(), false, None).await; + + let result = list( + domain_scoped_auth("domain-1"), + State(state), + Query(ListParams::default()), + ) + .await; + assert!(matches!(result, Err(ScimApiError::Api(_)))); + } +} diff --git a/crates/keystone/src/scim/group/membership.rs b/crates/keystone/src/scim/group/membership.rs new file mode 100644 index 000000000..5d6afe3ad --- /dev/null +++ b/crates/keystone/src/scim/group/membership.rs @@ -0,0 +1,54 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! Cross-realm & manual-user membership fencing (ADR 0024 §7). + +use openstack_keystone_core::auth::ExecutionContext; +use openstack_keystone_core_types::scim::ScimResourceType; + +use crate::keystone::ServiceState; +use crate::scim::error::ScimApiError; + +/// Reject a `members` list containing any `User.id` not owned by this same +/// realm — a reference to a user owned by a different realm, or to a +/// manually-created user with no `ScimResourceIndex` entry at all, is a +/// `400 Bad Request` (`scimType: "invalidValue"`), not a silent no-op or a +/// cross-realm existence leak. +pub(super) async fn validate_members_owned_by_realm( + state: &ServiceState, + exec: &ExecutionContext<'_>, + domain_id: &str, + provider_id: &str, + member_ids: &[String], +) -> Result<(), ScimApiError> { + for member_id in member_ids { + let owned = state + .provider + .get_scim_resource_provider() + .get_index( + exec, + domain_id, + provider_id, + ScimResourceType::User, + member_id, + ) + .await? + .is_some_and(|i| i.deprovisioned_at.is_none()); + if !owned { + return Err(ScimApiError::InvalidValue(format!( + "member {member_id} is not a user owned by this realm" + ))); + } + } + Ok(()) +} diff --git a/crates/keystone/src/scim/group/show.rs b/crates/keystone/src/scim/group/show.rs new file mode 100644 index 000000000..b3755b5df --- /dev/null +++ b/crates/keystone/src/scim/group/show.rs @@ -0,0 +1,263 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! `GET /SCIM/v2/{domain_id}/Groups/{id}` (ADR 0024 §3.C Ownership Fencing). + +use axum::{Json, extract::Path, extract::State}; +use serde_json::json; + +use openstack_keystone_core::api::KeystoneApiError; +use openstack_keystone_core::api::api_key_auth::ScimRealmAuth; +use openstack_keystone_core::auth::ExecutionContext; +use openstack_keystone_core_types::scim::ScimResourceType; + +use crate::keystone::ServiceState; +use crate::scim::error::ScimApiError; +use crate::scim::types::ScimGroup; + +/// Fetch `(Group, ScimResourceIndex)` for `id` under the caller's own realm, +/// enforcing the Ownership Fencing Algorithm (ADR 0024 §3.C): absent index, +/// or a `deprovisioned_at` stamp, is treated as `404 Not Found` — +/// indistinguishable from "does not exist", even if a same-ID resource +/// exists under a different realm or was created manually. +pub(super) async fn fetch_owned( + state: &ServiceState, + exec: &ExecutionContext<'_>, + domain_id: &str, + provider_id: &str, + id: &str, +) -> Result< + ( + openstack_keystone_core_types::identity::Group, + openstack_keystone_core_types::scim::ScimResourceIndex, + ), + KeystoneApiError, +> { + let index = state + .provider + .get_scim_resource_provider() + .get_index(exec, domain_id, provider_id, ScimResourceType::Group, id) + .await?; + let Some(index) = index.filter(|i| i.deprovisioned_at.is_none()) else { + return Err(KeystoneApiError::NotFound { + resource: "group".to_string(), + identifier: id.to_string(), + }); + }; + let group = state + .provider + .get_identity_provider() + .get_group(exec, id) + .await?; + let Some(group) = group else { + return Err(KeystoneApiError::NotFound { + resource: "group".to_string(), + identifier: id.to_string(), + }); + }; + Ok((group, index)) +} + +pub(super) async fn show( + ScimRealmAuth { ctx, realm }: ScimRealmAuth, + Path((_domain_id, id)): Path<(String, String)>, + State(state): State, +) -> Result, ScimApiError> { + let exec = ExecutionContext::from_auth(&state, &ctx); + let (group, index) = + fetch_owned(&state, &exec, &realm.domain_id, &realm.provider_id, &id).await?; + + state + .policy_enforcer + .enforce( + "identity/scim/group/show", + &ctx, + serde_json::Value::Null, + Some(json!({"group": group})), + ) + .await?; + + let member_ids = state + .provider + .get_identity_provider() + .list_users_of_group(&exec, &group.id) + .await?; + + Ok(Json(ScimGroup::from_domain(&group, &index, &member_ids))) +} + +#[cfg(test)] +mod tests { + use openstack_keystone_core::api::api_key_auth::ScimRealmContext; + use openstack_keystone_core::auth::ValidatedSecurityContext; + use openstack_keystone_core_types::auth::{ + AuthenticationContext, AuthzInfoBuilder, IdentityInfo, PrincipalInfo, ScopeInfo, + SecurityContext, UserIdentityInfoBuilder, + }; + use openstack_keystone_core_types::identity::Group; + use openstack_keystone_core_types::resource::Domain; + use openstack_keystone_core_types::scim::ScimResourceIndex; + + use super::*; + use crate::api::tests::get_mocked_state; + use crate::identity::MockIdentityProvider; + use crate::provider::Provider; + use crate::scim_resource::MockScimResourceProvider; + + pub(super) fn domain_scoped_auth(domain_id: &str) -> ScimRealmAuth { + let user = UserIdentityInfoBuilder::default() + .user_id("scim-provisioner") + .build() + .unwrap(); + let authz = AuthzInfoBuilder::default() + .roles(Vec::new()) + .scope(ScopeInfo::Domain(Domain { + id: domain_id.to_string(), + name: String::new(), + description: None, + enabled: true, + extra: Default::default(), + })) + .build() + .unwrap(); + let sc = SecurityContext::test_build() + .authentication_context(AuthenticationContext::Password) + .principal(PrincipalInfo { + identity: IdentityInfo::User(user), + }) + .authorization(authz) + .build(); + ScimRealmAuth { + ctx: ValidatedSecurityContext::test_new(sc), + realm: ScimRealmContext { + domain_id: domain_id.to_string(), + provider_id: "okta-1".to_string(), + }, + } + } + + pub(super) fn make_index(keystone_id: &str) -> ScimResourceIndex { + ScimResourceIndex { + domain_id: "domain-1".to_string(), + provider_id: "okta-1".to_string(), + resource_type: ScimResourceType::Group, + keystone_id: keystone_id.to_string(), + external_id: Some("ext-old".to_string()), + version: 0, + deprovisioned_at: None, + created_at: 1, + updated_at: 1, + } + } + + #[tokio::test] + async fn test_show() { + let mut resource_mock = MockScimResourceProvider::default(); + resource_mock + .expect_get_index() + .returning(|_, _, _, _, id| Ok(Some(make_index(id)))); + let mut identity_mock = MockIdentityProvider::default(); + identity_mock.expect_get_group().returning(|_, id| { + Ok(Some(Group { + id: id.to_string(), + domain_id: "domain-1".to_string(), + name: "engineers".to_string(), + description: None, + extra: Default::default(), + })) + }); + identity_mock + .expect_list_users_of_group() + .returning(|_, _| Ok(vec!["user-1".to_string()])); + + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_scim_resource(resource_mock), + true, + None, + ) + .await; + + let result = show( + domain_scoped_auth("domain-1"), + Path(("domain-1".to_string(), "group-1".to_string())), + State(state), + ) + .await + .unwrap(); + assert_eq!(result.id, "group-1"); + assert_eq!(result.members.len(), 1); + assert_eq!(result.members[0].value, "user-1"); + } + + #[tokio::test] + async fn test_show_not_owned_returns_404() { + let mut resource_mock = MockScimResourceProvider::default(); + resource_mock + .expect_get_index() + .returning(|_, _, _, _, _| Ok(None)); + + let state = get_mocked_state( + Provider::mocked_builder().mock_scim_resource(resource_mock), + true, + None, + ) + .await; + + let result = show( + domain_scoped_auth("domain-1"), + Path(( + "domain-1".to_string(), + "group-from-another-realm".to_string(), + )), + State(state), + ) + .await; + assert!(matches!( + result, + Err(ScimApiError::Api(KeystoneApiError::NotFound { .. })) + )); + } + + #[tokio::test] + async fn test_show_deprovisioned_returns_404() { + let mut resource_mock = MockScimResourceProvider::default(); + resource_mock + .expect_get_index() + .returning(|_, _, _, _, id| { + Ok(Some(ScimResourceIndex { + deprovisioned_at: Some(123), + ..make_index(id) + })) + }); + + let state = get_mocked_state( + Provider::mocked_builder().mock_scim_resource(resource_mock), + true, + None, + ) + .await; + + let result = show( + domain_scoped_auth("domain-1"), + Path(("domain-1".to_string(), "group-1".to_string())), + State(state), + ) + .await; + assert!(matches!( + result, + Err(ScimApiError::Api(KeystoneApiError::NotFound { .. })) + )); + } +} diff --git a/crates/keystone/src/scim/group/update.rs b/crates/keystone/src/scim/group/update.rs new file mode 100644 index 000000000..d9088988c --- /dev/null +++ b/crates/keystone/src/scim/group/update.rs @@ -0,0 +1,432 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! `PUT /SCIM/v2/{domain_id}/Groups/{id}` — full-replace update (ADR 0024 +//! §3.C, §4, §5.C, §7, §11; `PATCH` is deferred to a later PR). + +use std::collections::HashSet; + +use axum::{Json, extract::Path, extract::State}; +use serde_json::json; + +use openstack_keystone_core::api::KeystoneApiError; +use openstack_keystone_core::api::api_key_auth::ScimRealmAuth; +use openstack_keystone_core::auth::ExecutionContext; +use openstack_keystone_core_types::scim::{ScimResourceIndexUpdate, ScimResourceType}; + +use crate::keystone::ServiceState; +use crate::scim::error::ScimApiError; +use crate::scim::group::membership::validate_members_owned_by_realm; +use crate::scim::group::show::fetch_owned; +use crate::scim::types::{MAX_GROUP_MEMBERS, ScimGroup, ScimGroupWrite}; + +pub(super) async fn update( + ScimRealmAuth { ctx, realm }: ScimRealmAuth, + Path((_domain_id, id)): Path<(String, String)>, + State(state): State, + Json(req): Json, +) -> Result, ScimApiError> { + if req.display_name.trim().is_empty() { + return Err(KeystoneApiError::BadRequest("displayName is required".to_string()).into()); + } + + let new_member_ids = req.member_ids(); + if new_member_ids.len() > MAX_GROUP_MEMBERS { + return Err(ScimApiError::InvalidValue(format!( + "members must not exceed {MAX_GROUP_MEMBERS} entries" + ))); + } + + let exec = ExecutionContext::from_auth(&state, &ctx); + let (existing_group, existing_index) = + fetch_owned(&state, &exec, &realm.domain_id, &realm.provider_id, &id).await?; + + state + .policy_enforcer + .enforce( + "identity/scim/group/update", + &ctx, + json!({"group": {"display_name": req.display_name}}), + Some(json!({"group": existing_group})), + ) + .await?; + + // ADR 0024 §3.D: if `displayName` is changing, re-run the domain-wide + // collision check (a no-op rename doesn't need it). The lookup is + // case-insensitive, so exclude the resource's own id — otherwise a + // case-only rename would collide with itself. + if req.display_name != existing_group.name + && let Some(matched_id) = state + .provider + .get_identity_provider() + .find_group_by_name_ci(&exec, &realm.domain_id, &req.display_name) + .await? + && matched_id != id + { + return Err(ScimApiError::Uniqueness( + "displayName already exists within this domain".to_string(), + )); + } + + // ADR 0024 §7: every referenced member must already be owned by this + // same realm. + validate_members_owned_by_realm( + &state, + &exec, + &realm.domain_id, + &realm.provider_id, + &new_member_ids, + ) + .await?; + + if req.external_id != existing_index.external_id { + state + .provider + .get_scim_resource_provider() + .update_index( + &exec, + &realm.domain_id, + &realm.provider_id, + ScimResourceType::Group, + &id, + ScimResourceIndexUpdate { + external_id: Some(req.external_id.clone()), + deprovisioned_at: None, + }, + ) + .await?; + } + + let group = state + .provider + .get_identity_provider() + .update_group(&exec, &id, req.to_group_update()) + .await?; + + // §5.C: `PUT` does a full membership resync against the target member set, + // not an incremental patch. Adds are attempted before removals so that a + // failure leaves the group unchanged rather than emptying it. + let current_member_ids: HashSet = state + .provider + .get_identity_provider() + .list_users_of_group(&exec, &id) + .await? + .into_iter() + .collect(); + let target_member_ids: HashSet = new_member_ids.iter().cloned().collect(); + + let added: Vec<(&str, &str)> = target_member_ids + .difference(¤t_member_ids) + .map(|uid| (uid.as_str(), id.as_str())) + .collect(); + if !added.is_empty() { + state + .provider + .get_identity_provider() + .add_users_to_groups(&exec, added) + .await?; + } + for removed in current_member_ids.difference(&target_member_ids) { + state + .provider + .get_identity_provider() + .remove_user_from_group(&exec, removed, &id) + .await?; + } + + let index = state + .provider + .get_scim_resource_provider() + .get_index( + &exec, + &realm.domain_id, + &realm.provider_id, + ScimResourceType::Group, + &id, + ) + .await? + .unwrap_or(existing_index); + + Ok(Json(ScimGroup::from_domain( + &group, + &index, + &new_member_ids, + ))) +} + +#[cfg(test)] +mod tests { + use openstack_keystone_core::api::KeystoneApiError; + use openstack_keystone_core::api::api_key_auth::ScimRealmContext; + use openstack_keystone_core::auth::ValidatedSecurityContext; + use openstack_keystone_core_types::auth::{ + AuthenticationContext, AuthzInfoBuilder, IdentityInfo, PrincipalInfo, ScopeInfo, + SecurityContext, UserIdentityInfoBuilder, + }; + use openstack_keystone_core_types::identity::Group; + use openstack_keystone_core_types::resource::Domain; + use openstack_keystone_core_types::scim::ScimResourceIndex; + + use super::*; + use crate::api::tests::get_mocked_state; + use crate::identity::MockIdentityProvider; + use crate::provider::Provider; + use crate::scim::types::ScimGroupMember; + use crate::scim_resource::MockScimResourceProvider; + + fn domain_scoped_auth(domain_id: &str) -> ScimRealmAuth { + let user = UserIdentityInfoBuilder::default() + .user_id("scim-provisioner") + .build() + .unwrap(); + let authz = AuthzInfoBuilder::default() + .roles(Vec::new()) + .scope(ScopeInfo::Domain(Domain { + id: domain_id.to_string(), + name: String::new(), + description: None, + enabled: true, + extra: Default::default(), + })) + .build() + .unwrap(); + let sc = SecurityContext::test_build() + .authentication_context(AuthenticationContext::Password) + .principal(PrincipalInfo { + identity: IdentityInfo::User(user), + }) + .authorization(authz) + .build(); + ScimRealmAuth { + ctx: ValidatedSecurityContext::test_new(sc), + realm: ScimRealmContext { + domain_id: domain_id.to_string(), + provider_id: "okta-1".to_string(), + }, + } + } + + fn make_index(keystone_id: &str) -> ScimResourceIndex { + ScimResourceIndex { + domain_id: "domain-1".to_string(), + provider_id: "okta-1".to_string(), + resource_type: ScimResourceType::Group, + keystone_id: keystone_id.to_string(), + external_id: Some("ext-old".to_string()), + version: 0, + deprovisioned_at: None, + created_at: 1, + updated_at: 1, + } + } + + fn write_req(display_name: &str, members: Vec<&str>) -> ScimGroupWrite { + ScimGroupWrite { + schemas: vec![], + external_id: Some("ext-old".to_string()), + display_name: display_name.to_string(), + members: members + .into_iter() + .map(|v| ScimGroupMember { + value: v.to_string(), + }) + .collect(), + } + } + + #[tokio::test] + async fn test_update() { + let mut resource_mock = MockScimResourceProvider::default(); + resource_mock + .expect_get_index() + .returning(|_, _, _, _, id| Ok(Some(make_index(id)))); + + let mut identity_mock = MockIdentityProvider::default(); + identity_mock.expect_get_group().returning(|_, id| { + Ok(Some(Group { + id: id.to_string(), + domain_id: "domain-1".to_string(), + name: "engineers".to_string(), + description: None, + extra: Default::default(), + })) + }); + identity_mock + .expect_find_group_by_name_ci() + .returning(|_, _, _| Ok(None)); + identity_mock.expect_update_group().returning(|_, id, req| { + Ok(Group { + id: id.to_string(), + domain_id: "domain-1".to_string(), + name: req.name.clone().unwrap(), + description: None, + extra: Default::default(), + }) + }); + identity_mock + .expect_list_users_of_group() + .returning(|_, _| Ok(vec![])); + + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_scim_resource(resource_mock), + true, + None, + ) + .await; + + let result = update( + domain_scoped_auth("domain-1"), + Path(("domain-1".to_string(), "group-1".to_string())), + State(state), + Json(write_req("new_name", vec![])), + ) + .await + .unwrap(); + assert_eq!(result.display_name, "new_name"); + } + + #[tokio::test] + async fn test_update_resyncs_membership() { + let mut resource_mock = MockScimResourceProvider::default(); + resource_mock + .expect_get_index() + .returning(|_, _, _, _, id| Ok(Some(make_index(id)))); + + let mut identity_mock = MockIdentityProvider::default(); + identity_mock.expect_get_group().returning(|_, id| { + Ok(Some(Group { + id: id.to_string(), + domain_id: "domain-1".to_string(), + name: "engineers".to_string(), + description: None, + extra: Default::default(), + })) + }); + identity_mock + .expect_find_group_by_name_ci() + .returning(|_, _, _| Ok(None)); + identity_mock.expect_update_group().returning(|_, id, req| { + Ok(Group { + id: id.to_string(), + domain_id: "domain-1".to_string(), + name: req.name.clone().unwrap(), + description: None, + extra: Default::default(), + }) + }); + // Current membership: user-old. Desired: user-new. + identity_mock + .expect_list_users_of_group() + .returning(|_, _| Ok(vec!["user-old".to_string()])); + identity_mock + .expect_add_users_to_groups() + .withf(|_, memberships| memberships == &vec![("user-new", "group-1")]) + .returning(|_, _| Ok(())); + identity_mock + .expect_remove_user_from_group() + .withf(|_, uid, gid| uid == "user-old" && gid == "group-1") + .returning(|_, _, _| Ok(())); + + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_scim_resource(resource_mock), + true, + None, + ) + .await; + + let result = update( + domain_scoped_auth("domain-1"), + Path(("domain-1".to_string(), "group-1".to_string())), + State(state), + Json(write_req("engineers", vec!["user-new"])), + ) + .await + .unwrap(); + assert_eq!(result.members.len(), 1); + assert_eq!(result.members[0].value, "user-new"); + } + + #[tokio::test] + async fn test_update_rejects_member_not_owned_by_realm() { + let mut resource_mock = MockScimResourceProvider::default(); + resource_mock + .expect_get_index() + .withf(|_, _, _, rt, id| *rt == ScimResourceType::Group && id == "group-1") + .returning(|_, _, _, _, id| Ok(Some(make_index(id)))); + resource_mock + .expect_get_index() + .withf(|_, _, _, rt, _| *rt == ScimResourceType::User) + .returning(|_, _, _, _, _| Ok(None)); + + let mut identity_mock = MockIdentityProvider::default(); + identity_mock.expect_get_group().returning(|_, id| { + Ok(Some(Group { + id: id.to_string(), + domain_id: "domain-1".to_string(), + name: "engineers".to_string(), + description: None, + extra: Default::default(), + })) + }); + identity_mock.expect_update_group().never(); + + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_scim_resource(resource_mock), + true, + None, + ) + .await; + + let result = update( + domain_scoped_auth("domain-1"), + Path(("domain-1".to_string(), "group-1".to_string())), + State(state), + Json(write_req("engineers", vec!["user-from-elsewhere"])), + ) + .await; + assert!(matches!(result, Err(ScimApiError::InvalidValue(_)))); + } + + #[tokio::test] + async fn test_update_not_owned_returns_404() { + let mut resource_mock = MockScimResourceProvider::default(); + resource_mock + .expect_get_index() + .returning(|_, _, _, _, _| Ok(None)); + + let state = get_mocked_state( + Provider::mocked_builder().mock_scim_resource(resource_mock), + true, + None, + ) + .await; + + let result = update( + domain_scoped_auth("domain-1"), + Path(("domain-1".to_string(), "group-1".to_string())), + State(state), + Json(write_req("new_name", vec![])), + ) + .await; + assert!(matches!( + result, + Err(ScimApiError::Api(KeystoneApiError::NotFound { .. })) + )); + } +} diff --git a/crates/keystone/src/scim/types.rs b/crates/keystone/src/scim/types.rs index 2db33e6b3..a903a9d8a 100644 --- a/crates/keystone/src/scim/types.rs +++ b/crates/keystone/src/scim/types.rs @@ -24,13 +24,19 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use openstack_keystone_core::identity::generate_public_id; -use openstack_keystone_core_types::identity::{UserCreate, UserResponse, UserType, UserUpdate}; +use openstack_keystone_core_types::identity::{ + Group, GroupCreate, GroupUpdate, UserCreate, UserResponse, UserType, UserUpdate, +}; use openstack_keystone_core_types::scim::ScimResourceIndex; /// `urn:ietf:params:scim:schemas:core:2.0:User` schema URI. pub const USER_SCHEMA: &str = "urn:ietf:params:scim:schemas:core:2.0:User"; +/// `urn:ietf:params:scim:schemas:core:2.0:Group` schema URI. +pub const GROUP_SCHEMA: &str = "urn:ietf:params:scim:schemas:core:2.0:Group"; /// `urn:ietf:params:scim:api:messages:2.0:ListResponse` schema URI. pub const LIST_RESPONSE_SCHEMA: &str = "urn:ietf:params:scim:api:messages:2.0:ListResponse"; +/// ADR 0024 §11 membership-graph-bomb cap: max `members` entries per request. +pub const MAX_GROUP_MEMBERS: usize = 1000; const EXTRA_GIVEN_NAME: &str = "scim_given_name"; const EXTRA_FAMILY_NAME: &str = "scim_family_name"; @@ -233,6 +239,106 @@ pub struct ScimListResponse { pub resources: Vec, } +/// A single `members` entry — RFC 7644 permits `display`/`$ref`/`type` too, +/// but ADR 0024 §4's Group mapping table only requires the member `User.id`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScimGroupMember { + pub value: String, +} + +/// `GET`/`POST`/`PUT` response representation of a SCIM `Group`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ScimGroup { + pub schemas: Vec, + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub external_id: Option, + pub display_name: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub members: Vec, + pub meta: ScimMeta, +} + +impl ScimGroup { + /// Build the wire representation from the core Identity record, its SCIM + /// ownership anchor, and its resolved membership (ADR 0024 §4, §7). + pub fn from_domain(group: &Group, index: &ScimResourceIndex, member_ids: &[String]) -> Self { + Self { + schemas: vec![GROUP_SCHEMA.to_string()], + id: group.id.clone(), + external_id: index.external_id.clone(), + display_name: group.name.clone(), + members: member_ids + .iter() + .map(|id| ScimGroupMember { value: id.clone() }) + .collect(), + meta: ScimMeta { + resource_type: "Group".to_string(), + created: epoch_to_rfc3339(index.created_at), + last_modified: epoch_to_rfc3339(index.updated_at), + }, + } + } +} + +/// `POST`/`PUT` request body — also used for `PUT` full-replace (ADR 0024 +/// PR3 scope: no `PATCH`). Unlike `User`, a SCIM `Group`'s `id` stays +/// server-assigned: nothing federates in *as* a Group, so there is no +/// convergence hazard for a deterministic id to solve (see the SCIM Users +/// identity-convergence note in `to_user_create`). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScimGroupWrite { + #[serde(default)] + pub schemas: Vec, + #[serde(default)] + pub external_id: Option, + pub display_name: String, + #[serde(default)] + pub members: Vec, +} + +impl ScimGroupWrite { + /// The member `User.id`s this write requests, in request order. + pub fn member_ids(&self) -> Vec { + self.members.iter().map(|m| m.value.clone()).collect() + } + + /// Convert to a core Identity `GroupCreate` (ADR 0024 §4 attribute + /// mapping). `externalId` is excluded — it lives only in + /// `ScimResourceIndex.external_id`, never on the `Group` itself. + pub fn to_group_create(&self, domain_id: &str) -> GroupCreate { + GroupCreate { + id: None, + domain_id: domain_id.to_string(), + name: self.display_name.clone(), + description: None, + extra: HashMap::new(), + } + } + + /// Convert to a core Identity `GroupUpdate` (full-replace `PUT`). + pub fn to_group_update(&self) -> GroupUpdate { + GroupUpdate { + name: Some(self.display_name.clone()), + description: None, + extra: HashMap::new(), + } + } +} + +/// `GET /Groups` list response envelope. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ScimGroupListResponse { + pub schemas: Vec, + pub total_results: usize, + pub start_index: usize, + pub items_per_page: usize, + pub resources: Vec, +} + fn extra_str(extra: &HashMap, key: &str) -> Option { extra.get(key).and_then(|v| v.as_str()).map(String::from) } diff --git a/crates/storage-crypto/src/lib.rs b/crates/storage-crypto/src/lib.rs index 2c85c2d9b..c9b3a17a3 100644 --- a/crates/storage-crypto/src/lib.rs +++ b/crates/storage-crypto/src/lib.rs @@ -16,8 +16,8 @@ //! Implements the cryptographic barrier described in ADR 0016-v2 §2: //! //! * **Key Encryption Key (KEK):** provided by [`kek::KekProvider`]. -//! Development mode uses [`kek::EnvKek`]; production uses the PKCS#11 or -//! TPM 2.0 providers in the separate `storage-crypto-pkcs11` / +//! Development mode uses [`kek::EnvKek`]; production uses the PKCS#11 or TPM +//! 2.0 providers in the separate `storage-crypto-pkcs11` / //! `storage-crypto-tpm` crates (ADR 0016-v2 §2.5). //! //! * **Data Encryption Key (DEK) hierarchy:** [`dek::DekEpoch`] holds the diff --git a/doc/src/adr/0024-scim-v2-provisioning.md b/doc/src/adr/0024-scim-v2-provisioning.md index f9884cba2..a72005d1e 100644 --- a/doc/src/adr/0024-scim-v2-provisioning.md +++ b/doc/src/adr/0024-scim-v2-provisioning.md @@ -2,10 +2,27 @@ **Date:** 2026-07-01 +**Last-revised:** 2026-07-06 (PR1+PR2+PR3 implementation note) + ## Status Proposed +**Implementation note (2026-07-06):** PR1 (Realm Foundation), PR2 (Users +vertical slice), and PR3 (Groups + membership isolation) have landed. During +implementation, the realm/user identity design was refined beyond what this +ADR originally specified: `ScimRealmResource` gained a mandatory `idp_id` link +to a federation `IdentityProvider`, and a SCIM-provisioned `User`'s `id` is +derived deterministically rather than server-assigned, so it converges with a +later federated JIT login for the same person instead of producing a +duplicate account. §2.A and §4 below have been updated to match the as-built +behavior; the "Rejected alternative" callout in §4 is revised accordingly. +`Group`, by contrast, keeps a normal server-assigned `id` and an optional +`externalId` — nothing federates in *as* a Group, so there is no convergence +hazard a deterministic id would solve. The filter/PATCH/ETag protocol surface +(§5.B–E), the janitor purge phase (§6.C), and CLI parity (§12) remain +unimplemented — this ADR stays `Proposed` until all of it lands. + ## Reference Extends ADR 0002 (OPA), ADR 0017 (Security Context), ADR 0020 (Unified Mapping @@ -54,6 +71,7 @@ accidentally provision Users/Groups. pub struct ScimRealmResource { pub domain_id: String, pub provider_id: String, // shared coordinate with ApiClientResource / MappingRuleSet + pub idp_id: String, // federation IdentityProvider this realm's users belong to pub display_name: String, pub enabled: bool, pub created_at: i64, @@ -63,6 +81,16 @@ pub struct ScimRealmResource { **Keyspace:** `data:scim_realm:v1::`. +**`idp_id` is mandatory** and must resolve to an existing `IdentityProvider` +(checked at both realm create and update; an unresolvable `idp_id` is `404`). +This exists because of the identity-convergence scheme in §4: a SCIM-provisioned +`User`'s `id` is derived from `(domain_id, externalId)`, the same formula used +for a federation JIT shadow user's `id` — so the realm has to know, up front, +which `IdentityProvider`'s `sub` claims its `externalId`s are expected to equal +for that convergence to actually line up. A realm not bound to a real IdP would +still create syntactically valid `User` rows, but they'd never converge with +anything, silently defeating the point of §4's scheme. + Groups provisioned under a realm always inherit the realm's own `domain_id` — no separate target-domain override is offered, keeping "one realm, one domain" the literal ownership boundary even though a domain may host many realms. @@ -232,18 +260,37 @@ the ADR 0020 ephemeral shadow-registry path. A SCIM-provisioned user is expected to authenticate later through an entirely separate channel (OIDC, password, passkey); SCIM only manages the account's existence and attributes. +**Identity convergence with federation JIT (as-implemented).** `externalId` is +**mandatory** on `POST .../Users` (`400` if empty/absent), and the created +`User.id` is not server-assigned: it's derived deterministically as +`generate_public_id(domain_id, externalId, "user")` — the identical sha256-based +formula this codebase's ADR 0020 UME path already uses to derive a federation +JIT shadow user's `id`. The user row is created as `UserType::NonLocal` (no +password, no `local_user` row). The practical effect: a person provisioned +ahead of time via SCIM (`externalId` == the IdP's `sub` claim), who later +authenticates for the first time via that same realm's `idp_id` (§2.A), +converges onto the *same* `User` row a JIT login would otherwise have created +from scratch — rather than ending up with two accounts for one person, one +SCIM-managed and one federation-managed. `POST .../Users` additionally probes +for a user already occupying that deterministic id (e.g. one a federated JIT +login already created before SCIM provisioning caught up) and returns `409 +Conflict` (`scimType: "uniqueness"`) rather than surfacing a raw +primary-key-collision error from the Identity driver. + **Rejected alternative:** reusing `User.federated: Option>` (on `UserResponse`/`UserCreate`/`UserUpdate`; -`Federation { idp_id, protocols, unique_id }`) to carry the SCIM `externalId`. -`Federation` is scoped to authentication-protocol linkage (`idp_id` + +`Federation { idp_id, protocols, unique_id }`) to carry the SCIM `externalId` +directly. `Federation` is scoped to authentication-protocol linkage (`idp_id` + `protocol_id`) and is not realm-fenced or version-tracked; overloading it would conflate two different provenance concepts and bypass the ownership fencing in -§3.C. `ScimResourceIndex` is kept as a dedicated, parallel structure instead. +§3.C. `ScimResourceIndex` is kept as a dedicated, parallel structure for +provenance/ownership instead — the convergence above is achieved purely through +the shared `id`-derivation formula, not by writing into `User.federated`. | SCIM Attribute (User) | Keystone `User` field | | ------------------------------------ | -------------------------------------------------------- | -| `id` | `id` (Keystone UUID; immutable, server-assigned) | -| `externalId` | `ScimResourceIndex.external_id` | +| `id` | `id` (`generate_public_id(domain_id, externalId, "user")`; deterministic, not server-random — see above) | +| `externalId` | `ScimResourceIndex.external_id` (mandatory on create — see above) | | `userName` | `name` | | `active` | `enabled` | | `name.givenName` / `name.familyName` | `extra["scim_given_name"]` / `extra["scim_family_name"]` | diff --git a/policy/identity/scim/group/create.rego b/policy/identity/scim/group/create.rego new file mode 100644 index 000000000..fc1da8a0a --- /dev/null +++ b/policy/identity/scim/group/create.rego @@ -0,0 +1,53 @@ +# METADATA +# description: Policy for provisioning a SCIM group (ADR 0024 §3, §8) +package identity.scim.group.create + +import data.identity + +# Create a new SCIM-provisioned group. +# +# The `input.target.group` carries at least: +# domain_id: string Domain the realm is scoped to. +# +# The `input.existing` is null. +# +# The Realm Activation Gate (ADR 0024 §2.B) and the domain-only scope +# restriction (§2.C) are already enforced by the `ScimRealmAuth` extractor +# before this policy runs. This check additionally requires the realm's +# ephemeral identity to carry the `scim_provisioner` role. Since SCIM +# resource CRUD is invoked exclusively via API-key ingress (ADR 0021), +# `manager`/`admin`/`scim_provisioner` here are never backed by a real +# RoleAssignment -- API keys carry no Role at all. Each is simply a string +# an operator configures the realm's own MappingRuleSet (ADR 0020 UME) to +# emit as `Authorization::Domain{roles}` at request time (ADR 0024 §8). +default allow := false + +allow if { + "admin" in input.credentials.roles +} + +allow if { + input.credentials.is_admin +} + +allow if { + "manager" in input.credentials.roles + identity.domain_matches_domain_scope +} + +allow if { + "scim_provisioner" in input.credentials.roles + identity.domain_matches_domain_scope +} + +violation contains {"field": "domain_id", "msg": "provisioning a SCIM group in a domain different to the domain scope requires `admin` role."} if { + not "admin" in input.credentials.roles + not identity.domain_matches_domain_scope +} + +violation contains {"field": "roles", "msg": "provisioning a SCIM group requires a scim_provisioner or manager role with the domain scope."} if { + not "admin" in input.credentials.roles + not "manager" in input.credentials.roles + not "scim_provisioner" in input.credentials.roles + identity.domain_matches_domain_scope +} diff --git a/policy/identity/scim/group/create_test.rego b/policy/identity/scim/group/create_test.rego new file mode 100644 index 000000000..42b2af6bf --- /dev/null +++ b/policy/identity/scim/group/create_test.rego @@ -0,0 +1,17 @@ +package test_scim_group_create + +import data.identity.scim.group.create + +test_allowed if { + create.allow with input as {"credentials": {"roles": ["admin"]}} + create.allow with input as {"credentials": {"roles": [], "is_admin": true}} + create.allow with input as {"credentials": {"roles": ["manager"], "domain_id": "foo"}, "target": {"group": {"domain_id": "foo"}}} + create.allow with input as {"credentials": {"roles": ["scim_provisioner"], "domain_id": "foo"}, "target": {"group": {"domain_id": "foo"}}} +} + +test_forbidden if { + not create.allow with input as {"credentials": {"roles": []}} + not create.allow with input as {"credentials": {"roles": ["scim_provisioner"], "domain_id": "foo"}, "target": {"group": {"domain_id": "foo1"}}} + not create.allow with input as {"credentials": {"roles": ["scim_provisioner"]}, "target": {"group": {"domain_id": "foo"}}} + not create.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"group": {"domain_id": "foo"}}} +} diff --git a/policy/identity/scim/group/delete.rego b/policy/identity/scim/group/delete.rego new file mode 100644 index 000000000..1e0ee7bb9 --- /dev/null +++ b/policy/identity/scim/group/delete.rego @@ -0,0 +1,44 @@ +# METADATA +# description: Policy for neutralizing/tombstoning a SCIM-provisioned group (ADR 0024 §3, §6.B, §8) +package identity.scim.group.delete + +import data.identity + +# `DELETE` (neutralize + tombstone) a SCIM-provisioned group. +# +# The `input.existing.group` is the stored `Group`. +# +# The Ownership Fencing Algorithm (ADR 0024 §3.C) has already run in the +# handler before this policy is evaluated — this is a second, independent +# authorization check, not a substitute for it (§8). +default allow := false + +allow if { + "admin" in input.credentials.roles +} + +allow if { + input.credentials.is_admin +} + +allow if { + "manager" in input.credentials.roles + identity.domain_matches_domain_scope +} + +allow if { + "scim_provisioner" in input.credentials.roles + identity.domain_matches_domain_scope +} + +violation contains {"field": "domain_id", "msg": "deleting a SCIM group in a domain different to the domain scope requires `admin` role."} if { + not "admin" in input.credentials.roles + not identity.domain_matches_domain_scope +} + +violation contains {"field": "roles", "msg": "deleting a SCIM group requires a scim_provisioner or manager role with the domain scope."} if { + not "admin" in input.credentials.roles + not "manager" in input.credentials.roles + not "scim_provisioner" in input.credentials.roles + identity.domain_matches_domain_scope +} diff --git a/policy/identity/scim/group/delete_test.rego b/policy/identity/scim/group/delete_test.rego new file mode 100644 index 000000000..8990299b8 --- /dev/null +++ b/policy/identity/scim/group/delete_test.rego @@ -0,0 +1,15 @@ +package test_scim_group_delete + +import data.identity.scim.group.delete + +test_allowed if { + delete.allow with input as {"credentials": {"roles": ["admin"]}} + delete.allow with input as {"credentials": {"roles": [], "is_admin": true}} + delete.allow with input as {"credentials": {"roles": ["scim_provisioner"], "domain_id": "foo"}, "existing": {"group": {"domain_id": "foo"}}} +} + +test_forbidden if { + not delete.allow with input as {"credentials": {"roles": []}} + not delete.allow with input as {"credentials": {"roles": ["scim_provisioner"], "domain_id": "foo"}, "existing": {"group": {"domain_id": "foo1"}}} + not delete.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "existing": {"group": {"domain_id": "foo"}}} +} diff --git a/policy/identity/scim/group/list.rego b/policy/identity/scim/group/list.rego new file mode 100644 index 000000000..4089c2f0d --- /dev/null +++ b/policy/identity/scim/group/list.rego @@ -0,0 +1,43 @@ +# METADATA +# description: Policy for listing SCIM-provisioned groups (ADR 0024 §3, §8) +package identity.scim.group.list + +import data.identity + +# List SCIM-provisioned groups owned by the realm. +# +# The `input.target.group` carries at least: +# domain_id: string Domain the realm is scoped to. +# +# The `input.existing` is null. +default allow := false + +allow if { + "admin" in input.credentials.roles +} + +allow if { + input.credentials.is_admin +} + +allow if { + "manager" in input.credentials.roles + identity.domain_matches_domain_scope +} + +allow if { + "scim_provisioner" in input.credentials.roles + identity.domain_matches_domain_scope +} + +violation contains {"field": "domain_id", "msg": "listing SCIM groups in a domain different to the domain scope requires `admin` role."} if { + not "admin" in input.credentials.roles + not identity.domain_matches_domain_scope +} + +violation contains {"field": "roles", "msg": "listing SCIM groups requires a scim_provisioner or manager role with the domain scope."} if { + not "admin" in input.credentials.roles + not "manager" in input.credentials.roles + not "scim_provisioner" in input.credentials.roles + identity.domain_matches_domain_scope +} diff --git a/policy/identity/scim/group/list_test.rego b/policy/identity/scim/group/list_test.rego new file mode 100644 index 000000000..9081df026 --- /dev/null +++ b/policy/identity/scim/group/list_test.rego @@ -0,0 +1,15 @@ +package test_scim_group_list + +import data.identity.scim.group.list + +test_allowed if { + list.allow with input as {"credentials": {"roles": ["admin"]}} + list.allow with input as {"credentials": {"roles": [], "is_admin": true}} + list.allow with input as {"credentials": {"roles": ["scim_provisioner"], "domain_id": "foo"}, "target": {"group": {"domain_id": "foo"}}} +} + +test_forbidden if { + not list.allow with input as {"credentials": {"roles": []}} + not list.allow with input as {"credentials": {"roles": ["scim_provisioner"], "domain_id": "foo"}, "target": {"group": {"domain_id": "foo1"}}} + not list.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"group": {"domain_id": "foo"}}} +} diff --git a/policy/identity/scim/group/show.rego b/policy/identity/scim/group/show.rego new file mode 100644 index 000000000..1a20adcff --- /dev/null +++ b/policy/identity/scim/group/show.rego @@ -0,0 +1,44 @@ +# METADATA +# description: Policy for showing a SCIM-provisioned group (ADR 0024 §3, §8) +package identity.scim.group.show + +import data.identity + +# Show a single SCIM-provisioned group. +# +# The `input.existing.group` is the stored `Group`. +# +# The Ownership Fencing Algorithm (ADR 0024 §3.C) has already run in the +# handler before this policy is evaluated — this is a second, independent +# authorization check, not a substitute for it (§8). +default allow := false + +allow if { + "admin" in input.credentials.roles +} + +allow if { + input.credentials.is_admin +} + +allow if { + "manager" in input.credentials.roles + identity.domain_matches_domain_scope +} + +allow if { + "scim_provisioner" in input.credentials.roles + identity.domain_matches_domain_scope +} + +violation contains {"field": "domain_id", "msg": "showing a SCIM group in a domain different to the domain scope requires `admin` role."} if { + not "admin" in input.credentials.roles + not identity.domain_matches_domain_scope +} + +violation contains {"field": "roles", "msg": "showing a SCIM group requires a scim_provisioner or manager role with the domain scope."} if { + not "admin" in input.credentials.roles + not "manager" in input.credentials.roles + not "scim_provisioner" in input.credentials.roles + identity.domain_matches_domain_scope +} diff --git a/policy/identity/scim/group/show_test.rego b/policy/identity/scim/group/show_test.rego new file mode 100644 index 000000000..e2f270c0b --- /dev/null +++ b/policy/identity/scim/group/show_test.rego @@ -0,0 +1,15 @@ +package test_scim_group_show + +import data.identity.scim.group.show + +test_allowed if { + show.allow with input as {"credentials": {"roles": ["admin"]}} + show.allow with input as {"credentials": {"roles": [], "is_admin": true}} + show.allow with input as {"credentials": {"roles": ["scim_provisioner"], "domain_id": "foo"}, "existing": {"group": {"domain_id": "foo"}}} +} + +test_forbidden if { + not show.allow with input as {"credentials": {"roles": []}} + not show.allow with input as {"credentials": {"roles": ["scim_provisioner"], "domain_id": "foo"}, "existing": {"group": {"domain_id": "foo1"}}} + not show.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "existing": {"group": {"domain_id": "foo"}}} +} diff --git a/policy/identity/scim/group/update.rego b/policy/identity/scim/group/update.rego new file mode 100644 index 000000000..b83006478 --- /dev/null +++ b/policy/identity/scim/group/update.rego @@ -0,0 +1,44 @@ +# METADATA +# description: Policy for updating a SCIM-provisioned group (ADR 0024 §3, §8) +package identity.scim.group.update + +import data.identity + +# Full-replace update (`PUT`) of a SCIM-provisioned group. +# +# The `input.existing.group` is the stored `Group`. +# +# The Ownership Fencing Algorithm (ADR 0024 §3.C) has already run in the +# handler before this policy is evaluated — this is a second, independent +# authorization check, not a substitute for it (§8). +default allow := false + +allow if { + "admin" in input.credentials.roles +} + +allow if { + input.credentials.is_admin +} + +allow if { + "manager" in input.credentials.roles + identity.domain_matches_domain_scope +} + +allow if { + "scim_provisioner" in input.credentials.roles + identity.domain_matches_domain_scope +} + +violation contains {"field": "domain_id", "msg": "updating a SCIM group in a domain different to the domain scope requires `admin` role."} if { + not "admin" in input.credentials.roles + not identity.domain_matches_domain_scope +} + +violation contains {"field": "roles", "msg": "updating a SCIM group requires a scim_provisioner or manager role with the domain scope."} if { + not "admin" in input.credentials.roles + not "manager" in input.credentials.roles + not "scim_provisioner" in input.credentials.roles + identity.domain_matches_domain_scope +} diff --git a/policy/identity/scim/group/update_test.rego b/policy/identity/scim/group/update_test.rego new file mode 100644 index 000000000..3f2d39b4b --- /dev/null +++ b/policy/identity/scim/group/update_test.rego @@ -0,0 +1,15 @@ +package test_scim_group_update + +import data.identity.scim.group.update + +test_allowed if { + update.allow with input as {"credentials": {"roles": ["admin"]}} + update.allow with input as {"credentials": {"roles": [], "is_admin": true}} + update.allow with input as {"credentials": {"roles": ["scim_provisioner"], "domain_id": "foo"}, "existing": {"group": {"domain_id": "foo"}}} +} + +test_forbidden if { + not update.allow with input as {"credentials": {"roles": []}} + not update.allow with input as {"credentials": {"roles": ["scim_provisioner"], "domain_id": "foo"}, "existing": {"group": {"domain_id": "foo1"}}} + not update.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "existing": {"group": {"domain_id": "foo"}}} +}