diff --git a/.changeset/changesets/eerily-striving-louse.md b/.changeset/changesets/eerily-striving-louse.md new file mode 100644 index 0000000..0836aa2 --- /dev/null +++ b/.changeset/changesets/eerily-striving-louse.md @@ -0,0 +1,4 @@ +--- +lldap-operator-traits: minor +--- +Define async trait abstractions for lldap user, group, membership, attribute schema, and password operations diff --git a/Cargo.lock b/Cargo.lock index c9803ab..3b29046 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -114,6 +114,9 @@ version = "0.0.0" [[package]] name = "lldap-operator-traits" version = "0.0.0" +dependencies = [ + "tokio", +] [[package]] name = "once_cell_polyfill" @@ -121,6 +124,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -176,6 +185,27 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/crates/lldap-operator-traits/Cargo.toml b/crates/lldap-operator-traits/Cargo.toml index 52a271c..f5a7449 100644 --- a/crates/lldap-operator-traits/Cargo.toml +++ b/crates/lldap-operator-traits/Cargo.toml @@ -13,3 +13,6 @@ description = "Trait definitions for lldap user and group operations" workspace = true [dependencies] + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt"] } diff --git a/crates/lldap-operator-traits/src/attribute.rs b/crates/lldap-operator-traits/src/attribute.rs new file mode 100644 index 0000000..70700bf --- /dev/null +++ b/crates/lldap-operator-traits/src/attribute.rs @@ -0,0 +1,191 @@ +use std::future::Future; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AttributeType { + String, + Integer, + JpegPhoto, + DateTime, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AttributeSchema { + pub name: String, + pub attribute_type: AttributeType, + pub is_list: bool, + pub is_visible: bool, + pub is_editable: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AddAttributeInput { + pub name: String, + pub attribute_type: AttributeType, + pub is_list: bool, + pub is_visible: bool, + pub is_editable: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AttributeValue { + pub name: String, + pub value: Vec, +} + +pub trait AttributeSchemaClient { + type Error: std::error::Error + Send + Sync + 'static; + + fn list_user_attribute_schema( + &self, + ) -> impl Future, Self::Error>> + Send; + + fn list_group_attribute_schema( + &self, + ) -> impl Future, Self::Error>> + Send; + + fn add_user_attribute( + &self, + input: &AddAttributeInput, + ) -> impl Future> + Send; + + fn add_group_attribute( + &self, + input: &AddAttributeInput, + ) -> impl Future> + Send; + + fn delete_user_attribute( + &self, + name: &str, + ) -> impl Future> + Send; + + fn delete_group_attribute( + &self, + name: &str, + ) -> impl Future> + Send; +} + +#[cfg(test)] +mod tests { + use std::fmt; + + use super::*; + + #[derive(Debug)] + struct MockError; + + impl fmt::Display for MockError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("mock error") + } + } + + impl std::error::Error for MockError {} + + struct MockAttributeSchemaClient; + + impl AttributeSchemaClient for MockAttributeSchemaClient { + type Error = MockError; + + async fn list_user_attribute_schema(&self) -> Result, Self::Error> { + Ok(vec![AttributeSchema { + name: "phone".to_owned(), + attribute_type: AttributeType::String, + is_list: false, + is_visible: true, + is_editable: true, + }]) + } + + async fn list_group_attribute_schema(&self) -> Result, Self::Error> { + Ok(vec![]) + } + + async fn add_user_attribute(&self, _input: &AddAttributeInput) -> Result<(), Self::Error> { + Ok(()) + } + + async fn add_group_attribute(&self, _input: &AddAttributeInput) -> Result<(), Self::Error> { + Ok(()) + } + + async fn delete_user_attribute(&self, _name: &str) -> Result<(), Self::Error> { + Ok(()) + } + + async fn delete_group_attribute(&self, _name: &str) -> Result<(), Self::Error> { + Ok(()) + } + } + + #[tokio::test] + async fn list_user_attribute_schema_returns_schemas() { + let client = MockAttributeSchemaClient; + let schemas = client + .list_user_attribute_schema() + .await + .expect("should succeed"); + assert_eq!(schemas.len(), 1); + assert_eq!(schemas[0].name, "phone"); + assert_eq!(schemas[0].attribute_type, AttributeType::String); + } + + #[tokio::test] + async fn list_group_attribute_schema_returns_empty() { + let client = MockAttributeSchemaClient; + let schemas = client + .list_group_attribute_schema() + .await + .expect("should succeed"); + assert!(schemas.is_empty()); + } + + #[tokio::test] + async fn add_user_attribute_succeeds() { + let client = MockAttributeSchemaClient; + let input = AddAttributeInput { + name: "department".to_owned(), + attribute_type: AttributeType::String, + is_list: false, + is_visible: true, + is_editable: true, + }; + client + .add_user_attribute(&input) + .await + .expect("should succeed"); + } + + #[tokio::test] + async fn add_group_attribute_succeeds() { + let client = MockAttributeSchemaClient; + let input = AddAttributeInput { + name: "location".to_owned(), + attribute_type: AttributeType::String, + is_list: false, + is_visible: true, + is_editable: false, + }; + client + .add_group_attribute(&input) + .await + .expect("should succeed"); + } + + #[tokio::test] + async fn delete_user_attribute_succeeds() { + let client = MockAttributeSchemaClient; + client + .delete_user_attribute("phone") + .await + .expect("should succeed"); + } + + #[tokio::test] + async fn delete_group_attribute_succeeds() { + let client = MockAttributeSchemaClient; + client + .delete_group_attribute("location") + .await + .expect("should succeed"); + } +} diff --git a/crates/lldap-operator-traits/src/group.rs b/crates/lldap-operator-traits/src/group.rs new file mode 100644 index 0000000..bee8d8d --- /dev/null +++ b/crates/lldap-operator-traits/src/group.rs @@ -0,0 +1,160 @@ +use std::future::Future; + +use crate::attribute::AttributeValue; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Group { + pub id: i64, + pub display_name: String, + pub uuid: String, + pub attributes: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreateGroupInput { + pub display_name: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UpdateGroupInput { + pub id: i64, + pub display_name: Option, + pub attributes: Option>, +} + +pub trait GroupClient { + type Error: std::error::Error + Send + Sync + 'static; + + fn get_group(&self, group_id: i64) -> impl Future> + Send; + + fn get_group_by_display_name( + &self, + display_name: &str, + ) -> impl Future> + Send; + + fn list_groups(&self) -> impl Future, Self::Error>> + Send; + + fn create_group( + &self, + input: &CreateGroupInput, + ) -> impl Future> + Send; + + fn update_group( + &self, + input: &UpdateGroupInput, + ) -> impl Future> + Send; + + fn delete_group(&self, group_id: i64) -> impl Future> + Send; +} + +#[cfg(test)] +mod tests { + use std::fmt; + + use super::*; + + #[derive(Debug)] + struct MockError; + + impl fmt::Display for MockError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("mock error") + } + } + + impl std::error::Error for MockError {} + + struct MockGroupClient; + + fn sample_group() -> Group { + Group { + id: 1, + display_name: "engineers".to_owned(), + uuid: "660e8400-e29b-41d4-a716-446655440000".to_owned(), + attributes: vec![], + } + } + + impl GroupClient for MockGroupClient { + type Error = MockError; + + async fn get_group(&self, _group_id: i64) -> Result { + Ok(sample_group()) + } + + async fn get_group_by_display_name( + &self, + _display_name: &str, + ) -> Result { + Ok(sample_group()) + } + + async fn list_groups(&self) -> Result, Self::Error> { + Ok(vec![sample_group()]) + } + + async fn create_group(&self, _input: &CreateGroupInput) -> Result { + Ok(sample_group()) + } + + async fn update_group(&self, _input: &UpdateGroupInput) -> Result<(), Self::Error> { + Ok(()) + } + + async fn delete_group(&self, _group_id: i64) -> Result<(), Self::Error> { + Ok(()) + } + } + + #[tokio::test] + async fn get_group_returns_group() { + let client = MockGroupClient; + let group = client.get_group(1).await.expect("should succeed"); + assert_eq!(group.id, 1); + assert_eq!(group.display_name, "engineers"); + } + + #[tokio::test] + async fn get_group_by_display_name_returns_group() { + let client = MockGroupClient; + let group = client + .get_group_by_display_name("engineers") + .await + .expect("should succeed"); + assert_eq!(group.display_name, "engineers"); + } + + #[tokio::test] + async fn list_groups_returns_groups() { + let client = MockGroupClient; + let groups = client.list_groups().await.expect("should succeed"); + assert_eq!(groups.len(), 1); + } + + #[tokio::test] + async fn create_group_returns_created_group() { + let client = MockGroupClient; + let input = CreateGroupInput { + display_name: "engineers".to_owned(), + }; + let group = client.create_group(&input).await.expect("should succeed"); + assert_eq!(group.display_name, "engineers"); + } + + #[tokio::test] + async fn update_group_succeeds() { + let client = MockGroupClient; + let input = UpdateGroupInput { + id: 1, + display_name: Some("senior-engineers".to_owned()), + attributes: None, + }; + client.update_group(&input).await.expect("should succeed"); + } + + #[tokio::test] + async fn delete_group_succeeds() { + let client = MockGroupClient; + client.delete_group(1).await.expect("should succeed"); + } +} diff --git a/crates/lldap-operator-traits/src/lib.rs b/crates/lldap-operator-traits/src/lib.rs index 8b13789..6f42e76 100644 --- a/crates/lldap-operator-traits/src/lib.rs +++ b/crates/lldap-operator-traits/src/lib.rs @@ -1 +1,13 @@ +pub mod attribute; +pub mod group; +pub mod membership; +pub mod password; +pub mod user; +pub use attribute::{ + AddAttributeInput, AttributeSchema, AttributeSchemaClient, AttributeType, AttributeValue, +}; +pub use group::{CreateGroupInput, Group, GroupClient, UpdateGroupInput}; +pub use membership::MembershipClient; +pub use password::PasswordClient; +pub use user::{CreateUserInput, UpdateUserInput, User, UserClient}; diff --git a/crates/lldap-operator-traits/src/membership.rs b/crates/lldap-operator-traits/src/membership.rs new file mode 100644 index 0000000..8ce7806 --- /dev/null +++ b/crates/lldap-operator-traits/src/membership.rs @@ -0,0 +1,99 @@ +use std::future::Future; + +pub trait MembershipClient { + type Error: std::error::Error + Send + Sync + 'static; + + fn add_user_to_group( + &self, + username: &str, + group_id: i64, + ) -> impl Future> + Send; + + fn remove_user_from_group( + &self, + username: &str, + group_id: i64, + ) -> impl Future> + Send; + + fn check_membership( + &self, + username: &str, + group_id: i64, + ) -> impl Future> + Send; +} + +#[cfg(test)] +mod tests { + use std::fmt; + + use super::*; + + #[derive(Debug)] + struct MockError; + + impl fmt::Display for MockError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("mock error") + } + } + + impl std::error::Error for MockError {} + + struct MockMembershipClient; + + impl MembershipClient for MockMembershipClient { + type Error = MockError; + + async fn add_user_to_group( + &self, + _username: &str, + _group_id: i64, + ) -> Result<(), Self::Error> { + Ok(()) + } + + async fn remove_user_from_group( + &self, + _username: &str, + _group_id: i64, + ) -> Result<(), Self::Error> { + Ok(()) + } + + async fn check_membership( + &self, + _username: &str, + _group_id: i64, + ) -> Result { + Ok(true) + } + } + + #[tokio::test] + async fn add_user_to_group_succeeds() { + let client = MockMembershipClient; + client + .add_user_to_group("jdoe", 1) + .await + .expect("should succeed"); + } + + #[tokio::test] + async fn remove_user_from_group_succeeds() { + let client = MockMembershipClient; + client + .remove_user_from_group("jdoe", 1) + .await + .expect("should succeed"); + } + + #[tokio::test] + async fn check_membership_returns_true() { + let client = MockMembershipClient; + let is_member = client + .check_membership("jdoe", 1) + .await + .expect("should succeed"); + assert!(is_member); + } +} diff --git a/crates/lldap-operator-traits/src/password.rs b/crates/lldap-operator-traits/src/password.rs new file mode 100644 index 0000000..8ef4400 --- /dev/null +++ b/crates/lldap-operator-traits/src/password.rs @@ -0,0 +1,48 @@ +use std::future::Future; + +pub trait PasswordClient { + type Error: std::error::Error + Send + Sync + 'static; + + fn set_password( + &self, + username: &str, + password: &str, + ) -> impl Future> + Send; +} + +#[cfg(test)] +mod tests { + use std::fmt; + + use super::*; + + #[derive(Debug)] + struct MockError; + + impl fmt::Display for MockError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("mock error") + } + } + + impl std::error::Error for MockError {} + + struct MockPasswordClient; + + impl PasswordClient for MockPasswordClient { + type Error = MockError; + + async fn set_password(&self, _username: &str, _password: &str) -> Result<(), Self::Error> { + Ok(()) + } + } + + #[tokio::test] + async fn set_password_succeeds() { + let client = MockPasswordClient; + client + .set_password("jdoe", "s3cret") + .await + .expect("should succeed"); + } +} diff --git a/crates/lldap-operator-traits/src/user.rs b/crates/lldap-operator-traits/src/user.rs new file mode 100644 index 0000000..66efc1b --- /dev/null +++ b/crates/lldap-operator-traits/src/user.rs @@ -0,0 +1,160 @@ +use std::future::Future; + +use crate::attribute::AttributeValue; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct User { + pub username: String, + pub email: String, + pub display_name: Option, + pub first_name: Option, + pub last_name: Option, + pub uuid: String, + pub creation_date: String, + pub attributes: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreateUserInput { + pub username: String, + pub email: String, + pub display_name: Option, + pub first_name: Option, + pub last_name: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UpdateUserInput { + pub username: String, + pub email: Option, + pub display_name: Option, + pub first_name: Option, + pub last_name: Option, + pub attributes: Option>, +} + +pub trait UserClient { + type Error: std::error::Error + Send + Sync + 'static; + + fn get_user(&self, username: &str) -> impl Future> + Send; + + fn list_users(&self) -> impl Future, Self::Error>> + Send; + + fn create_user( + &self, + input: &CreateUserInput, + ) -> impl Future> + Send; + + fn update_user( + &self, + input: &UpdateUserInput, + ) -> impl Future> + Send; + + fn delete_user(&self, username: &str) -> impl Future> + Send; +} + +#[cfg(test)] +mod tests { + use std::fmt; + + use super::*; + + #[derive(Debug)] + struct MockError; + + impl fmt::Display for MockError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("mock error") + } + } + + impl std::error::Error for MockError {} + + struct MockUserClient; + + fn sample_user() -> User { + User { + username: "jdoe".to_owned(), + email: "jdoe@example.com".to_owned(), + display_name: Some("John Doe".to_owned()), + first_name: Some("John".to_owned()), + last_name: Some("Doe".to_owned()), + uuid: "550e8400-e29b-41d4-a716-446655440000".to_owned(), + creation_date: "2025-01-01T00:00:00Z".to_owned(), + attributes: vec![], + } + } + + impl UserClient for MockUserClient { + type Error = MockError; + + async fn get_user(&self, _username: &str) -> Result { + Ok(sample_user()) + } + + async fn list_users(&self) -> Result, Self::Error> { + Ok(vec![sample_user()]) + } + + async fn create_user(&self, _input: &CreateUserInput) -> Result { + Ok(sample_user()) + } + + async fn update_user(&self, _input: &UpdateUserInput) -> Result<(), Self::Error> { + Ok(()) + } + + async fn delete_user(&self, _username: &str) -> Result<(), Self::Error> { + Ok(()) + } + } + + #[tokio::test] + async fn get_user_returns_user() { + let client = MockUserClient; + let user = client.get_user("jdoe").await.expect("should succeed"); + assert_eq!(user.username, "jdoe"); + assert_eq!(user.email, "jdoe@example.com"); + } + + #[tokio::test] + async fn list_users_returns_users() { + let client = MockUserClient; + let users = client.list_users().await.expect("should succeed"); + assert_eq!(users.len(), 1); + } + + #[tokio::test] + async fn create_user_returns_created_user() { + let client = MockUserClient; + let input = CreateUserInput { + username: "jdoe".to_owned(), + email: "jdoe@example.com".to_owned(), + display_name: None, + first_name: None, + last_name: None, + }; + let user = client.create_user(&input).await.expect("should succeed"); + assert_eq!(user.username, "jdoe"); + } + + #[tokio::test] + async fn update_user_succeeds() { + let client = MockUserClient; + let input = UpdateUserInput { + username: "jdoe".to_owned(), + email: Some("new@example.com".to_owned()), + display_name: None, + first_name: None, + last_name: None, + attributes: None, + }; + client.update_user(&input).await.expect("should succeed"); + } + + #[tokio::test] + async fn delete_user_succeeds() { + let client = MockUserClient; + client.delete_user("jdoe").await.expect("should succeed"); + } +} diff --git a/docs/src/architecture.md b/docs/src/architecture.md index 231e53b..cd98ee1 100644 --- a/docs/src/architecture.md +++ b/docs/src/architecture.md @@ -16,4 +16,3 @@ The operator is organized as a Cargo workspace with focused crates: | `lldap-operator-reconciler` | Reconciliation business logic | -