diff --git a/queries/src/lib.rs b/queries/src/lib.rs index 2869489..b3f0cb8 100644 --- a/queries/src/lib.rs +++ b/queries/src/lib.rs @@ -110,6 +110,68 @@ pub struct DeleteGroup { pub delete_group: Success, } +#[derive(cynic::QueryFragment, Debug)] +#[cynic(graphql_type = "Query")] +pub struct GetUserAttributes { + pub schema: Schema, +} + +#[derive(cynic::QueryFragment, Debug)] +pub struct Schema { + pub user_schema: AttributeList, +} + +#[derive(cynic::QueryFragment, Debug)] +pub struct AttributeList { + pub attributes: Vec, +} + +#[derive(cynic::QueryFragment, Debug)] +pub struct AttributeSchema { + pub name: String, + pub is_visible: bool, + pub is_list: bool, + pub is_editable: bool, + pub attribute_type: AttributeType, +} + +#[derive(cynic::Enum, Clone, Copy, Debug, PartialEq, Eq)] +pub enum AttributeType { + String, + Integer, + JpegPhoto, + DateTime, +} + +#[derive(cynic::QueryVariables, Debug)] +pub struct CreateUserAttributeVariables<'a> { + pub editable: bool, + pub list: bool, + pub name: &'a str, + #[cynic(rename = "type")] + pub r#type: AttributeType, + pub visible: bool, +} + +#[derive(cynic::QueryFragment, Debug)] +#[cynic(graphql_type = "Mutation", variables = "CreateUserAttributeVariables")] +pub struct CreateUserAttribute { + #[arguments(attributeType: $r#type, isEditable: $editable, isList: $list, isVisible: $visible, name: $name)] + pub add_user_attribute: Success, +} + +#[derive(cynic::QueryVariables, Debug)] +pub struct DeleteUserAttributeVariables<'a> { + pub name: &'a str, +} + +#[derive(cynic::QueryFragment, Debug)] +#[cynic(graphql_type = "Mutation", variables = "DeleteUserAttributeVariables")] +pub struct DeleteUserAttribute { + #[arguments(name: $name)] + pub delete_user_attribute: Success, +} + #[cfg(test)] mod tests { use cynic::{MutationBuilder, QueryBuilder}; @@ -177,4 +239,31 @@ mod tests { insta::assert_snapshot!(operation.query); } + + #[test] + fn get_user_attributes_gql_output() { + let operation = GetUserAttributes::build(()); + + insta::assert_snapshot!(operation.query); + } + + #[test] + fn create_user_attribute_gql_output() { + let operation = CreateUserAttribute::build(CreateUserAttributeVariables { + r#type: AttributeType::String, + list: true, + editable: true, + visible: true, + name: "attr", + }); + + insta::assert_snapshot!(operation.query); + } + + #[test] + fn delete_user_attribute_gql_output() { + let operation = DeleteUserAttribute::build(DeleteUserAttributeVariables { name: "attr" }); + + insta::assert_snapshot!(operation.query); + } } diff --git a/queries/src/snapshots/queries__tests__create_user_attribute_gql_output.snap b/queries/src/snapshots/queries__tests__create_user_attribute_gql_output.snap new file mode 100644 index 0000000..5185edf --- /dev/null +++ b/queries/src/snapshots/queries__tests__create_user_attribute_gql_output.snap @@ -0,0 +1,9 @@ +--- +source: queries/src/lib.rs +expression: operation.query +--- +mutation CreateUserAttribute($editable: Boolean!, $list: Boolean!, $name: String!, $type: AttributeType!, $visible: Boolean!) { + addUserAttribute(attributeType: $type, isEditable: $editable, isList: $list, isVisible: $visible, name: $name) { + ok + } +} diff --git a/queries/src/snapshots/queries__tests__delete_user_attribute_gql_output.snap b/queries/src/snapshots/queries__tests__delete_user_attribute_gql_output.snap new file mode 100644 index 0000000..556041a --- /dev/null +++ b/queries/src/snapshots/queries__tests__delete_user_attribute_gql_output.snap @@ -0,0 +1,9 @@ +--- +source: queries/src/lib.rs +expression: operation.query +--- +mutation DeleteUserAttribute($name: String!) { + deleteUserAttribute(name: $name) { + ok + } +} diff --git a/queries/src/snapshots/queries__tests__get_user_attributes_gql_output.snap b/queries/src/snapshots/queries__tests__get_user_attributes_gql_output.snap new file mode 100644 index 0000000..1b37adf --- /dev/null +++ b/queries/src/snapshots/queries__tests__get_user_attributes_gql_output.snap @@ -0,0 +1,17 @@ +--- +source: queries/src/lib.rs +expression: operation.query +--- +query GetUserAttributes { + schema { + userSchema { + attributes { + name + isVisible + isList + isEditable + attributeType + } + } + } +} diff --git a/src/bin/crdgen.rs b/src/bin/crdgen.rs index 34717e2..abc1287 100644 --- a/src/bin/crdgen.rs +++ b/src/bin/crdgen.rs @@ -1,9 +1,11 @@ use kube::CustomResourceExt; fn main() { - print!( - "{}---\n{}", + let resources = [ serde_yaml::to_string(&lldap_controller::resources::ServiceUser::crd()).unwrap(), - serde_yaml::to_string(&lldap_controller::resources::Group::crd()).unwrap() - ) + serde_yaml::to_string(&lldap_controller::resources::Group::crd()).unwrap(), + serde_yaml::to_string(&lldap_controller::resources::UserAttribute::crd()).unwrap(), + ] + .join("---\n"); + print!("{resources}") } diff --git a/src/context.rs b/src/context.rs index 3f551e2..0bdf157 100644 --- a/src/context.rs +++ b/src/context.rs @@ -53,6 +53,18 @@ pub trait ControllerEvents { async fn user_not_found(&self, obj: &T, username: &str) -> Result<(), Self::Error> where T: Resource + Sync; + + async fn user_attribute_created(&self, obj: &T) -> Result<(), Self::Error> + where + T: Resource + Sync; + + async fn user_attribute_desync(&self, obj: &T, fields: &[String]) -> Result<(), Self::Error> + where + T: Resource + Sync; + + async fn user_attribute_deleted(&self, obj: &T) -> Result<(), Self::Error> + where + T: Resource + Sync; } impl ControllerEvents for Recorder { @@ -159,4 +171,57 @@ impl ControllerEvents for Recorder { ) .await } + + async fn user_attribute_created(&self, obj: &T) -> Result<(), Self::Error> + where + T: Resource + Sync, + { + self.publish( + &Event { + type_: EventType::Warning, + reason: "Created".into(), + note: Some("Created user attribute".into()), + action: "Created".into(), + secondary: None, + }, + &obj.object_ref(&()), + ) + .await + } + + async fn user_attribute_desync(&self, obj: &T, fields: &[String]) -> Result<(), Self::Error> + where + T: Resource + Sync, + { + self.publish( + &Event { + type_: EventType::Warning, + reason: "Desync".into(), + note: Some(format!( + "User attribute fields '{fields:?}' are out of sync" + )), + action: "Desync".into(), + secondary: None, + }, + &obj.object_ref(&()), + ) + .await + } + + async fn user_attribute_deleted(&self, obj: &T) -> Result<(), Self::Error> + where + T: Resource + Sync, + { + self.publish( + &Event { + type_: EventType::Warning, + reason: "Deleted".into(), + note: Some("Deleted user attribute'".into()), + action: "Deleted".into(), + secondary: None, + }, + &obj.object_ref(&()), + ) + .await + } } diff --git a/src/lldap.rs b/src/lldap.rs index 2e51192..bbc332f 100644 --- a/src/lldap.rs +++ b/src/lldap.rs @@ -8,14 +8,17 @@ use lldap_auth::opaque::AuthenticationError; use lldap_auth::registration::ServerRegistrationStartResponse; use lldap_auth::{opaque, registration}; use queries::{ - AddUserToGroup, AddUserToGroupVariables, CreateGroup, CreateGroupVariables, CreateUser, - CreateUserVariables, DeleteGroup, DeleteGroupVariables, DeleteUser, DeleteUserVariables, - GetGroups, GetUser, GetUserVariables, Group, RemoveUserFromGroup, RemoveUserFromGroupVariables, - User, + AddUserToGroup, AddUserToGroupVariables, AttributeSchema, CreateGroup, CreateGroupVariables, + CreateUser, CreateUserAttribute, CreateUserAttributeVariables, CreateUserVariables, + DeleteGroup, DeleteGroupVariables, DeleteUser, DeleteUserAttribute, + DeleteUserAttributeVariables, DeleteUserVariables, GetGroups, GetUser, GetUserAttributes, + GetUserVariables, Group, RemoveUserFromGroup, RemoveUserFromGroupVariables, User, }; use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue}; use tracing::{debug, trace}; +use crate::resources::AttributeType; + #[derive(thiserror::Error, Debug)] pub enum Error { #[error("Cynic error: {0}")] @@ -285,4 +288,60 @@ impl LldapClient { Ok(()) } + + pub async fn get_user_attributes(&self) -> Result> { + let operation = GetUserAttributes::build(()); + + let response = self + .client + .post(format!("{}/api/graphql", self.url)) + .run_graphql(operation) + .await?; + + Ok(check_graphql_errors(response)? + .schema + .user_schema + .attributes) + } + + pub async fn create_user_attribute( + &self, + name: &str, + r#type: AttributeType, + list: bool, + visible: bool, + editable: bool, + ) -> Result<()> { + let operation = CreateUserAttribute::build(CreateUserAttributeVariables { + name, + r#type: r#type.into(), + list, + visible, + editable, + }); + + let response = self + .client + .post(format!("{}/api/graphql", self.url)) + .run_graphql(operation) + .await?; + + check_graphql_errors(response)?; + + Ok(()) + } + + pub async fn delete_user_attribute(&self, name: &str) -> Result<()> { + let operation = DeleteUserAttribute::build(DeleteUserAttributeVariables { name }); + + let response = self + .client + .post(format!("{}/api/graphql", self.url)) + .run_graphql(operation) + .await?; + + check_graphql_errors(response)?; + + Ok(()) + } } diff --git a/src/main.rs b/src/main.rs index d4c9fa7..d7dc40c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,7 +9,7 @@ use kube::runtime::{Controller, watcher}; use kube::{Api, Client as KubeClient, Resource}; use lldap_controller::context::Context; use lldap_controller::lldap::LldapConfig; -use lldap_controller::resources::{self, Error, Group, ServiceUser, reconcile}; +use lldap_controller::resources::{self, Error, Group, ServiceUser, UserAttribute, reconcile}; use tracing::{debug, info, warn}; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; @@ -55,9 +55,9 @@ async fn main() -> anyhow::Result<()> { LldapConfig::try_from_env()?, ); - let service_users = Api::::all(client.clone()); let secrets = Api::::all(client.clone()); + let service_users = Api::::all(client.clone()); let service_user_controller = Controller::new(service_users, Default::default()) .owns(secrets, Default::default()) .shutdown_on_signal() @@ -65,13 +65,22 @@ async fn main() -> anyhow::Result<()> { .for_each(log_status); let groups = Api::::all(client.clone()); - let group_controller = Controller::new(groups, Default::default()) + .shutdown_on_signal() + .run(reconcile, error_policy, Arc::new(data.clone())) + .for_each(log_status); + + let user_attributes = Api::::all(client.clone()); + let user_attribute_controller = Controller::new(user_attributes, Default::default()) .shutdown_on_signal() .run(reconcile, error_policy, Arc::new(data)) .for_each(log_status); - tokio::join!(service_user_controller, group_controller); + tokio::join!( + service_user_controller, + group_controller, + user_attribute_controller + ); Ok(()) } diff --git a/src/resources/mod.rs b/src/resources/mod.rs index f5c581f..93d5cc8 100644 --- a/src/resources/mod.rs +++ b/src/resources/mod.rs @@ -1,5 +1,6 @@ mod group; mod service_user; +mod user_attribute; use core::fmt; use std::sync::Arc; @@ -13,6 +14,7 @@ use tracing::{debug, instrument}; pub use self::group::Group; pub use self::service_user::ServiceUser; +pub use self::user_attribute::{Type as AttributeType, UserAttribute}; use crate::context::Context; use crate::lldap; @@ -28,6 +30,8 @@ pub enum Error { Finalizer(#[source] Box>), #[error("MissingObjectKey: {0}")] MissingObjectKey(&'static str), + #[error("UserAttributeDesync: {0:?}")] + UserAttributeDesync(Vec), } impl From> for Error { diff --git a/src/resources/user_attribute.rs b/src/resources/user_attribute.rs new file mode 100644 index 0000000..a3a95e8 --- /dev/null +++ b/src/resources/user_attribute.rs @@ -0,0 +1,194 @@ +use std::time::Duration; + +use kube::{ + Api, CELSchema, CustomResource, + api::{Patch, PatchParams}, + runtime::controller::Action, +}; +use queries::AttributeType; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tracing::{debug, trace, warn}; + +use crate::{ + context::ControllerEvents, + lldap, + resources::{Error, user_attribute}, +}; + +use super::Reconcile; + +#[derive(Deserialize, Serialize, Clone, Copy, Debug, JsonSchema)] +pub enum Type { + String, + Integer, + Jpeg, + DateTime, +} + +impl From for AttributeType { + fn from(t: Type) -> Self { + match t { + Type::String => Self::String, + Type::Integer => Self::Integer, + Type::Jpeg => Self::JpegPhoto, + Type::DateTime => Self::DateTime, + } + } +} + +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, CELSchema)] +#[kube( + kind = "UserAttribute", + group = "lldap.huizinga.dev", + version = "v1", + status = "UserAttributesStatus" +)] +#[kube( + shortname = "lua", + doc = "Custom resource for managing custom User Attributes inside of LLDAP", + printcolumn = r#"{"name":"Type", "type":"string", "description":"Type of attribute", "jsonPath":".spec.type"}"#, + printcolumn = r#"{"name":"List", "type":"boolean", "description":"Can the attribute contain multiple values", "jsonPath":".spec.list"}"#, + printcolumn = r#"{"name":"Visible", "type":"boolean", "description":"Can users see the value", "jsonPath":".spec.userVisible"}"#, + printcolumn = r#"{"name":"Editable", "type":"boolean", "description":"Can users edit the value", "jsonPath":".spec.userEditable"}"#, + printcolumn = r#"{"name":"Synced", "type":"boolean", "jsonPath":".status.synced"}"#, + printcolumn = r#"{"name":"Age", "type":"date", "jsonPath":".metadata.creationTimestamp"}"# +)] +#[kube( + rule = Rule::new("self.spec == oldSelf.spec").message("User attributes are immutable"), + rule = Rule::new("!self.spec.userEditable || self.spec.userVisible && self.spec.userEditable").message("Editable attribute must also be visible") +)] +#[serde(rename_all = "camelCase")] +pub struct UesrAttributeSpec { + r#type: Type, + #[serde(default)] + list: bool, + #[serde(default)] + user_visible: bool, + #[serde(default)] + user_editable: bool, +} +#[derive(Deserialize, Serialize, Clone, Default, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct UserAttributesStatus { + pub synced: bool, +} + +impl Reconcile for UserAttribute { + async fn reconcile( + self: std::sync::Arc, + ctx: std::sync::Arc, + ) -> super::Result { + let name = self + .metadata + .name + .clone() + .ok_or(Error::MissingObjectKey(".metadata.name"))?; + + debug!(name, "Apply"); + + trace!(name, "Get existing attributes"); + let lldap_client = ctx.lldap_config.build_client().await?; + let user_attributes = lldap_client.get_user_attributes().await?; + trace!("{user_attributes:?}"); + + let client = &ctx.client; + let api = Api::::all(client.clone()); + if let Some(attribute) = user_attributes + .iter() + .find(|attribute| attribute.name == name) + { + trace!("User attribute already exists: {attribute:?}"); + let mut desynced: Vec = Vec::new(); + if attribute.attribute_type != self.spec.r#type.into() { + desynced.push("type".into()); + } + + if attribute.is_list != self.spec.list { + desynced.push("list".into()); + } + + if attribute.is_visible != self.spec.user_visible { + desynced.push("userVisible".into()); + } + + if attribute.is_editable != self.spec.user_editable { + desynced.push("userEditable".into()); + } + + if !desynced.is_empty() { + set_status(&api, &name, false).await?; + ctx.recorder + .user_attribute_desync(self.as_ref(), &desynced) + .await?; + + return Err(Error::UserAttributeDesync(desynced)); + } + + trace!("User attribute matches with spec"); + } else { + trace!("User attribute does not exist yet"); + + lldap_client + .create_user_attribute( + &name, + self.spec.r#type, + self.spec.list, + self.spec.user_visible, + self.spec.user_editable, + ) + .await?; + ctx.recorder.user_attribute_created(self.as_ref()).await?; + } + + set_status(&api, &name, true).await?; + + Ok(Action::requeue(Duration::from_secs(3600))) + } + + async fn cleanup( + self: std::sync::Arc, + ctx: std::sync::Arc, + ) -> super::Result { + let name = self + .metadata + .name + .clone() + .ok_or(Error::MissingObjectKey(".metadata.name"))?; + + debug!(name, "Cleanup"); + + let lldap_client = ctx.lldap_config.build_client().await?; + + trace!(name, "Deleting user attribute"); + match lldap_client.delete_user_attribute(&name).await { + Err(lldap::Error::GraphQl(err)) + if err.message == format!("Attribute {name} is not defined in the schema") => + { + warn!(name, "User attribute not found"); + Ok(()) + } + Ok(_) => { + ctx.recorder.user_attribute_deleted(self.as_ref()).await?; + Ok(()) + } + Err(err) => Err(err), + }?; + + Ok(Action::await_change()) + } +} + +async fn set_status( + api: &Api, + name: &str, + synced: bool, +) -> Result { + trace!(name, "Updating status"); + let status = json!({ + "status": UserAttributesStatus { synced } + }); + api.patch_status(name, &PatchParams::default(), &Patch::Merge(&status)) + .await +} diff --git a/yaml/user_attribute.yaml b/yaml/user_attribute.yaml new file mode 100644 index 0000000..57ecd58 --- /dev/null +++ b/yaml/user_attribute.yaml @@ -0,0 +1,9 @@ +apiVersion: lldap.huizinga.dev/v1 +kind: UserAttribute +metadata: + name: test-attribute +spec: + type: String + list: true + userVisible: true + userEditable: true