Added UserAttribute crd to control user attributes (#9)

This commit is contained in:
Dreaded_X 2025-04-14 00:52:04 +02:00
parent 5d5c916a01
commit d21b53cf34
Signed by: Dreaded_X
GPG Key ID: 5A0CBFE3C3377FAA
11 changed files with 478 additions and 12 deletions

View File

@ -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<AttributeSchema>,
}
#[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);
}
}

View File

@ -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
}
}

View File

@ -0,0 +1,9 @@
---
source: queries/src/lib.rs
expression: operation.query
---
mutation DeleteUserAttribute($name: String!) {
deleteUserAttribute(name: $name) {
ok
}
}

View File

@ -0,0 +1,17 @@
---
source: queries/src/lib.rs
expression: operation.query
---
query GetUserAttributes {
schema {
userSchema {
attributes {
name
isVisible
isList
isEditable
attributeType
}
}
}
}

View File

@ -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}")
}

View File

@ -53,6 +53,18 @@ pub trait ControllerEvents {
async fn user_not_found<T>(&self, obj: &T, username: &str) -> Result<(), Self::Error>
where
T: Resource<DynamicType = ()> + Sync;
async fn user_attribute_created<T>(&self, obj: &T) -> Result<(), Self::Error>
where
T: Resource<DynamicType = ()> + Sync;
async fn user_attribute_desync<T>(&self, obj: &T, fields: &[String]) -> Result<(), Self::Error>
where
T: Resource<DynamicType = ()> + Sync;
async fn user_attribute_deleted<T>(&self, obj: &T) -> Result<(), Self::Error>
where
T: Resource<DynamicType = ()> + Sync;
}
impl ControllerEvents for Recorder {
@ -159,4 +171,57 @@ impl ControllerEvents for Recorder {
)
.await
}
async fn user_attribute_created<T>(&self, obj: &T) -> Result<(), Self::Error>
where
T: Resource<DynamicType = ()> + 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<T>(&self, obj: &T, fields: &[String]) -> Result<(), Self::Error>
where
T: Resource<DynamicType = ()> + 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<T>(&self, obj: &T) -> Result<(), Self::Error>
where
T: Resource<DynamicType = ()> + 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
}
}

View File

@ -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<Vec<AttributeSchema>> {
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(())
}
}

View File

@ -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::<ServiceUser>::all(client.clone());
let secrets = Api::<Secret>::all(client.clone());
let service_users = Api::<ServiceUser>::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::<Group>::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::<UserAttribute>::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(())
}

View File

@ -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<finalizer::Error<Self>>),
#[error("MissingObjectKey: {0}")]
MissingObjectKey(&'static str),
#[error("UserAttributeDesync: {0:?}")]
UserAttributeDesync(Vec<String>),
}
impl From<finalizer::Error<Self>> for Error {

View File

@ -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<Type> 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<Self>,
ctx: std::sync::Arc<crate::context::Context>,
) -> super::Result<kube::runtime::controller::Action> {
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::<UserAttribute>::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<String> = 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<Self>,
ctx: std::sync::Arc<crate::context::Context>,
) -> super::Result<kube::runtime::controller::Action> {
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<UserAttribute>,
name: &str,
synced: bool,
) -> Result<UserAttribute, kube::Error> {
trace!(name, "Updating status");
let status = json!({
"status": UserAttributesStatus { synced }
});
api.patch_status(name, &PatchParams::default(), &Patch::Merge(&status))
.await
}

9
yaml/user_attribute.yaml Normal file
View File

@ -0,0 +1,9 @@
apiVersion: lldap.huizinga.dev/v1
kind: UserAttribute
metadata:
name: test-attribute
spec:
type: String
list: true
userVisible: true
userEditable: true