Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions crates/core-types/src/identity/group.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,24 @@ pub struct GroupListParameters {
pub name: Option<String>,
}

#[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<Option<String>>,

/// Additional group properties. The provider merges this into the
/// existing `extra` before persisting; an empty map means unchanged.
#[builder(default)]
pub extra: HashMap<String, Value>,

/// New name. `None` = unchanged.
#[builder(default)]
pub name: Option<String>,
}

#[derive(Builder, Clone, Debug, Default, PartialEq)]
#[builder(build_fn(error = "BuilderError"))]
#[builder(setter(strip_option, into))]
Expand Down
38 changes: 38 additions & 0 deletions crates/core/src/identity/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,44 @@ pub trait IdentityBackend: Send + Sync {
user_id: &'a str,
) -> Result<Vec<Group>, 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<Vec<String>, 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<Option<String>, 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<Group, IdentityProviderError>;

/// Remove the user from the group.
///
/// # Parameters
Expand Down
38 changes: 38 additions & 0 deletions crates/core/src/identity/provider_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,44 @@ pub trait IdentityApi: Send + Sync {
user_id: &'a str,
) -> Result<Vec<Group>, 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<Vec<String>, 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<Option<String>, 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<Group, IdentityProviderError>;

/// Remove the user from the single group.
///
/// # Parameters
Expand Down
81 changes: 81 additions & 0 deletions crates/core/src/identity/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<String>, 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<Option<String>, 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<Group, IdentityProviderError> {
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
Expand Down
20 changes: 20 additions & 0 deletions crates/core/src/mocks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,26 @@ mod identity {
user_id: &'a str,
) -> Result<Vec<Group>, IdentityProviderError>;

async fn list_users_of_group<'a>(
&self,
ctx: &ExecutionContext<'a>,
group_id: &'a str,
) -> Result<Vec<String>, IdentityProviderError>;

async fn find_group_by_name_ci<'a>(
&self,
ctx: &ExecutionContext<'a>,
domain_id: &'a str,
name: &'a str,
) -> Result<Option<String>, IdentityProviderError>;

async fn update_group<'a>(
&self,
ctx: &ExecutionContext<'a>,
group_id: &'a str,
group: GroupUpdate,
) -> Result<Group, IdentityProviderError>;

async fn list_users<'a>(
&self,
ctx: &ExecutionContext<'a>,
Expand Down
4 changes: 4 additions & 0 deletions crates/identity-driver-sql/src/group.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<group::Model> for Group {
fn from(value: group::Model) -> Self {
Expand Down
83 changes: 83 additions & 0 deletions crates/identity-driver-sql/src/group/find_by_name.rs
Original file line number Diff line number Diff line change
@@ -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<Option<String>, 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::<db_group::Model>::new()])
.into_connection();

assert_eq!(
find_by_name_ci(&db, "foo_domain", "missing").await.unwrap(),
None
);
}
}
Loading
Loading