Moved ServiceUser into separate file
This commit is contained in:
75
src/resources/mod.rs
Normal file
75
src/resources/mod.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
mod service_user;
|
||||
|
||||
use core::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use k8s_openapi::NamespaceResourceScope;
|
||||
use kube::runtime::controller::Action;
|
||||
use kube::runtime::finalizer;
|
||||
use kube::{Api, Resource, ResourceExt};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
use tracing::{debug, instrument};
|
||||
|
||||
use crate::context::Context;
|
||||
use crate::lldap;
|
||||
|
||||
pub use service_user::ServiceUser;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("Failed to commit: {0}")]
|
||||
Commit(#[from] kube::api::entry::CommitError),
|
||||
#[error("Kube api error: {0}")]
|
||||
Kube(#[from] kube::Error),
|
||||
#[error("LLDAP error: {0}")]
|
||||
Lldap(#[from] lldap::Error),
|
||||
#[error("Finalizer error: {0}")]
|
||||
Finalizer(#[source] Box<finalizer::Error<Self>>),
|
||||
#[error("MissingObjectKey: {0}")]
|
||||
MissingObjectKey(&'static str),
|
||||
}
|
||||
|
||||
impl From<finalizer::Error<Self>> for Error {
|
||||
fn from(error: finalizer::Error<Self>) -> Self {
|
||||
Self::Finalizer(Box::new(error))
|
||||
}
|
||||
}
|
||||
|
||||
type Result<T, E = Error> = std::result::Result<T, E>;
|
||||
|
||||
#[async_trait]
|
||||
trait Reconcile {
|
||||
async fn reconcile(self: Arc<Self>, ctx: Arc<Context>) -> Result<Action>;
|
||||
|
||||
async fn cleanup(self: Arc<Self>, ctx: Arc<Context>) -> Result<Action>;
|
||||
}
|
||||
|
||||
#[instrument(skip(obj, ctx))]
|
||||
pub async fn reconcile<T>(obj: Arc<T>, ctx: Arc<Context>) -> Result<Action>
|
||||
where
|
||||
T: Resource<Scope = NamespaceResourceScope>
|
||||
+ ResourceExt
|
||||
+ Clone
|
||||
+ Serialize
|
||||
+ DeserializeOwned
|
||||
+ fmt::Debug
|
||||
+ Reconcile,
|
||||
<T as Resource>::DynamicType: Default,
|
||||
{
|
||||
debug!(name = obj.name_any(), "Reconcile");
|
||||
|
||||
let namespace = obj.namespace().expect("Resource is namespace scoped");
|
||||
let service_users = Api::<T>::namespaced(ctx.client.clone(), &namespace);
|
||||
|
||||
Ok(
|
||||
finalizer(&service_users, &ctx.controller_name, obj, |event| async {
|
||||
match event {
|
||||
finalizer::Event::Apply(obj) => obj.reconcile(ctx.clone()).await,
|
||||
finalizer::Event::Cleanup(obj) => obj.cleanup(ctx.clone()).await,
|
||||
}
|
||||
})
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
239
src/resources/service_user.rs
Normal file
239
src/resources/service_user.rs
Normal file
@@ -0,0 +1,239 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::str::from_utf8;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use k8s_openapi::api::core::v1::Secret;
|
||||
use k8s_openapi::apimachinery::pkg::apis::meta::v1::OwnerReference;
|
||||
use kube::api::{ObjectMeta, Patch, PatchParams, PostParams};
|
||||
use kube::runtime::controller::Action;
|
||||
use kube::{Api, CustomResource, Resource};
|
||||
use passwords::PasswordGenerator;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::{debug, trace, warn};
|
||||
|
||||
use super::{Error, Reconcile, Result};
|
||||
use crate::context::{Context, ControllerEvents};
|
||||
use crate::lldap;
|
||||
|
||||
#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)]
|
||||
#[kube(
|
||||
kind = "ServiceUser",
|
||||
group = "lldap.huizinga.dev",
|
||||
version = "v1",
|
||||
namespaced,
|
||||
status = "ServiceUserStatus"
|
||||
)]
|
||||
#[kube(
|
||||
shortname = "lsu",
|
||||
doc = "Custom resource for managing Service Users inside of LLDAP",
|
||||
printcolumn = r#"{"name":"Manager", "type":"boolean", "description":"Can the service user manage passwords", "jsonPath":".spec.passwordManager"}"#,
|
||||
printcolumn = r#"{"name":"Password", "type":"date", "description":"Secret creation timestamp", "jsonPath":".status.secretCreated"}"#,
|
||||
printcolumn = r#"{"name":"Age", "type":"date", "jsonPath":".metadata.creationTimestamp"}"#
|
||||
)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServiceUserSpec {
|
||||
#[serde(default)]
|
||||
password_manager: bool,
|
||||
#[serde(default)]
|
||||
additional_groups: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Default, Debug, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServiceUserStatus {
|
||||
pub secret_created: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
fn new_secret(username: &str, oref: OwnerReference) -> Secret {
|
||||
let pg = PasswordGenerator::new()
|
||||
.length(32)
|
||||
.uppercase_letters(true)
|
||||
.strict(true);
|
||||
|
||||
let mut contents = BTreeMap::new();
|
||||
contents.insert("username".into(), username.into());
|
||||
contents.insert(
|
||||
"password".into(),
|
||||
pg.generate_one().expect("Settings should be valid"),
|
||||
);
|
||||
|
||||
Secret {
|
||||
metadata: ObjectMeta {
|
||||
owner_references: Some(vec![oref]),
|
||||
..Default::default()
|
||||
},
|
||||
string_data: Some(contents),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn format_username(name: &str, namespace: &str) -> String {
|
||||
format!("{name}.{namespace}")
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Reconcile for ServiceUser {
|
||||
async fn reconcile(self: Arc<Self>, ctx: Arc<Context>) -> Result<Action> {
|
||||
let name = self
|
||||
.metadata
|
||||
.name
|
||||
.clone()
|
||||
.ok_or(Error::MissingObjectKey(".metadata.name"))?;
|
||||
let namespace = self
|
||||
.metadata
|
||||
.namespace
|
||||
.clone()
|
||||
.ok_or(Error::MissingObjectKey(".metadata.namespace"))?;
|
||||
let oref = self
|
||||
.controller_owner_ref(&())
|
||||
.expect("Field should populated by apiserver");
|
||||
|
||||
debug!(name, "Apply");
|
||||
|
||||
let secret_name = format!("{name}-lldap-credentials");
|
||||
let username = format_username(&name, &namespace);
|
||||
|
||||
let client = &ctx.client;
|
||||
let secrets = Api::<Secret>::namespaced(client.clone(), &namespace);
|
||||
|
||||
// TODO: Potentially issue: someone modifies the secret and removes the pass
|
||||
trace!(name, "Get or create secret");
|
||||
let mut created = false;
|
||||
let mut secret = secrets
|
||||
.entry(&secret_name)
|
||||
.await?
|
||||
.and_modify(|_| {
|
||||
debug!(name, secret_name, "Secret already exists");
|
||||
})
|
||||
.or_insert(|| {
|
||||
created = true;
|
||||
debug!(name, secret_name, "Generating new secret");
|
||||
|
||||
new_secret(&username, oref)
|
||||
});
|
||||
|
||||
trace!(name, "Committing secret");
|
||||
secret
|
||||
.commit(&PostParams {
|
||||
dry_run: false,
|
||||
field_manager: Some(ctx.controller_name.clone()),
|
||||
})
|
||||
.await?;
|
||||
let secret = secret;
|
||||
|
||||
if created {
|
||||
trace!(name, "Sending secret creating notification");
|
||||
// The reason this is here instead of inside the or_insert is that we
|
||||
// want to send the event _after_ it successfully committed.
|
||||
// Also or_insert is not async!
|
||||
ctx.recorder
|
||||
.secret_created(self.as_ref(), secret.get())
|
||||
.await?;
|
||||
}
|
||||
|
||||
let lldap_client = ctx.lldap_config.build_client().await?;
|
||||
|
||||
trace!(name, "Creating user if needed");
|
||||
let user = match lldap_client.get_user(&username).await {
|
||||
Err(lldap::Error::GraphQl(err))
|
||||
if err.message == format!("Entity not found: `{username}`") =>
|
||||
{
|
||||
debug!(name, username, "Creating new user");
|
||||
|
||||
let user = lldap_client.create_user(&username).await?;
|
||||
ctx.recorder.user_created(self.as_ref(), &username).await?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
Ok(user) => {
|
||||
debug!(name, username, "User already exists");
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}?;
|
||||
|
||||
trace!(name, "Updating groups");
|
||||
let mut groups = self.spec.additional_groups.clone();
|
||||
groups.push(
|
||||
if self.spec.password_manager {
|
||||
"lldap_password_manager"
|
||||
} else {
|
||||
"lldap_strict_readonly"
|
||||
}
|
||||
.to_owned(),
|
||||
);
|
||||
lldap_client.update_user_groups(&user, &groups).await?;
|
||||
|
||||
trace!(name, "Updating password");
|
||||
let password = secret.get().data.as_ref().unwrap().get("password").unwrap();
|
||||
let password = from_utf8(&password.0).unwrap();
|
||||
lldap_client.update_password(&username, password).await?;
|
||||
|
||||
trace!(name, "Updating status");
|
||||
let service_users = Api::<ServiceUser>::namespaced(client.clone(), &namespace);
|
||||
let status = json!({
|
||||
"status": ServiceUserStatus { secret_created: secret.get().meta().creation_timestamp.as_ref().map(|ts| ts.0) }
|
||||
});
|
||||
service_users
|
||||
.patch_status(&name, &PatchParams::default(), &Patch::Merge(&status))
|
||||
.await?;
|
||||
|
||||
Ok(Action::requeue(Duration::from_secs(3600)))
|
||||
}
|
||||
|
||||
async fn cleanup(self: Arc<Self>, ctx: Arc<Context>) -> Result<Action> {
|
||||
let name = self
|
||||
.metadata
|
||||
.name
|
||||
.clone()
|
||||
.ok_or(Error::MissingObjectKey(".metadata.name"))?;
|
||||
let namespace = self
|
||||
.metadata
|
||||
.namespace
|
||||
.clone()
|
||||
.ok_or(Error::MissingObjectKey(".metadata.namespace"))?;
|
||||
|
||||
debug!(name, "Cleanup");
|
||||
|
||||
let username = format_username(&name, &namespace);
|
||||
|
||||
let lldap_client = ctx.lldap_config.build_client().await?;
|
||||
|
||||
trace!(name, username, "Deleting user");
|
||||
match lldap_client.delete_user(&username).await {
|
||||
Err(lldap::Error::GraphQl(err))
|
||||
if err.message == format!("Entity not found: `No such user: '{username}'`") =>
|
||||
{
|
||||
ctx.recorder
|
||||
.user_not_found(self.as_ref(), &username)
|
||||
.await?;
|
||||
warn!(name, username, "User not found");
|
||||
Ok(())
|
||||
}
|
||||
Ok(_) => {
|
||||
ctx.recorder.user_deleted(self.as_ref(), &username).await?;
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}?;
|
||||
|
||||
Ok(Action::await_change())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kube::CustomResourceExt;
|
||||
|
||||
#[test]
|
||||
fn service_user_crd_output() {
|
||||
insta::assert_yaml_snapshot!(ServiceUser::crd());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
source: src/resources.rs
|
||||
expression: "ServiceUser::crd()"
|
||||
---
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
name: serviceusers.lldap.huizinga.dev
|
||||
spec:
|
||||
group: lldap.huizinga.dev
|
||||
names:
|
||||
categories: []
|
||||
kind: ServiceUser
|
||||
plural: serviceusers
|
||||
shortNames:
|
||||
- lsu
|
||||
singular: serviceuser
|
||||
scope: Namespaced
|
||||
versions:
|
||||
- additionalPrinterColumns:
|
||||
- description: Can the service user manage passwords
|
||||
jsonPath: ".spec.passwordManager"
|
||||
name: Manager
|
||||
type: boolean
|
||||
- description: Secret creation timestamp
|
||||
jsonPath: ".status.secretCreated"
|
||||
name: Password
|
||||
type: date
|
||||
- jsonPath: ".metadata.creationTimestamp"
|
||||
name: Age
|
||||
type: date
|
||||
name: v1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
description: Custom resource for managing Service Users inside of LLDAP
|
||||
properties:
|
||||
spec:
|
||||
properties:
|
||||
additionalGroups:
|
||||
default: []
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
passwordManager:
|
||||
default: false
|
||||
type: boolean
|
||||
type: object
|
||||
status:
|
||||
nullable: true
|
||||
properties:
|
||||
secretCreated:
|
||||
format: date-time
|
||||
nullable: true
|
||||
type: string
|
||||
type: object
|
||||
required:
|
||||
- spec
|
||||
title: ServiceUser
|
||||
type: object
|
||||
served: true
|
||||
storage: true
|
||||
subresources:
|
||||
status: {}
|
||||
Reference in New Issue
Block a user