Compare commits

..

39 Commits

Author SHA1 Message Date
cdb493d08b feat: Generate crds in Dockerfile
Some checks failed
Build and deploy / build (push) Failing after 11m40s
2025-12-21 07:04:49 +01:00
f1005138f4 feat: Update workflow to use docker bake 2025-12-21 06:41:04 +01:00
72bb27aae0 chore: Update dependencies 2025-12-21 06:19:04 +01:00
f98677eb1e Trigger reconcile webhook once build is done
All checks were successful
kustomization/lldap-controller/b5ae0585 reconciliation succeeded
Build and deploy / build (push) Successful in 7m36s
2025-04-23 21:00:10 +02:00
a6b342ea62 Use reusable workflow
All checks were successful
Build and deploy / build (push) Successful in 7m34s
kustomization/lldap-controller/b5ae0585 reconciliation succeeded
2025-04-23 14:15:19 +02:00
6bedf95348 Notify on build failure
All checks were successful
Build and deploy / Build container and manifests (push) Successful in 5m54s
kustomization/lldap-controller/b5ae0585 reconciliation succeeded
2025-04-22 18:24:06 +02:00
8a07d661ce Added option to load lldap password from file (#12)
All checks were successful
Build and deploy / Build container and manifests (push) Successful in 6m15s
kustomization/lldap-controller/b5ae0585 reconciliation succeeded
2025-04-22 11:13:46 +02:00
f714773ba8 Use custom error for missing environment variables
Some checks failed
Build and deploy / Build container and manifests (push) Has been cancelled
2025-04-22 10:44:25 +02:00
bb09334fad Include bind_dn field in secet (#13)
All checks were successful
Build and deploy / Build container and manifests (push) Successful in 7m8s
2025-04-22 00:34:50 +02:00
58bb0b312a Added dotenvy to make development a bit easier 2025-04-22 00:22:32 +02:00
46ea8e2cd7 Switched from anyhow to color_eyre 2025-04-22 00:22:30 +02:00
41efea3a98 Update rust 1.85 -> 1.86
All checks were successful
Build and deploy / Build container and manifests (push) Successful in 7m39s
2025-04-18 18:29:32 +02:00
884c37aa1b Small tweaks to bring in line with other Kubernetes tools 2025-04-18 18:29:31 +02:00
2d2ef6903b Fixed reconciliation of namespaced resources
All checks were successful
Build and deploy / Build container and manifests (push) Successful in 5m30s
2025-04-14 16:32:14 +02:00
7e4a4150ad Fixed permissions (#9)
All checks were successful
Build and deploy / Build container and manifests (push) Successful in 2m38s
2025-04-14 01:18:01 +02:00
9c37b2a2d1 Cleaned up events
All checks were successful
Build and deploy / Build container and manifests (push) Successful in 9m7s
2025-04-14 01:01:31 +02:00
d21b53cf34 Added UserAttribute crd to control user attributes (#9) 2025-04-14 01:01:08 +02:00
5d5c916a01 Cache docker layers
All checks were successful
Build and deploy / Build container and manifests (push) Successful in 2m51s
2025-03-22 06:28:31 +01:00
b24102a5b2 Update rbac to grant access to group resource
All checks were successful
Build and deploy / Build container and manifests (push) Successful in 8m43s
2025-03-22 05:10:55 +01:00
b9963dcb16 Use compact formatting for tracing if running through cargo
All checks were successful
Build and deploy / Build container and manifests (push) Successful in 7m23s
2025-03-22 04:58:56 +01:00
31354f8a83 Added demo yaml 2025-03-22 04:58:56 +01:00
5a254164cf Added Group controller (#8) 2025-03-22 04:58:54 +01:00
10354ee11a Added Group resource (#8) 2025-03-22 04:18:38 +01:00
64eac3d7c1 Added create/delete group queries (#8) 2025-03-22 04:18:29 +01:00
2e952ea8cd Remove async-traits 2025-03-22 04:18:29 +01:00
4308312a61 Fix formatting 2025-03-22 04:18:29 +01:00
b5342ab86a Update to rust edition 2024 2025-03-22 04:18:29 +01:00
13ddc853fb Moved ServiceUser into separate file 2025-03-22 04:18:15 +01:00
f085bf1088 Build auditable binaries 2025-03-22 04:18:15 +01:00
0567dea6c5 Add attestations to image 2025-03-22 04:18:15 +01:00
4a589395d2 Add annotations instead of labels to image 2025-03-22 04:18:15 +01:00
99977c1f8d Set SOURCE_DATE_EPOCH during image build 2025-03-22 04:18:15 +01:00
1b2e0faece Switched to nonroot distroless base and improved layer caching 2025-03-22 04:18:15 +01:00
16dc78358d Switch to hadolint for linting Dockerfile 2025-03-22 04:18:15 +01:00
a80e03ac90 Lock rust toolchain version to same version used in Dockerfile 2025-03-22 04:18:14 +01:00
4741fc1f00 Update crates 2025-03-22 04:18:14 +01:00
cc9a8c787f Run clippy, audit, and test also on Cargo.lock changes 2025-03-22 04:18:14 +01:00
a4deeac442 Add cargo audit 2025-03-22 04:18:14 +01:00
713a6da6e9 Improve workflow and generate CRDs (#3) 2025-03-22 04:17:08 +01:00
33 changed files with 1861 additions and 1024 deletions

View File

@@ -1,2 +1,2 @@
[advisories] [advisories]
ignore = ["RUSTSEC-2024-0344"] ignore = ["RUSTSEC-2024-0344", "RUSTSEC-2025-0023"]

View File

@@ -2,3 +2,4 @@
!queries !queries
!src !src
!Cargo.* !Cargo.*
!.cargo/config.toml

View File

@@ -7,80 +7,9 @@ on:
tags: tags:
- v*.*.* - v*.*.*
env:
OCI_REPO: git.huizinga.dev/dreaded_x/${{ gitea.event.repository.name}}
jobs: jobs:
build: build:
name: Build container and manifests uses: dreaded_x/workflows/.gitea/workflows/docker-kubernetes.yaml@ef78704b98c72e4a6b8340f9bff7b085a7bdd95c
runs-on: ubuntu-latest secrets: inherit
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to registry
uses: docker/login-action@v3
with: with:
registry: git.huizinga.dev webhook_url: ${{ secrets.WEBHOOK_URL }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Install kustomize
run: |
curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash
- name: Setup Flux CLI
uses: https://github.com/fluxcd/flux2/action@main
with:
version: v2.5.0
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.OCI_REPO }}
tags: |
type=edge
type=ref,event=branch
type=semver,pattern=v{{version}}
type=semver,pattern=v{{major}}.{{minor}}
type=semver,pattern=v{{major}}
- name: Build and export to docker
id: build
uses: docker/build-push-action@v6
with:
context: .
load: true
labels: ${{ steps.meta.outputs.labels }}
- name: Generate CRDs
run: |
docker run --rm ${{ steps.build.outputs.imageid }} crdgen > ./manifests/crds.yaml
- name: Push container
uses: docker/build-push-action@v6
id: push
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Kustomize manifests
run: |
./kustomize build ./manifests | sed "s/\${DIGEST}/${{ steps.push.outputs.digest }}/" > ./manifests.yaml
- name: Push manifests
run: |
flux push artifact oci://$OCI_REPO/manifests:latest \
--path="./manifests.yaml" \
--source="$(git config --get remote.origin.url)" \
--revision="$(git rev-parse HEAD)" \
$(echo "${{ steps.meta.outputs.labels }}" | sed -e 's/^/-a /')
flux tag artifact oci://$OCI_REPO/manifests:latest \
$(echo "${{ steps.meta.outputs.tags }}" | sed -e 's/^.*:/--tag /')

View File

@@ -2,17 +2,19 @@ fail_fast: true
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0 rev: v6.0.0
hooks: hooks:
- id: trailing-whitespace - id: trailing-whitespace
- id: end-of-file-fixer - id: end-of-file-fixer
- id: check-yaml - id: check-yaml
args:
- --allow-multiple-documents
- id: check-toml - id: check-toml
- id: check-added-large-files - id: check-added-large-files
- id: check-merge-conflict - id: check-merge-conflict
- repo: https://github.com/crate-ci/typos - repo: https://github.com/crate-ci/typos
rev: v1.21.0 rev: typos-dict-v0.13.13
hooks: hooks:
- id: typos - id: typos
args: ["--force-exclude"] args: ["--force-exclude"]
@@ -22,10 +24,12 @@ repos:
- id: fmt - id: fmt
name: fmt name: fmt
description: Format files with cargo fmt. description: Format files with cargo fmt.
entry: cargo fmt entry: cargo +nightly fmt
language: system language: system
types: [rust] types: [rust]
args: ["--", "--check"] args: ["--", "--check"]
# For some reason some formatting is different depending on how you invoke?
pass_filenames: false
- id: clippy - id: clippy
name: clippy name: clippy
@@ -41,6 +45,17 @@ repos:
name: audit name: audit
description: Audit packages description: Audit packages
entry: cargo audit entry: cargo audit
args: ["--deny", "warnings"]
language: system
pass_filenames: false
verbose: true
always_run: true
- id: udeps
name: unused
description: Check for unused crates
entry: cargo +nightly udeps
args: ["--workspace"]
language: system language: system
types: [file] types: [file]
files: (\.rs|Cargo.lock)$ files: (\.rs|Cargo.lock)$
@@ -56,7 +71,7 @@ repos:
files: (\.rs|Cargo.lock)$ files: (\.rs|Cargo.lock)$
pass_filenames: false pass_filenames: false
- repo: https://github.com/pryorda/dockerfilelint-precommit-hooks - repo: https://github.com/hadolint/hadolint
rev: v0.1.0 rev: v2.14.0
hooks: hooks:
- id: dockerfilelint - id: hadolint

2
.rustfmt.toml Normal file
View File

@@ -0,0 +1,2 @@
imports_granularity = "Module"
group_imports = "StdExternalCrate"

1588
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,40 +1,45 @@
[package] [package]
name = "lldap-controller" name = "lldap-controller"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2024"
default-run = "lldap-controller" default-run = "lldap-controller"
[workspace] [workspace]
members = ["queries"] members = ["queries"]
[workspace.dependencies] [workspace.dependencies]
cynic = "3.10.0" cynic = "3.12.0"
insta = { version = "1.42.2", features = ["yaml"] } cynic-codegen = "3.12.0"
insta = { version = "1.45.0", features = ["yaml"] }
[dependencies] [dependencies]
queries = { path = "./queries" } queries = { path = "./queries" }
anyhow = "1.0.97"
lldap_auth = { git = "https://github.com/lldap/lldap" } lldap_auth = { git = "https://github.com/lldap/lldap" }
# Purposefully kept at 0.8.x for compatibility with lldap
rand = { version = "0.8.5" } rand = { version = "0.8.5" }
serde_json = "1.0.140" serde_json = "1.0.145"
cynic = { workspace = true, features = ["http-reqwest"] } cynic = { workspace = true, features = ["http-reqwest"] }
tokio = { version = "1.44.0", features = ["full"] } tokio = { version = "1.48.0", features = ["full"] }
kube = { version = "0.99.0", features = ["derive", "runtime"] } kube = { version = "2.0.1", features = ["derive", "runtime"] }
k8s-openapi = { version = "0.24.0", features = ["v1_31"] } k8s-openapi = { version = "0.26.1", features = ["v1_34"] }
schemars = { version = "0.8.22", features = ["chrono"] } schemars = { version = "1.1.0", features = ["chrono04"] }
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_yaml = "0.9.34" serde_yaml = "0.9.34"
futures = "0.3.31" futures = "0.3.31"
tracing-subscriber = { version = "0.3.19", features = ["json", "env-filter"] } tracing-subscriber = { version = "0.3.22", features = ["json", "env-filter"] }
tracing = "0.1.41" tracing = "0.1.44"
thiserror = "2.0.12" thiserror = "2.0.17"
chrono = "0.4.40" chrono = "0.4.42"
passwords = "3.1.16" passwords = "3.1.16"
async-trait = "0.1.88" reqwest = { version = "0.12.26", default-features = false, features = [
reqwest = { version = "0.12.14", default-features = false, features = [
"json", "json",
"rustls-tls", "rustls-tls",
] } ] }
git-version = "0.3.9"
color-eyre = "0.6.5"
dotenvy = "0.15.7"
leon = "3.0.2"
async-trait = "0.1.89"
[dev-dependencies] [dev-dependencies]
insta = { workspace = true } insta = { workspace = true }

View File

@@ -1,9 +1,25 @@
FROM rust:1.85 AS builder FROM rust:1.92 AS base
WORKDIR /usr/src/lldap-controller ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse
ADD . . RUN cargo install cargo-chef --locked --version 0.1.71 && \
RUN cargo install --path . cargo install cargo-auditable --locked --version 0.6.6
WORKDIR /app
FROM debian:bookworm-slim FROM base AS planner
COPY --from=builder /usr/local/cargo/bin/lldap-controller /usr/local/bin/lldap-controller COPY . .
COPY --from=builder /usr/local/cargo/bin/crdgen /usr/local/bin/crdgen RUN cargo chef prepare --recipe-path recipe.json
CMD ["lldap-controller"]
FROM base AS builder
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
ARG RELEASE_VERSION
ENV RELEASE_VERSION=${RELEASE_VERSION}
RUN cargo auditable build --release && /app/target/release/crdgen > /crds.yaml
FROM scratch AS manifests
COPY --from=builder /crds.yaml /
FROM gcr.io/distroless/cc-debian12:nonroot AS runtime
COPY --from=builder /app/target/release/lldap-controller /lldap-controller
CMD ["/lldap-controller"]

23
docker-bake.hcl Normal file
View File

@@ -0,0 +1,23 @@
variable "TAG_BASE" {}
variable "RELEASE_VERSION" {}
group "default" {
targets = ["lldap-controller", "manifests"]
}
target "docker-metadata-action" {}
target "lldap-controller" {
inherits = ["docker-metadata-action"]
context = "./"
dockerfile = "Dockerfile"
tags = [for tag in target.docker-metadata-action.tags : "${TAG_BASE}:${tag}"]
target = "runtime"
}
target "manifests" {
context = "./"
dockerfile = "Dockerfile"
target = "manifests"
output = [{ type = "cacheonly" }, "manifests"]
}

View File

@@ -9,6 +9,12 @@ rules:
- serviceusers - serviceusers
- serviceusers/status - serviceusers/status
- serviceusers/finalizers - serviceusers/finalizers
- groups
- grours/status
- grours/finalizers
- userattributes
- userattributes/status
- userattributes/finalizers
verbs: verbs:
- "*" - "*"
- apiGroups: - apiGroups:

View File

@@ -31,6 +31,10 @@ spec:
requests: requests:
cpu: 50m cpu: 50m
memory: 100Mi memory: 100Mi
volumeMounts:
- name: credentials
readOnly: true
mountPath: "/secrets/credentials"
env: env:
- name: RUST_LOG - name: RUST_LOG
value: info,lldap_controller=debug value: info,lldap_controller=debug
@@ -38,8 +42,11 @@ spec:
value: "http://lldap:17170" value: "http://lldap:17170"
- name: LLDAP_USERNAME - name: LLDAP_USERNAME
value: admin value: admin
- name: LLDAP_PASSWORD - name: LLDAP_PASSWORD_FILE
valueFrom: value: /secrets/credentials/lldap-ldap-user-pass
secretKeyRef: - name: LLDAP_BIND_DN
name: lldap-credentials value: uid={username},ou=people,dc=huizinga,dc=dev
key: lldap-ldap-user-pass volumes:
- name: credentials
secret:
secretName: lldap-credentials

View File

@@ -1,7 +1,7 @@
[package] [package]
name = "queries" name = "queries"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2024"
[dependencies] [dependencies]
cynic = { workspace = true } cynic = { workspace = true }
@@ -10,4 +10,4 @@ cynic = { workspace = true }
insta = { workspace = true } insta = { workspace = true }
[build-dependencies] [build-dependencies]
cynic-codegen = "3.10.0" cynic-codegen = { workspace = true }

View File

@@ -86,11 +86,97 @@ pub struct GetGroups {
pub groups: Vec<Group>, pub groups: Vec<Group>,
} }
#[derive(cynic::QueryVariables, Debug)]
pub struct CreateGroupVariables<'a> {
pub name: &'a str,
}
#[derive(cynic::QueryFragment, Debug)]
#[cynic(graphql_type = "Mutation", variables = "CreateGroupVariables")]
pub struct CreateGroup {
#[arguments(name: $name)]
pub create_group: Group,
}
#[derive(cynic::QueryVariables, Debug)]
pub struct DeleteGroupVariables {
pub id: i32,
}
#[derive(cynic::QueryFragment, Debug)]
#[cynic(graphql_type = "Mutation", variables = "DeleteGroupVariables")]
pub struct DeleteGroup {
#[arguments(groupId: $id)]
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)] #[cfg(test)]
mod tests { mod tests {
use cynic::{MutationBuilder, QueryBuilder};
use super::*; use super::*;
use cynic::MutationBuilder;
use cynic::QueryBuilder;
#[test] #[test]
fn delete_user_gql_output() { fn delete_user_gql_output() {
@@ -139,4 +225,45 @@ mod tests {
insta::assert_snapshot!(operation.query); insta::assert_snapshot!(operation.query);
} }
#[test]
fn create_group_gql_output() {
let operation = CreateGroup::build(CreateGroupVariables { name: "group" });
insta::assert_snapshot!(operation.query);
}
#[test]
fn delete_group_gql_output() {
let operation = DeleteGroup::build(DeleteGroupVariables { id: 0 });
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,10 @@
---
source: queries/src/lib.rs
expression: operation.query
---
mutation CreateGroup($name: String!) {
createGroup(name: $name) {
id
displayName
}
}

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 DeleteGroup($id: Int!) {
deleteGroup(groupId: $id) {
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,4 +1,4 @@
[toolchain] [toolchain]
channel = "1.85" channel = "1.92"
profile = "default" profile = "default"
components = ["rust-analyzer"] components = ["rust-analyzer"]

View File

@@ -1,8 +1,11 @@
use kube::CustomResourceExt; use kube::CustomResourceExt;
fn main() { fn main() {
print!( let resources = [
"{}", serde_yaml::to_string(&lldap_controller::resources::ServiceUser::crd()).unwrap(),
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::UserAttribute::crd()).unwrap(),
]
.join("---\n");
print!("{resources}")
} }

View File

@@ -1,21 +1,25 @@
use async_trait::async_trait;
use k8s_openapi::api::core::v1::Secret; use k8s_openapi::api::core::v1::Secret;
use kube::{ use kube::runtime::events::{Event, EventType, Recorder, Reporter};
runtime::events::{Event, EventType, Recorder, Reporter}, use kube::{Resource, ResourceExt};
Resource, ResourceExt,
};
use crate::lldap::LldapConfig; use crate::lldap::LldapConfig;
#[derive(Clone)]
pub struct Context { pub struct Context {
pub client: kube::Client, pub client: kube::Client,
pub lldap_config: LldapConfig, pub lldap_config: LldapConfig,
pub controller_name: String, pub controller_name: String,
pub recorder: Recorder, pub recorder: Recorder,
pub bind_dn_template: String,
} }
impl Context { impl Context {
pub fn new(controller_name: &str, client: kube::Client, lldap_config: LldapConfig) -> Self { pub fn new(
controller_name: &str,
client: kube::Client,
lldap_config: LldapConfig,
bind_dn_template: impl Into<String>,
) -> Self {
let reporter: Reporter = controller_name.into(); let reporter: Reporter = controller_name.into();
let recorder = Recorder::new(client.clone(), reporter); let recorder = Recorder::new(client.clone(), reporter);
@@ -24,11 +28,12 @@ impl Context {
lldap_config, lldap_config,
controller_name: controller_name.into(), controller_name: controller_name.into(),
recorder, recorder,
bind_dn_template: bind_dn_template.into(),
} }
} }
} }
#[async_trait] #[allow(async_fn_in_trait)]
pub trait ControllerEvents { pub trait ControllerEvents {
type Error; type Error;
@@ -36,20 +41,35 @@ pub trait ControllerEvents {
where where
T: Resource<DynamicType = ()> + Sync; T: Resource<DynamicType = ()> + Sync;
async fn user_created<T>(&self, obj: &T, username: &str) -> Result<(), Self::Error> async fn user_created<T>(&self, obj: &T) -> Result<(), Self::Error>
where where
T: Resource<DynamicType = ()> + Sync; T: Resource<DynamicType = ()> + Sync;
async fn user_deleted<T>(&self, obj: &T, username: &str) -> Result<(), Self::Error> async fn group_created<T>(&self, obj: &T) -> Result<(), Self::Error>
where where
T: Resource<DynamicType = ()> + Sync; T: Resource<DynamicType = ()> + Sync;
async fn user_not_found<T>(&self, obj: &T, username: &str) -> Result<(), Self::Error> async fn user_deleted<T>(&self, obj: &T) -> Result<(), Self::Error>
where
T: Resource<DynamicType = ()> + Sync;
async fn group_deleted<T>(&self, obj: &T) -> 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 where
T: Resource<DynamicType = ()> + Sync; T: Resource<DynamicType = ()> + Sync;
} }
#[async_trait]
impl ControllerEvents for Recorder { impl ControllerEvents for Recorder {
type Error = kube::Error; type Error = kube::Error;
@@ -70,16 +90,16 @@ impl ControllerEvents for Recorder {
.await .await
} }
async fn user_created<T>(&self, obj: &T, username: &str) -> Result<(), Self::Error> async fn user_created<T>(&self, obj: &T) -> Result<(), Self::Error>
where where
T: Resource<DynamicType = ()> + Sync, T: Resource<DynamicType = ()> + Sync,
{ {
self.publish( self.publish(
&Event { &Event {
type_: EventType::Normal, type_: EventType::Normal,
reason: "UserCreated".into(), reason: "Created".into(),
note: Some(format!("Created user '{username}'")), note: Some("Created user".into()),
action: "UserCreated".into(), action: "Created".into(),
secondary: None, secondary: None,
}, },
&obj.object_ref(&()), &obj.object_ref(&()),
@@ -87,16 +107,16 @@ impl ControllerEvents for Recorder {
.await .await
} }
async fn user_deleted<T>(&self, obj: &T, username: &str) -> Result<(), Self::Error> async fn group_created<T>(&self, obj: &T) -> Result<(), Self::Error>
where where
T: Resource<DynamicType = ()> + Sync, T: Resource<DynamicType = ()> + Sync,
{ {
self.publish( self.publish(
&Event { &Event {
type_: EventType::Normal, type_: EventType::Normal,
reason: "UserDeleted".into(), reason: "Created".into(),
note: Some(format!("Deleted user '{username}'")), note: Some("Created group".into()),
action: "UserDeleted".into(), action: "Created".into(),
secondary: None, secondary: None,
}, },
&obj.object_ref(&()), &obj.object_ref(&()),
@@ -104,16 +124,86 @@ impl ControllerEvents for Recorder {
.await .await
} }
async fn user_not_found<T>(&self, obj: &T, username: &str) -> Result<(), Self::Error> async fn user_deleted<T>(&self, obj: &T) -> Result<(), Self::Error>
where
T: Resource<DynamicType = ()> + Sync,
{
self.publish(
&Event {
type_: EventType::Normal,
reason: "Deleted".into(),
note: Some("Deleted user".into()),
action: "Deleted".into(),
secondary: None,
},
&obj.object_ref(&()),
)
.await
}
async fn group_deleted<T>(&self, obj: &T) -> Result<(), Self::Error>
where
T: Resource<DynamicType = ()> + Sync,
{
self.publish(
&Event {
type_: EventType::Normal,
reason: "Deleted".into(),
note: Some("Deleted group".into()),
action: "Deleted".into(),
secondary: None,
},
&obj.object_ref(&()),
)
.await
}
async fn user_attribute_created<T>(&self, obj: &T) -> Result<(), Self::Error>
where where
T: Resource<DynamicType = ()> + Sync, T: Resource<DynamicType = ()> + Sync,
{ {
self.publish( self.publish(
&Event { &Event {
type_: EventType::Warning, type_: EventType::Warning,
reason: "UserNotFound".into(), reason: "Created".into(),
note: Some(format!("User '{username}' not found")), note: Some("Created user attribute".into()),
action: "UserNotFound".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, secondary: None,
}, },
&obj.object_ref(&()), &obj.object_ref(&()),

View File

@@ -1,3 +1,6 @@
pub mod context; pub mod context;
pub mod lldap; pub mod lldap;
pub mod resources; pub mod resources;
mod version;
pub use version::VERSION;

View File

@@ -1,19 +1,22 @@
use anyhow::Context;
use lldap_auth::opaque::AuthenticationError;
use lldap_auth::registration::ServerRegistrationStartResponse;
use lldap_auth::{opaque, registration};
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
use std::time::Duration; use std::time::Duration;
use tracing::{debug, trace};
use cynic::http::{CynicReqwestError, ReqwestExt}; use cynic::http::{CynicReqwestError, ReqwestExt};
use cynic::{GraphQlError, GraphQlResponse, MutationBuilder, QueryBuilder}; use cynic::{GraphQlError, GraphQlResponse, MutationBuilder, QueryBuilder};
use lldap_auth::login::{ClientSimpleLoginRequest, ServerLoginResponse}; use lldap_auth::login::{ClientSimpleLoginRequest, ServerLoginResponse};
use lldap_auth::opaque::AuthenticationError;
use lldap_auth::registration::ServerRegistrationStartResponse;
use lldap_auth::{opaque, registration};
use queries::{ use queries::{
AddUserToGroup, AddUserToGroupVariables, CreateUser, CreateUserVariables, DeleteUser, AddUserToGroup, AddUserToGroupVariables, AttributeSchema, CreateGroup, CreateGroupVariables,
DeleteUserVariables, GetGroups, GetUser, GetUserVariables, Group, RemoveUserFromGroup, CreateUser, CreateUserAttribute, CreateUserAttributeVariables, CreateUserVariables,
RemoveUserFromGroupVariables, User, 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)] #[derive(thiserror::Error, Debug)]
pub enum Error { pub enum Error {
@@ -25,22 +28,27 @@ pub enum Error {
Authentication(#[from] AuthenticationError), Authentication(#[from] AuthenticationError),
#[error("GraphQL error: {0}")] #[error("GraphQL error: {0}")]
GraphQl(#[from] GraphQlError), GraphQl(#[from] GraphQlError),
#[error("Missing environment variable: {0}")]
MissingEnvironmentVariable(&'static str),
#[error("Could not read password file: {0}")]
CouldNotReadPasswordFile(#[from] std::io::Error),
} }
pub type Result<T, E = Error> = std::result::Result<T, E>; pub type Result<T, E = Error> = std::result::Result<T, E>;
fn check_graphql_errors<T>(response: GraphQlResponse<T>) -> Result<T> { fn check_graphql_errors<T>(response: GraphQlResponse<T>) -> Result<T> {
if let Some(errors) = &response.errors { if let Some(errors) = &response.errors
if !errors.is_empty() { && !errors.is_empty()
{
Err(errors.first().expect("Should not be empty").clone())?; Err(errors.first().expect("Should not be empty").clone())?;
} }
}
Ok(response Ok(response
.data .data
.expect("Data should be valid if there are no error")) .expect("Data should be valid if there are no error"))
} }
#[derive(Clone)]
pub struct LldapConfig { pub struct LldapConfig {
username: String, username: String,
password: String, password: String,
@@ -48,14 +56,26 @@ pub struct LldapConfig {
} }
impl LldapConfig { impl LldapConfig {
pub fn try_from_env() -> anyhow::Result<Self> { pub fn try_from_env() -> Result<Self> {
let password = std::env::var("LLDAP_PASSWORD_FILE").map_or_else(
|_| {
std::env::var("LLDAP_PASSWORD").map_err(|_| {
Error::MissingEnvironmentVariable("LLDAP_PASSWORD or LLDAP_PASSWORD_FILE")
})
},
|path| {
std::fs::read_to_string(path)
.map(|v| v.trim().into())
.map_err(|err| err.into())
},
)?;
Ok(Self { Ok(Self {
username: std::env::var("LLDAP_USERNAME") username: std::env::var("LLDAP_USERNAME")
.context("Variable 'LLDAP_USERNAME' is not set or invalid")?, .map_err(|_| Error::MissingEnvironmentVariable("LLDAP_USERNAME"))?,
password: std::env::var("LLDAP_PASSWORD") password,
.context("Variable 'LLDAP_PASSWORD' is not set or invalid")?,
url: std::env::var("LLDAP_URL") url: std::env::var("LLDAP_URL")
.context("Variable 'LLDAP_URL' is not set or invalid")?, .map_err(|_| Error::MissingEnvironmentVariable("LLDAP_URL"))?,
}) })
} }
@@ -150,6 +170,32 @@ impl LldapClient {
Ok(check_graphql_errors(response)?.groups) Ok(check_graphql_errors(response)?.groups)
} }
pub async fn create_group(&self, name: &str) -> Result<Group> {
let operation = CreateGroup::build(CreateGroupVariables { name });
let response = self
.client
.post(format!("{}/api/graphql", self.url))
.run_graphql(operation)
.await?;
Ok(check_graphql_errors(response)?.create_group)
}
pub async fn delete_group(&self, id: i32) -> Result<()> {
let operation = DeleteGroup::build(DeleteGroupVariables { id });
let response = self
.client
.post(format!("{}/api/graphql", self.url))
.run_graphql(operation)
.await?;
check_graphql_errors(response)?;
Ok(())
}
pub async fn add_user_to_group(&self, username: &str, group: i32) -> Result<()> { pub async fn add_user_to_group(&self, username: &str, group: i32) -> Result<()> {
let operation = AddUserToGroup::build(AddUserToGroupVariables { username, group }); let operation = AddUserToGroup::build(AddUserToGroupVariables { username, group });
@@ -257,4 +303,60 @@ impl LldapClient {
Ok(()) 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

@@ -1,34 +1,61 @@
use std::{sync::Arc, time::Duration}; use std::sync::Arc;
use std::time::Duration;
use color_eyre::eyre::Context as _;
use dotenvy::dotenv;
use futures::StreamExt; use futures::StreamExt;
use k8s_openapi::api::core::v1::Secret; use k8s_openapi::api::core::v1::Secret;
use kube::{ use kube::runtime::controller::{self, Action};
runtime::{controller::Action, Controller}, use kube::runtime::reflector::ObjectRef;
Api, Client as KubeClient, use kube::runtime::{Controller, watcher};
}; use kube::{Api, Client as KubeClient, Resource};
use lldap_controller::{ use lldap_controller::VERSION;
context::Context, use lldap_controller::context::Context;
lldap::LldapConfig, use lldap_controller::lldap::LldapConfig;
resources::{self, reconcile, ServiceUser}, use lldap_controller::resources::{
self, Error, Group, ServiceUser, UserAttribute, reconcile, reconcile_namespaced,
}; };
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Registry}; use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::{EnvFilter, Registry};
fn error_policy(_obj: Arc<ServiceUser>, err: &resources::Error, _ctx: Arc<Context>) -> Action { fn error_policy<T>(_obj: Arc<T>, err: &resources::Error, _ctx: Arc<Context>) -> Action {
warn!("error: {}", err); warn!("error: {}", err);
Action::requeue(Duration::from_secs(5)) Action::requeue(Duration::from_secs(5))
} }
async fn log_status<T>(
res: Result<(ObjectRef<T>, Action), controller::Error<Error, watcher::Error>>,
) where
T: Resource,
{
match res {
Ok(obj) => debug!("reconciled {:?}", obj.0.name),
Err(err) => warn!("reconcile failed: {}", err),
}
}
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> color_eyre::Result<()> {
let logger = tracing_subscriber::fmt::layer().json(); color_eyre::install()?;
dotenv().ok();
let env_filter = EnvFilter::try_from_default_env() let env_filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new("info")) .or_else(|_| EnvFilter::try_new("info"))
.expect("Fallback should be valid"); .expect("Fallback should be valid");
if std::env::var("CARGO").is_ok() {
let logger = tracing_subscriber::fmt::layer().compact();
Registry::default().with(logger).with(env_filter).init(); Registry::default().with(logger).with(env_filter).init();
} else {
let logger = tracing_subscriber::fmt::layer().json();
Registry::default().with(logger).with(env_filter).init();
}
info!("Starting controller"); info!(version = VERSION, "Starting");
let bind_dn_template = std::env::var("LLDAP_BIND_DN").wrap_err("LLDAP_BIND_DN is not set")?;
let client = KubeClient::try_default().await?; let client = KubeClient::try_default().await?;
@@ -36,22 +63,35 @@ async fn main() -> anyhow::Result<()> {
"lldap.huizinga.dev", "lldap.huizinga.dev",
client.clone(), client.clone(),
LldapConfig::try_from_env()?, LldapConfig::try_from_env()?,
bind_dn_template,
); );
let service_users = Api::<ServiceUser>::all(client.clone());
let secrets = Api::<Secret>::all(client.clone()); let secrets = Api::<Secret>::all(client.clone());
Controller::new(service_users.clone(), Default::default()) let service_users = Api::<ServiceUser>::all(client.clone());
let service_user_controller = Controller::new(service_users, Default::default())
.owns(secrets, Default::default()) .owns(secrets, Default::default())
.shutdown_on_signal()
.run(reconcile_namespaced, error_policy, Arc::new(data.clone()))
.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() .shutdown_on_signal()
.run(reconcile, error_policy, Arc::new(data)) .run(reconcile, error_policy, Arc::new(data))
.for_each(|res| async move { .for_each(log_status);
match res {
Ok(obj) => debug!("reconciled {:?}", obj.0.name), tokio::join!(
Err(err) => warn!("reconcile failed: {}", err), service_user_controller,
} group_controller,
}) user_attribute_controller
.await; );
Ok(()) Ok(())
} }

78
src/resources/group.rs Normal file
View File

@@ -0,0 +1,78 @@
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use kube::CustomResource;
use kube::runtime::controller::Action;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use tracing::{debug, trace};
use super::{Error, Reconcile, Result};
use crate::context::{Context, ControllerEvents};
#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)]
#[kube(kind = "Group", group = "lldap.huizinga.dev", version = "v1")]
#[kube(
shortname = "lg",
doc = "Custom resource for managing Groups inside of LLDAP"
)]
#[serde(rename_all = "camelCase")]
pub struct GroupSpec {}
#[async_trait]
impl Reconcile for Group {
async fn reconcile(self: Arc<Self>, ctx: Arc<Context>) -> Result<Action> {
let name = self
.metadata
.name
.clone()
.ok_or(Error::MissingObjectKey(".metadata.name"))?;
debug!(name, "Apply");
let lldap_client = ctx.lldap_config.build_client().await?;
trace!(name, "Get existing groups");
let groups = lldap_client.get_groups().await?;
if !groups.iter().any(|group| group.display_name == name) {
trace!("Group does not exist yet");
lldap_client.create_group(&name).await?;
ctx.recorder.group_created(self.as_ref()).await?;
} else {
trace!("Group already exists");
}
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"))?;
debug!(name, "Cleanup");
let lldap_client = ctx.lldap_config.build_client().await?;
trace!(name, "Get existing groups");
let groups = lldap_client.get_groups().await?;
if let Some(group) = groups.iter().find(|group| group.display_name == name) {
trace!(name, "Deleting group");
lldap_client.delete_group(group.id).await?;
ctx.recorder.group_deleted(self.as_ref()).await?;
} else {
trace!(name, "Group does not exist")
}
Ok(Action::await_change())
}
}

103
src/resources/mod.rs Normal file
View File

@@ -0,0 +1,103 @@
mod group;
mod service_user;
mod user_attribute;
use core::fmt;
use std::sync::Arc;
use async_trait::async_trait;
use k8s_openapi::{ClusterResourceScope, NamespaceResourceScope};
use kube::runtime::controller::Action;
use kube::runtime::finalizer;
use kube::{Api, Resource, ResourceExt};
use serde::Serialize;
use serde::de::DeserializeOwned;
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;
#[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),
#[error("UserAttributeDesync: {0:?}")]
UserAttributeDesync(Vec<String>),
}
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]
pub 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_namespaced<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 should be namespaced");
let api = Api::<T>::namespaced(ctx.client.clone(), &namespace);
Ok(finalizer(&api, &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?)
}
#[instrument(skip(obj, ctx))]
pub async fn reconcile<T>(obj: Arc<T>, ctx: Arc<Context>) -> Result<Action>
where
T: Resource<Scope = ClusterResourceScope>
+ ResourceExt
+ Clone
+ Serialize
+ DeserializeOwned
+ fmt::Debug
+ Reconcile,
<T as Resource>::DynamicType: Default,
{
debug!(name = obj.name_any(), "Reconcile");
let api = Api::<T>::all(ctx.client.clone());
Ok(finalizer(&api, &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?)
}

View File

@@ -1,4 +1,3 @@
use core::fmt;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::str::from_utf8; use std::str::from_utf8;
use std::sync::Arc; use std::sync::Arc;
@@ -8,43 +7,20 @@ use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use k8s_openapi::api::core::v1::Secret; use k8s_openapi::api::core::v1::Secret;
use k8s_openapi::apimachinery::pkg::apis::meta::v1::OwnerReference; use k8s_openapi::apimachinery::pkg::apis::meta::v1::OwnerReference;
use k8s_openapi::NamespaceResourceScope;
use kube::api::{ObjectMeta, Patch, PatchParams, PostParams}; use kube::api::{ObjectMeta, Patch, PatchParams, PostParams};
use kube::runtime::controller::Action; use kube::runtime::controller::Action;
use kube::runtime::finalizer; use kube::{Api, CustomResource, Resource};
use kube::{Api, CustomResource, Resource, ResourceExt}; use leon::{Template, vals};
use passwords::PasswordGenerator; use passwords::PasswordGenerator;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use tracing::{debug, instrument, trace, warn}; use tracing::{debug, trace, warn};
use super::{Error, Reconcile, Result};
use crate::context::{Context, ControllerEvents}; use crate::context::{Context, ControllerEvents};
use crate::lldap; use crate::lldap;
#[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))
}
}
pub type Result<T, E = Error> = std::result::Result<T, E>;
#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)] #[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)]
#[kube( #[kube(
kind = "ServiceUser", kind = "ServiceUser",
@@ -97,41 +73,6 @@ fn new_secret(username: &str, oref: OwnerReference) -> Secret {
} }
} }
#[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?,
)
}
fn format_username(name: &str, namespace: &str) -> String { fn format_username(name: &str, namespace: &str) -> String {
format!("{name}.{namespace}") format!("{name}.{namespace}")
} }
@@ -175,6 +116,31 @@ impl Reconcile for ServiceUser {
debug!(name, secret_name, "Generating new secret"); debug!(name, secret_name, "Generating new secret");
new_secret(&username, oref) new_secret(&username, oref)
})
.and_modify(|secret| {
let bind_dn_template = match Template::parse(&ctx.bind_dn_template) {
Ok(template) => template,
Err(err) => {
warn!("Invalid bind_dn template: {err}");
return;
}
};
let bind_dn = match bind_dn_template.render(&&vals(|key| match key {
"username" => Some(username.clone().into()),
_ => None,
})) {
Ok(bind_dn) => bind_dn,
Err(err) => {
warn!("Failed to render bind_dn template: {err}");
return;
}
};
secret
.string_data
.get_or_insert_default()
.insert("bind_dn".into(), bind_dn);
}); });
trace!(name, "Committing secret"); trace!(name, "Committing secret");
@@ -206,7 +172,7 @@ impl Reconcile for ServiceUser {
debug!(name, username, "Creating new user"); debug!(name, username, "Creating new user");
let user = lldap_client.create_user(&username).await?; let user = lldap_client.create_user(&username).await?;
ctx.recorder.user_created(self.as_ref(), &username).await?; ctx.recorder.user_created(self.as_ref()).await?;
Ok(user) Ok(user)
} }
@@ -270,14 +236,11 @@ impl Reconcile for ServiceUser {
Err(lldap::Error::GraphQl(err)) Err(lldap::Error::GraphQl(err))
if err.message == format!("Entity not found: `No such user: '{username}'`") => 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"); warn!(name, username, "User not found");
Ok(()) Ok(())
} }
Ok(_) => { Ok(_) => {
ctx.recorder.user_deleted(self.as_ref(), &username).await?; ctx.recorder.user_deleted(self.as_ref()).await?;
Ok(()) Ok(())
} }
Err(err) => Err(err), Err(err) => Err(err),
@@ -289,9 +252,10 @@ impl Reconcile for ServiceUser {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*;
use kube::CustomResourceExt; use kube::CustomResourceExt;
use super::*;
#[test] #[test]
fn service_user_crd_output() { fn service_user_crd_output() {
insta::assert_yaml_snapshot!(ServiceUser::crd()); insta::assert_yaml_snapshot!(ServiceUser::crd());

View File

@@ -0,0 +1,191 @@
use std::time::Duration;
use async_trait::async_trait;
use kube::api::{Patch, PatchParams};
use kube::runtime::controller::Action;
use kube::{Api, CustomResource, KubeSchema};
use queries::AttributeType;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{debug, trace, warn};
use super::Reconcile;
use crate::context::ControllerEvents;
use crate::lldap;
use crate::resources::Error;
#[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, KubeSchema)]
#[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(
validation = Rule::new("self.spec == oldSelf.spec").message("User attributes are immutable"),
validation = Rule::new("!self.spec.userEditable || self.spec.userVisible && self.spec.userEditable").message("Editable attribute must also be visible")
)]
#[serde(rename_all = "camelCase")]
pub struct UserAttributeSpec {
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,
}
#[async_trait]
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
}

11
src/version.rs Normal file
View File

@@ -0,0 +1,11 @@
pub const VERSION: &str = get_version();
const fn get_version() -> &'static str {
if let Some(version) = std::option_env!("RELEASE_VERSION")
&& !version.is_empty()
{
version
} else {
git_version::git_version!(fallback = "unknown")
}
}

5
yaml/group.yaml Normal file
View File

@@ -0,0 +1,5 @@
apiVersion: lldap.huizinga.dev/v1
kind: Group
metadata:
name: test-group
spec: {}

View File

@@ -1,6 +1,5 @@
apiVersion: lldap.huizinga.dev/v1 apiVersion: lldap.huizinga.dev/v1
kind: ServiceUser kind: ServiceUser
metadata: metadata:
name: authelia name: test-user
spec: spec: {}
passwordManager: false

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