Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot] 6f31147bb7 build(deps): bump docker/login-action from 3 to 4
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-04 20:23:13 +00:00
57 changed files with 2045 additions and 2114 deletions
+1 -1
View File
@@ -59,7 +59,7 @@ RUN set -x \
&& for file in $(cat /lldap/app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \ && for file in $(cat /lldap/app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
&& chmod a+r -R . && chmod a+r -R .
FROM alpine:3.22 FROM alpine:3.19
WORKDIR /app WORKDIR /app
ENV UID=1000 ENV UID=1000
ENV GID=1000 ENV GID=1000
+1 -1
View File
@@ -1,5 +1,5 @@
# Keep tracking base image # Keep tracking base image
FROM rust:1.91-slim-bookworm FROM rust:1.89-slim-bookworm
# Set needed env path # Set needed env path
ENV PATH="/opt/armv7l-linux-musleabihf-cross/:/opt/armv7l-linux-musleabihf-cross/bin/:/opt/aarch64-linux-musl-cross/:/opt/aarch64-linux-musl-cross/bin/:/opt/x86_64-linux-musl-cross/:/opt/x86_64-linux-musl-cross/bin/:$PATH" ENV PATH="/opt/armv7l-linux-musleabihf-cross/:/opt/armv7l-linux-musleabihf-cross/bin/:/opt/aarch64-linux-musl-cross/:/opt/aarch64-linux-musl-cross/bin/:/opt/x86_64-linux-musl-cross/:/opt/x86_64-linux-musl-cross/bin/:$PATH"
+5 -5
View File
@@ -24,7 +24,7 @@ on:
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
MSRV: "1.91.0" MSRV: "1.89.0"
### CI Docs ### CI Docs
@@ -106,7 +106,7 @@ jobs:
restore-keys: | restore-keys: |
lldap-ui- lldap-ui-
- name: Install wasm-pack with cargo - name: Install wasm-pack with cargo
run: cargo install --locked wasm-pack || true run: cargo install wasm-pack || true
env: env:
RUSTFLAGS: "" RUSTFLAGS: ""
- name: Build frontend - name: Build frontend
@@ -520,7 +520,7 @@ jobs:
path: web path: web
- name: Setup QEMU - name: Setup QEMU
uses: docker/setup-qemu-action@v4 uses: docker/setup-qemu-action@v3
- name: Setup buildx - name: Setup buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
with: with:
@@ -623,14 +623,14 @@ jobs:
# Docker login to nitnelave/lldap and lldap/lldap # Docker login to nitnelave/lldap and lldap/lldap
- name: Login to Nitnelave/LLDAP Docker Hub - name: Login to Nitnelave/LLDAP Docker Hub
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
registry: ghcr.io registry: ghcr.io
username: nitnelave username: nitnelave
+1 -1
View File
@@ -8,7 +8,7 @@ on:
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
MSRV: "1.91.0" MSRV: "1.89.0"
jobs: jobs:
pre_job: pre_job:
-49
View File
@@ -5,55 +5,6 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.6.3] 2026-05-01
Small release, focused on LDAP compatibility, TLS maintenance, dependency upgrades and documentation/examples.
### Added
- LDAP schema definitions for `memberOf`, `modifyTimestamp` and `pwdChangedTime`
- Support for configuring the healthcheck listen addresses
- Usernames are now included in password recovery emails
### Changed
- JWT `exp` and `iat` claims are now serialized as NumericDate values to comply with RFC7519
- Migrated to `rustls` 0.23 and centralized TLS handling
- The login form no longer enforces a password length limit
### Fixed
- `pwdChangedTime` is now emitted as LDAP GeneralizedTime instead of RFC3339
- LDAP base-scope searches for non-existent entries now return `NoSuchObject`
- `cn` equality filters are now case insensitive
- The server now shuts down the database connection pool gracefully
- The bootstrap script now handles empty globs correctly
### Security
- Updated the LDAP dependency stack, including `ldap3_proto`, in response to
security advisory
[`GHSA-qcxq-75wr-5cm8`](https://github.com/kanidm/ldap3/security/advisories/GHSA-qcxq-75wr-5cm8),
where a specially crafted LDAP query could make the server crash
### Cleanups
- Split GraphQL queries and mutations into smaller modules
- Refactored configuration and user update logic
- Upgraded the Rust toolchain and shared dependencies
### New services
- Apache WebDAV
- Continuwuity
- Gerrit
- Gogs
- Open WebUI
- OpenCloud
- Pocket ID
- Semaphore
- TrueNAS
## [0.6.2] 2025-07-21 ## [0.6.2] 2025-07-21
Small release, focused on LDAP improvements and ongoing maintenance. Small release, focused on LDAP improvements and ongoing maintenance.
Generated
+1417 -1110
View File
File diff suppressed because it is too large Load Diff
+1 -114
View File
@@ -16,7 +16,7 @@ edition = "2024"
homepage = "https://github.com/lldap/lldap" homepage = "https://github.com/lldap/lldap"
license = "GPL-3.0-only" license = "GPL-3.0-only"
repository = "https://github.com/lldap/lldap" repository = "https://github.com/lldap/lldap"
rust-version = "1.91.0" rust-version = "1.89.0"
[profile.release] [profile.release]
lto = true lto = true
@@ -24,122 +24,9 @@ lto = true
[profile.release.package.lldap_app] [profile.release.package.lldap_app]
opt-level = 's' opt-level = 's'
[workspace.dependencies.anyhow]
version = "1"
[workspace.dependencies.async-trait]
version = "0.1"
[workspace.dependencies.base64]
version = "0.22"
[workspace.dependencies.bincode]
version = "1"
[workspace.dependencies.chrono]
version = "0.4"
features = ["serde", "wasmbind"]
[workspace.dependencies.clap]
version = "4"
features = ["std", "color", "suggestions", "derive", "env"]
[workspace.dependencies.derive_more]
version = "2"
default-features = false
features = ["debug", "display", "from", "from_str"]
[workspace.dependencies.graphql_client]
version = "0.11"
default-features = false
features = ["graphql_query_derive"]
[workspace.dependencies.image]
version = "0.25"
default-features = false
features = ["jpeg"]
[workspace.dependencies.itertools]
version = "0.14"
[workspace.dependencies.juniper]
version = "0.17"
default-features = false
features = ["chrono", "schema-language", "url", "uuid"]
[workspace.dependencies.ldap3]
version = "0"
default-features = false
features = ["sync", "tls-rustls-ring"]
[workspace.dependencies.ldap3_proto]
version = "0.7"
[workspace.dependencies.jwt]
version = "0.16"
[workspace.dependencies.log]
version = "0"
[workspace.dependencies.mockall]
version = "0.14"
[workspace.dependencies.opaque-ke]
version = "0.7"
[workspace.dependencies.orion]
version = "0.17"
[workspace.dependencies.pretty_assertions]
version = "1"
[workspace.dependencies.rand]
version = "0.8"
features = ["small_rng", "getrandom"]
[workspace.dependencies.reqwest]
version = "0.11"
default-features = false
[workspace.dependencies.sea-orm] [workspace.dependencies.sea-orm]
version = "1.1.8" version = "1.1.8"
default-features = false default-features = false
features = ["macros", "with-chrono", "with-uuid", "sqlx-all", "runtime-actix-rustls"]
[workspace.dependencies.secstr]
version = "0"
features = ["serde"]
[workspace.dependencies.serde] [workspace.dependencies.serde]
version = "1" version = "1"
[workspace.dependencies.serde_bytes]
version = "0.11"
[workspace.dependencies.serde_json]
version = "1"
[workspace.dependencies.strum]
version = "0.28"
features = ["derive"]
[workspace.dependencies.thiserror]
version = "2"
[workspace.dependencies.tokio]
version = "1"
features = ["full"]
[workspace.dependencies.tracing]
version = "0"
[workspace.dependencies.tracing-subscriber]
version = "0.3"
features = ["env-filter", "tracing-log"]
[workspace.dependencies.urlencoding]
version = "2"
[workspace.dependencies.uuid]
version = "1.18.1"
features = ["js", "serde", "v1", "v3", "v4"]
+35 -19
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "lldap_app" name = "lldap_app"
version = "0.6.3" version = "0.6.2"
description = "Frontend for LLDAP" description = "Frontend for LLDAP"
edition.workspace = true edition.workspace = true
include = ["src/**/*", "queries/**/*", "Cargo.toml", "../schema.graphql"] include = ["src/**/*", "queries/**/*", "Cargo.toml", "../schema.graphql"]
@@ -11,34 +11,27 @@ repository.workspace = true
rust-version.workspace = true rust-version.workspace = true
[dependencies] [dependencies]
anyhow = { workspace = true } anyhow = "1"
chrono = { workspace = true } base64 = "0.13"
derive_more = { workspace = true } gloo-console = "0.2.3"
gloo-console = "0.4" gloo-file = "0.2.3"
gloo-file = "0.4" gloo-net = "*"
gloo-net = "0.7" graphql_client = "0.10"
graphql_client = { workspace = true } http = "0.2"
image = { workspace = true } jwt = "0.13"
jwt = { workspace = true } rand = "0.8"
rand = { workspace = true } serde_json = "1"
serde = { workspace = true }
serde_json = { workspace = true }
strum = { workspace = true }
percent-encoding = "2"
url-escape = "0.1.1" url-escape = "0.1.1"
validator = "0.14" validator = "0.14"
validator_derive = "0.14" validator_derive = "0.14"
wasm-bindgen = "0.2.100" wasm-bindgen = "0.2.100"
wasm-bindgen-futures = "0" wasm-bindgen-futures = "*"
yew = "0.19.3" yew = "0.19.3"
yew-router = "0.16" yew-router = "0.16"
# Needed because of https://github.com/tkaitchuck/aHash/issues/95 # Needed because of https://github.com/tkaitchuck/aHash/issues/95
indexmap = "=1.6.2" indexmap = "=1.6.2"
base64 = { workspace = true }
[dependencies.web-sys] [dependencies.web-sys]
version = "0.3" version = "0.3"
features = [ features = [
@@ -57,6 +50,17 @@ features = [
"console", "console",
] ]
[dependencies.chrono]
version = "*"
features = [
"wasmbind"
]
[dependencies.derive_more]
features = ["debug", "display", "from", "from_str"]
default-features = false
version = "1"
[dependencies.lldap_auth] [dependencies.lldap_auth]
path = "../crates/auth" path = "../crates/auth"
features = [ "opaque_client" ] features = [ "opaque_client" ]
@@ -67,6 +71,18 @@ path = "../crates/frontend-options"
[dependencies.lldap_validation] [dependencies.lldap_validation]
path = "../crates/validation" path = "../crates/validation"
[dependencies.image]
features = ["jpeg"]
default-features = false
version = "0.24"
[dependencies.serde]
workspace = true
[dependencies.strum]
features = ["derive"]
version = "0.25"
[dependencies.yew_form] [dependencies.yew_form]
git = "https://github.com/jfbilodeau/yew_form" git = "https://github.com/jfbilodeau/yew_form"
rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9" rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9"
+4 -20
View File
@@ -183,16 +183,6 @@ impl App {
} }
} }
/// Percent-decode a URL path segment into a user ID string.
/// Returns `None` if the decoded bytes are not valid UTF-8, so the caller
/// can redirect to a safe page rather than silently mangling the ID.
fn decode_user_id(raw: &str) -> Option<String> {
percent_encoding::percent_decode_str(raw)
.decode_utf8()
.ok()
.map(|s| s.into_owned())
}
fn dispatch_route( fn dispatch_route(
switch: &AppRoute, switch: &AppRoute,
link: &Scope<Self>, link: &Scope<Self>,
@@ -258,17 +248,11 @@ impl App {
AppRoute::GroupDetails { group_id } => html! { AppRoute::GroupDetails { group_id } => html! {
<GroupDetails group_id={*group_id} is_admin={is_admin} /> <GroupDetails group_id={*group_id} is_admin={is_admin} />
}, },
AppRoute::UserDetails { user_id } => match Self::decode_user_id(user_id) { AppRoute::UserDetails { user_id } => html! {
Some(decoded_id) => html! { <UserDetails username={user_id.clone()} is_admin={is_admin} />
<UserDetails username={decoded_id} is_admin={is_admin} />
},
None => html! { <Redirect to={AppRoute::Login} /> },
}, },
AppRoute::ChangePassword { user_id } => match Self::decode_user_id(user_id) { AppRoute::ChangePassword { user_id } => html! {
Some(decoded_id) => html! { <ChangePasswordForm username={user_id.clone()} is_admin={is_admin} />
<ChangePasswordForm username={decoded_id} is_admin={is_admin} />
},
None => html! { <Redirect to={AppRoute::Login} /> },
}, },
AppRoute::StartResetPassword => match password_reset_enabled { AppRoute::StartResetPassword => match password_reset_enabled {
Some(true) => html! { <ResetPasswordStep1Form /> }, Some(true) => html! { <ResetPasswordStep1Form /> },
+1 -1
View File
@@ -112,7 +112,7 @@ impl CommonComponent<CreateGroupForm> for CreateGroupForm {
let model = self.form.model(); let model = self.form.model();
let req = create_group::Variables { let req = create_group::Variables {
group: create_group::CreateGroupInput { group: create_group::CreateGroupInput {
display_name: model.groupname, displayName: model.groupname,
attributes, attributes,
}, },
}; };
+3 -3
View File
@@ -143,9 +143,9 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
user: create_user::CreateUserInput { user: create_user::CreateUserInput {
id: model.username, id: model.username,
email: None, email: None,
display_name: None, displayName: None,
first_name: None, firstName: None,
last_name: None, lastName: None,
avatar: None, avatar: None,
attributes, attributes,
}, },
+5 -6
View File
@@ -1,7 +1,6 @@
use std::{fmt::Display, str::FromStr}; use std::{fmt::Display, str::FromStr};
use anyhow::{Error, Ok, Result, bail}; use anyhow::{Error, Ok, Result, bail};
use base64::Engine;
use gloo_file::{ use gloo_file::{
File, File,
callbacks::{FileReader, read_as_bytes}, callbacks::{FileReader, read_as_bytes},
@@ -55,12 +54,12 @@ fn to_base64(file: &JsFile) -> Result<String> {
if !is_valid_jpeg(data.as_slice()) { if !is_valid_jpeg(data.as_slice()) {
bail!("Chosen image is not a valid JPEG"); bail!("Chosen image is not a valid JPEG");
} }
Ok(base64::engine::general_purpose::STANDARD.encode(data)) Ok(base64::encode(data))
} }
JsFile { JsFile {
file: None, file: None,
contents: Some(data), contents: Some(data),
} => Ok(base64::engine::general_purpose::STANDARD.encode(data)), } => Ok(base64::encode(data)),
} }
} }
@@ -99,7 +98,7 @@ impl Component for JpegFileInput {
.props() .props()
.value .value
.as_ref() .as_ref()
.and_then(|x| base64::engine::general_purpose::STANDARD.decode(x).ok()), .and_then(|x| base64::decode(x).ok()),
}), }),
reader: None, reader: None,
} }
@@ -112,7 +111,7 @@ impl Component for JpegFileInput {
.props() .props()
.value .value
.as_ref() .as_ref()
.and_then(|x| base64::engine::general_purpose::STANDARD.decode(x).ok()), .and_then(|x| base64::decode(x).ok()),
}); });
self.reader = None; self.reader = None;
true true
@@ -231,7 +230,7 @@ impl JpegFileInput {
} }
fn is_valid_jpeg(bytes: &[u8]) -> bool { fn is_valid_jpeg(bytes: &[u8]) -> bool {
image::ImageReader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg) image::io::Reader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg)
.decode() .decode()
.is_ok() .is_ok()
} }
+5 -5
View File
@@ -248,13 +248,13 @@ impl GroupDetailsForm {
}; };
let mut group_input = update_group::UpdateGroupInput { let mut group_input = update_group::UpdateGroupInput {
id: self.group.id, id: self.group.id,
display_name: None, displayName: None,
remove_attributes: None, removeAttributes: None,
insert_attributes: None, insertAttributes: None,
}; };
let default_group_input = group_input.clone(); let default_group_input = group_input.clone();
group_input.remove_attributes = remove_attributes; group_input.removeAttributes = remove_attributes;
group_input.insert_attributes = insert_attributes; group_input.insertAttributes = insert_attributes;
// Nothing changed. // Nothing changed.
if group_input == default_group_input { if group_input == default_group_input {
return Ok(false); return Ok(false);
+7 -7
View File
@@ -260,16 +260,16 @@ impl UserDetailsForm {
let mut user_input = update_user::UpdateUserInput { let mut user_input = update_user::UpdateUserInput {
id: self.user.id.clone(), id: self.user.id.clone(),
email: None, email: None,
display_name: None, displayName: None,
first_name: None, firstName: None,
last_name: None, lastName: None,
avatar: None, avatar: None,
remove_attributes: None, removeAttributes: None,
insert_attributes: None, insertAttributes: None,
}; };
let default_user_input = user_input.clone(); let default_user_input = user_input.clone();
user_input.remove_attributes = remove_attributes; user_input.removeAttributes = remove_attributes;
user_input.insert_attributes = insert_attributes; user_input.insertAttributes = insert_attributes;
// Nothing changed. // Nothing changed.
if user_input == default_user_input { if user_input == default_user_input {
return Ok(false); return Ok(false);
+1 -1
View File
@@ -94,7 +94,7 @@ impl HostService {
where where
QueryType: GraphQLQuery + 'static, QueryType: GraphQLQuery + 'static,
{ {
let unwrap_graphql_response = |graphql_client::Response { data, errors, .. }| { let unwrap_graphql_response = |graphql_client::Response { data, errors }| {
data.ok_or_else(|| { data.ok_or_else(|| {
anyhow!( anyhow!(
"Errors: [{}]", "Errors: [{}]",
+1 -2
View File
@@ -1,2 +1 @@
pub type DateTime = chrono::DateTime<chrono::Utc>; pub type DateTimeUtc = chrono::DateTime<chrono::Utc>;
pub type DateTimeUtc = DateTime;
+3 -3
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "lldap_access_control" name = "lldap_access_control"
version = "0.1.1" version = "0.1.0"
description = "Access control wrappers for LLDAP" description = "Access control wrappers for LLDAP"
authors.workspace = true authors.workspace = true
edition.workspace = true edition.workspace = true
@@ -10,8 +10,8 @@ repository.workspace = true
rust-version.workspace = true rust-version.workspace = true
[dependencies] [dependencies]
tracing = { workspace = true } tracing = "*"
async-trait = { workspace = true } async-trait = "0.1"
[dependencies.lldap_auth] [dependencies.lldap_auth]
path = "../auth" path = "../auth"
+20 -8
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "lldap_auth" name = "lldap_auth"
version = "0.6.3" version = "0.6.0"
description = "Authentication protocol for LLDAP" description = "Authentication protocol for LLDAP"
edition.workspace = true edition.workspace = true
authors.workspace = true authors.workspace = true
@@ -18,23 +18,35 @@ sea_orm = ["dep:sea-orm"]
test = [] test = []
[dependencies] [dependencies]
chrono = { workspace = true }
derive_more = { workspace = true }
opaque-ke = { workspace = true }
serde = { workspace = true }
rust-argon2 = "2" rust-argon2 = "2"
curve25519-dalek = "3" curve25519-dalek = "3"
digest = "0.9" digest = "0.9"
generic-array = "0.14" generic-array = "0.14"
rand = { workspace = true } rand = "0.8"
sha2 = "0.9" sha2 = "0.9"
thiserror = { workspace = true } thiserror = "2"
uuid = { workspace = true, features = ["serde"] } uuid = { version = "1.18.1", features = ["serde"] }
[dependencies.derive_more]
features = ["debug", "display"]
default-features = false
version = "1"
[dependencies.opaque-ke]
version = "0.7"
[dependencies.chrono]
version = "*"
features = ["serde"]
[dependencies.sea-orm] [dependencies.sea-orm]
workspace = true workspace = true
features = ["macros"]
optional = true optional = true
[dependencies.serde]
workspace = true
# For WASM targets, use the JS getrandom. # For WASM targets, use the JS getrandom.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.getrandom] [target.'cfg(not(target_arch = "wasm32"))'.dependencies.getrandom]
version = "0.2" version = "0.2"
+22 -10
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "lldap_domain_handlers" name = "lldap_domain_handlers"
version = "0.1.1" version = "0.1.0"
edition.workspace = true edition.workspace = true
authors.workspace = true authors.workspace = true
homepage.workspace = true homepage.workspace = true
@@ -12,17 +12,22 @@ rust-version.workspace = true
test = [] test = []
[dependencies] [dependencies]
async-trait = { workspace = true } async-trait = "0.1"
base64 = { workspace = true } base64 = "0.21"
chrono = { workspace = true } ldap3_proto = "0.6.0"
derive_more = { workspace = true } serde_bytes = "0.11"
ldap3_proto = { workspace = true }
serde = { workspace = true }
serde_bytes = { workspace = true }
uuid = { workspace = true }
[dev-dependencies] [dev-dependencies]
pretty_assertions = { workspace = true } pretty_assertions = "1"
[dependencies.chrono]
features = ["serde"]
version = "0.4"
[dependencies.derive_more]
features = ["debug", "display", "from", "from_str"]
default-features = false
version = "1"
[dependencies.lldap_auth] [dependencies.lldap_auth]
path = "../auth" path = "../auth"
@@ -33,3 +38,10 @@ path = "../domain"
[dependencies.lldap_domain_model] [dependencies.lldap_domain_model]
path = "../domain-model" path = "../domain-model"
[dependencies.serde]
workspace = true
[dependencies.uuid]
features = ["v1", "v3"]
version = "1"
+27 -12
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "lldap_domain_model" name = "lldap_domain_model"
version = "0.1.1" version = "0.1.0"
edition.workspace = true edition.workspace = true
authors.workspace = true authors.workspace = true
homepage.workspace = true homepage.workspace = true
@@ -12,19 +12,23 @@ rust-version.workspace = true
test = [] test = []
[dependencies] [dependencies]
base64 = { workspace = true } base64 = "0.21"
bincode = { workspace = true } bincode = "1.3"
chrono = { workspace = true } orion = "0.17"
derive_more = { workspace = true } serde_bytes = "0.11"
orion = { workspace = true } thiserror = "2"
sea-orm = { workspace = true }
serde = { workspace = true }
serde_bytes = { workspace = true }
thiserror = { workspace = true }
uuid = { workspace = true }
[dev-dependencies] [dev-dependencies]
pretty_assertions = { workspace = true } pretty_assertions = "1"
[dependencies.chrono]
features = ["serde"]
version = "0.4"
[dependencies.derive_more]
features = ["debug", "display", "from", "from_str"]
default-features = false
version = "1"
[dependencies.lldap_auth] [dependencies.lldap_auth]
path = "../auth" path = "../auth"
@@ -32,3 +36,14 @@ features = ["opaque_server", "opaque_client", "sea_orm"]
[dependencies.lldap_domain] [dependencies.lldap_domain]
path = "../domain" path = "../domain"
[dependencies.sea-orm]
workspace = true
features = ["macros"]
[dependencies.serde]
workspace = true
[dependencies.uuid]
features = ["v1", "v3"]
version = "1"
+44 -16
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "lldap_domain" name = "lldap_domain"
version = "0.1.1" version = "0.1.0"
authors = [ authors = [
"Valentin Tolmer <valentin@tolmer.fr>", "Valentin Tolmer <valentin@tolmer.fr>",
"Simon Broeng Jensen <sbj@cwconsult.dk>", "Simon Broeng Jensen <sbj@cwconsult.dk>",
@@ -15,23 +15,51 @@ rust-version.workspace = true
test = [] test = []
[dependencies] [dependencies]
anyhow = { workspace = true } anyhow = "*"
base64 = { workspace = true } base64 = "0.21"
bincode = { workspace = true } bincode = "1.3"
chrono = { workspace = true } itertools = "0.10"
derive_more = { workspace = true } juniper = "0.15"
image = { workspace = true } serde_bytes = "0.11"
itertools = { workspace = true }
juniper = { workspace = true } [dev-dependencies]
sea-orm = { workspace = true } pretty_assertions = "1"
serde = { workspace = true }
serde_bytes = { workspace = true } [dependencies.chrono]
strum = { workspace = true } features = ["serde"]
uuid = { workspace = true } version = "*"
[dependencies.derive_more]
features = ["debug", "display", "from", "from_str"]
default-features = false
version = "1"
[dependencies.image]
features = ["jpeg"]
default-features = false
version = "0.24"
[dependencies.lldap_auth] [dependencies.lldap_auth]
path = "../auth" path = "../auth"
features = ["opaque_server", "opaque_client", "sea_orm"] features = ["opaque_server", "opaque_client", "sea_orm"]
[dev-dependencies] [dependencies.sea-orm]
pretty_assertions = { workspace = true } workspace = true
features = [
"macros",
"with-chrono",
"with-uuid",
"sqlx-all",
"runtime-actix-rustls",
]
[dependencies.serde]
workspace = true
[dependencies.strum]
features = ["derive"]
version = "0.25"
[dependencies.uuid]
features = ["v1", "v3"]
version = "1"
+9 -6
View File
@@ -333,7 +333,7 @@ impl TryFrom<&[u8]> for JpegPhoto {
return Ok(JpegPhoto::null()); return Ok(JpegPhoto::null());
} }
// Confirm that it's a valid Jpeg, then store only the bytes. // Confirm that it's a valid Jpeg, then store only the bytes.
image::ImageReader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg) image::io::Reader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg)
.decode()?; .decode()?;
Ok(JpegPhoto(bytes.to_vec())) Ok(JpegPhoto(bytes.to_vec()))
} }
@@ -346,7 +346,7 @@ impl TryFrom<Vec<u8>> for JpegPhoto {
return Ok(JpegPhoto::null()); return Ok(JpegPhoto::null());
} }
// Confirm that it's a valid Jpeg, then store only the bytes. // Confirm that it's a valid Jpeg, then store only the bytes.
image::ImageReader::with_format( image::io::Reader::with_format(
std::io::Cursor::new(bytes.as_slice()), std::io::Cursor::new(bytes.as_slice()),
image::ImageFormat::Jpeg, image::ImageFormat::Jpeg,
) )
@@ -397,7 +397,7 @@ impl JpegPhoto {
#[cfg(any(feature = "test", test))] #[cfg(any(feature = "test", test))]
pub fn for_tests() -> Self { pub fn for_tests() -> Self {
use image::{ImageFormat, Rgb, RgbImage}; use image::{ImageOutputFormat, Rgb, RgbImage};
let img = RgbImage::from_fn(32, 32, |x, y| { let img = RgbImage::from_fn(32, 32, |x, y| {
if (x + y) % 2 == 0 { if (x + y) % 2 == 0 {
Rgb([0, 0, 0]) Rgb([0, 0, 0])
@@ -406,8 +406,11 @@ impl JpegPhoto {
} }
}); });
let mut bytes: Vec<u8> = Vec::new(); let mut bytes: Vec<u8> = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut bytes), ImageFormat::Jpeg) img.write_to(
.unwrap(); &mut std::io::Cursor::new(&mut bytes),
ImageOutputFormat::Jpeg(0),
)
.unwrap();
Self(bytes) Self(bytes)
} }
} }
@@ -685,7 +688,7 @@ mod tests {
); );
assert_eq!( assert_eq!(
&format!("{:?}", Serialized::from(&JpegPhoto::for_tests())), &format!("{:?}", Serialized::from(&JpegPhoto::for_tests())),
"Serialized(\"hash: 0xBB3017828B2F3DEF\")" "Serialized(\"hash: 0xB947C77A16F3C3BD\")"
); );
} }
+3 -3
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "lldap_frontend_options" name = "lldap_frontend_options"
version = "0.1.1" version = "0.1.0"
description = "Frontend options for LLDAP" description = "Frontend options for LLDAP"
authors.workspace = true authors.workspace = true
edition.workspace = true edition.workspace = true
@@ -9,5 +9,5 @@ license.workspace = true
repository.workspace = true repository.workspace = true
rust-version.workspace = true rust-version.workspace = true
[dependencies] [dependencies.serde]
serde = { workspace = true } workspace = true
+23 -12
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "lldap_graphql_server" name = "lldap_graphql_server"
version = "0.1.1" version = "0.1.0"
description = "GraphQL server for LLDAP" description = "GraphQL server for LLDAP"
edition.workspace = true edition.workspace = true
authors.workspace = true authors.workspace = true
@@ -10,14 +10,15 @@ repository.workspace = true
rust-version.workspace = true rust-version.workspace = true
[dependencies] [dependencies]
anyhow = { workspace = true } anyhow = "*"
chrono = { workspace = true } juniper = "0.15"
juniper = { workspace = true } serde_json = "1"
serde = { workspace = true } tracing = "*"
serde_json = { workspace = true } urlencoding = "2"
tracing = { workspace = true }
urlencoding = { workspace = true } [dependencies.chrono]
uuid = { workspace = true } features = ["serde"]
version = "*"
[dependencies.lldap_access_control] [dependencies.lldap_access_control]
path = "../access-control" path = "../access-control"
@@ -44,10 +45,16 @@ path = "../sql-backend-handler"
[dependencies.lldap_validation] [dependencies.lldap_validation]
path = "../validation" path = "../validation"
[dependencies.serde]
workspace = true
[dependencies.uuid]
features = ["v1", "v3"]
version = "1"
[dev-dependencies] [dev-dependencies]
mockall = { workspace = true } mockall = "0.11.4"
pretty_assertions = { workspace = true } pretty_assertions = "1"
tokio = { workspace = true }
#[dev-dependencies.lldap_auth] #[dev-dependencies.lldap_auth]
#path = "../auth" #path = "../auth"
@@ -63,3 +70,7 @@ path = "../test-utils"
#[dev-dependencies.lldap_sql_backend_handler] #[dev-dependencies.lldap_sql_backend_handler]
#path = "../sql-backend-handler" #path = "../sql-backend-handler"
#features = ["test"] #features = ["test"]
[dev-dependencies.tokio]
features = ["full"]
version = "1.25"
+2 -2
View File
@@ -60,7 +60,7 @@ impl<Handler: BackendHandler> Context<Handler> {
impl<Handler: BackendHandler> juniper::Context for Context<Handler> {} impl<Handler: BackendHandler> juniper::Context for Context<Handler> {}
type Schema<Handler> = type Schema<Handler> =
RootNode<Query<Handler>, Mutation<Handler>, EmptySubscription<Context<Handler>>>; RootNode<'static, Query<Handler>, Mutation<Handler>, EmptySubscription<Context<Handler>>>;
pub fn schema<Handler: BackendHandler>() -> Schema<Handler> { pub fn schema<Handler: BackendHandler>() -> Schema<Handler> {
Schema::new( Schema::new(
@@ -73,7 +73,7 @@ pub fn schema<Handler: BackendHandler>() -> Schema<Handler> {
pub fn export_schema(output_file: Option<String>) -> anyhow::Result<()> { pub fn export_schema(output_file: Option<String>) -> anyhow::Result<()> {
use anyhow::Context; use anyhow::Context;
use lldap_sql_backend_handler::SqlBackendHandler; use lldap_sql_backend_handler::SqlBackendHandler;
let output = schema::<SqlBackendHandler>().as_sdl(); let output = schema::<SqlBackendHandler>().as_schema_language();
match output_file { match output_file {
None => println!("{output}"), None => println!("{output}"),
Some(path) => { Some(path) => {
+4 -4
View File
@@ -561,13 +561,13 @@ mod tests {
use mockall::predicate::eq; use mockall::predicate::eq;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
fn mutation_schema<C, Q, M>( fn mutation_schema<'q, C, Q, M>(
query_root: Q, query_root: Q,
mutation_root: M, mutation_root: M,
) -> RootNode<Q, M, EmptySubscription<C>> ) -> RootNode<'q, Q, M, EmptySubscription<C>>
where where
Q: GraphQLType<DefaultScalarValue, Context = C, TypeInfo = ()>, Q: GraphQLType<DefaultScalarValue, Context = C, TypeInfo = ()> + 'q,
M: GraphQLType<DefaultScalarValue, Context = C, TypeInfo = ()>, M: GraphQLType<DefaultScalarValue, Context = C, TypeInfo = ()> + 'q,
{ {
RootNode::new(query_root, mutation_root, EmptySubscription::<C>::new()) RootNode::new(query_root, mutation_root, EmptySubscription::<C>::new())
} }
+1 -1
View File
@@ -93,7 +93,7 @@ impl<Handler: BackendHandler> AttributeValue<Handler> {
} }
} }
pub(super) fn attribute_name(&self) -> &str { pub(super) fn name(&self) -> &str {
self.attribute.name.as_str() self.attribute.name.as_str()
} }
} }
+11 -20
View File
@@ -47,11 +47,7 @@ impl<Handler: BackendHandler> Query<Handler> {
"1.0" "1.0"
} }
pub async fn user( pub async fn user(context: &Context<Handler>, user_id: String) -> FieldResult<User<Handler>> {
&self,
context: &Context<Handler>,
user_id: String,
) -> FieldResult<User<Handler>> {
use anyhow::Context; use anyhow::Context;
let span = debug_span!("[GraphQL query] user"); let span = debug_span!("[GraphQL query] user");
span.in_scope(|| { span.in_scope(|| {
@@ -65,15 +61,14 @@ impl<Handler: BackendHandler> Query<Handler> {
&span, &span,
"Unauthorized access to user data", "Unauthorized access to user data",
))?; ))?;
let schema: Arc<PublicSchema> = Arc::new(self.get_schema(context, span.clone()).await?); let schema = Arc::new(self.get_schema(context, span.clone()).await?);
let user = handler.get_user_details(&user_id).instrument(span).await?; let user = handler.get_user_details(&user_id).instrument(span).await?;
User::<Handler>::from_user(user, schema) User::<Handler>::from_user(user, schema)
} }
async fn users( async fn users(
&self,
context: &Context<Handler>, context: &Context<Handler>,
filters: Option<RequestFilter>, #[graphql(name = "where")] filters: Option<RequestFilter>,
) -> FieldResult<Vec<User<Handler>>> { ) -> FieldResult<Vec<User<Handler>>> {
let span = debug_span!("[GraphQL query] users"); let span = debug_span!("[GraphQL query] users");
span.in_scope(|| { span.in_scope(|| {
@@ -85,7 +80,7 @@ impl<Handler: BackendHandler> Query<Handler> {
&span, &span,
"Unauthorized access to user list", "Unauthorized access to user list",
))?; ))?;
let schema: Arc<PublicSchema> = Arc::new(self.get_schema(context, span.clone()).await?); let schema = Arc::new(self.get_schema(context, span.clone()).await?);
let users = handler let users = handler
.list_users( .list_users(
filters filters
@@ -101,7 +96,7 @@ impl<Handler: BackendHandler> Query<Handler> {
.collect() .collect()
} }
async fn groups(&self, context: &Context<Handler>) -> FieldResult<Vec<Group<Handler>>> { async fn groups(context: &Context<Handler>) -> FieldResult<Vec<Group<Handler>>> {
let span = debug_span!("[GraphQL query] groups"); let span = debug_span!("[GraphQL query] groups");
let handler = context let handler = context
.get_readonly_handler() .get_readonly_handler()
@@ -109,7 +104,7 @@ impl<Handler: BackendHandler> Query<Handler> {
&span, &span,
"Unauthorized access to group list", "Unauthorized access to group list",
))?; ))?;
let schema: Arc<PublicSchema> = Arc::new(self.get_schema(context, span.clone()).await?); let schema = Arc::new(self.get_schema(context, span.clone()).await?);
let domain_groups = handler.list_groups(None).instrument(span).await?; let domain_groups = handler.list_groups(None).instrument(span).await?;
domain_groups domain_groups
.into_iter() .into_iter()
@@ -117,11 +112,7 @@ impl<Handler: BackendHandler> Query<Handler> {
.collect() .collect()
} }
async fn group( async fn group(context: &Context<Handler>, group_id: i32) -> FieldResult<Group<Handler>> {
&self,
context: &Context<Handler>,
group_id: i32,
) -> FieldResult<Group<Handler>> {
let span = debug_span!("[GraphQL query] group"); let span = debug_span!("[GraphQL query] group");
span.in_scope(|| { span.in_scope(|| {
debug!(?group_id); debug!(?group_id);
@@ -132,7 +123,7 @@ impl<Handler: BackendHandler> Query<Handler> {
&span, &span,
"Unauthorized access to group data", "Unauthorized access to group data",
))?; ))?;
let schema: Arc<PublicSchema> = Arc::new(self.get_schema(context, span.clone()).await?); let schema = Arc::new(self.get_schema(context, span.clone()).await?);
let group_details = handler let group_details = handler
.get_group_details(GroupId(group_id)) .get_group_details(GroupId(group_id))
.instrument(span) .instrument(span)
@@ -140,7 +131,7 @@ impl<Handler: BackendHandler> Query<Handler> {
Group::<Handler>::from_group_details(group_details, schema.clone()) Group::<Handler>::from_group_details(group_details, schema.clone())
} }
async fn schema(&self, context: &Context<Handler>) -> FieldResult<Schema<Handler>> { async fn schema(context: &Context<Handler>) -> FieldResult<Schema<Handler>> {
let span = debug_span!("[GraphQL query] get_schema"); let span = debug_span!("[GraphQL query] get_schema");
self.get_schema(context, span).await.map(Into::into) self.get_schema(context, span).await.map(Into::into)
} }
@@ -184,9 +175,9 @@ mod tests {
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use std::collections::HashSet; use std::collections::HashSet;
fn schema<C, Q>(query_root: Q) -> RootNode<Q, EmptyMutation<C>, EmptySubscription<C>> fn schema<'q, C, Q>(query_root: Q) -> RootNode<'q, Q, EmptyMutation<C>, EmptySubscription<C>>
where where
Q: GraphQLType<DefaultScalarValue, Context = C, TypeInfo = ()>, Q: GraphQLType<DefaultScalarValue, Context = C, TypeInfo = ()> + 'q,
{ {
RootNode::new( RootNode::new(
query_root, query_root,
+3 -3
View File
@@ -70,7 +70,7 @@ impl<Handler: BackendHandler> User<Handler> {
fn first_name(&self) -> &str { fn first_name(&self) -> &str {
self.attributes self.attributes
.iter() .iter()
.find(|a| a.attribute_name() == "first_name") .find(|a| a.name() == "first_name")
.map(|a| a.attribute.value.as_str().unwrap_or_default()) .map(|a| a.attribute.value.as_str().unwrap_or_default())
.unwrap_or_default() .unwrap_or_default()
} }
@@ -78,7 +78,7 @@ impl<Handler: BackendHandler> User<Handler> {
fn last_name(&self) -> &str { fn last_name(&self) -> &str {
self.attributes self.attributes
.iter() .iter()
.find(|a| a.attribute_name() == "last_name") .find(|a| a.name() == "last_name")
.map(|a| a.attribute.value.as_str().unwrap_or_default()) .map(|a| a.attribute.value.as_str().unwrap_or_default())
.unwrap_or_default() .unwrap_or_default()
} }
@@ -86,7 +86,7 @@ impl<Handler: BackendHandler> User<Handler> {
fn avatar(&self) -> Option<String> { fn avatar(&self) -> Option<String> {
self.attributes self.attributes
.iter() .iter()
.find(|a| a.attribute_name() == "avatar") .find(|a| a.name() == "avatar")
.map(|a| { .map(|a| {
String::from( String::from(
a.attribute a.attribute
+23 -13
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "lldap_ldap" name = "lldap_ldap"
version = "0.1.1" version = "0.1.0"
description = "LDAP operations support" description = "LDAP operations support"
authors.workspace = true authors.workspace = true
edition.workspace = true edition.workspace = true
@@ -10,19 +10,27 @@ repository.workspace = true
rust-version.workspace = true rust-version.workspace = true
[dependencies] [dependencies]
anyhow = { workspace = true } anyhow = "*"
tracing = { workspace = true } ldap3_proto = "0.6.0"
itertools = { workspace = true } tracing = "*"
itertools = "0.10"
derive_more = { workspace = true } [dependencies.derive_more]
features = ["from"]
default-features = false
version = "1"
chrono = { workspace = true } [dependencies.chrono]
features = ["serde"]
version = "*"
rand = { workspace = true } [dependencies.rand]
features = ["small_rng", "getrandom"]
version = "0.8"
uuid = { workspace = true } [dependencies.uuid]
version = "1"
ldap3_proto = { workspace = true } features = ["v1", "v3"]
[dependencies.lldap_access_control] [dependencies.lldap_access_control]
path = "../access-control" path = "../access-control"
@@ -47,10 +55,12 @@ path = "../opaque-handler"
path = "../test-utils" path = "../test-utils"
[dev-dependencies] [dev-dependencies]
mockall = { workspace = true } mockall = "0.11.4"
pretty_assertions = { workspace = true } pretty_assertions = "1"
tokio = { workspace = true } [dev-dependencies.tokio]
features = ["full"]
version = "1.25"
[dev-dependencies.lldap_domain] [dev-dependencies.lldap_domain]
path = "../domain" path = "../domain"
+10 -95
View File
@@ -173,31 +173,22 @@ fn get_group_attribute_equality_filter(
typ: AttributeType, typ: AttributeType,
is_list: bool, is_list: bool,
value: &str, value: &str,
) -> LdapResult<GroupRequestFilter> { ) -> GroupRequestFilter {
if is_list {
return Err(LdapError {
code: LdapResultCode::UnwillingToPerform,
message: format!(
"Equality filter on list attribute \"{}\" is not supported",
field
),
});
}
let value_lc = value.to_ascii_lowercase(); let value_lc = value.to_ascii_lowercase();
let serialized_value = deserialize_attribute_value(&[value.to_owned()], typ, false); let serialized_value = deserialize_attribute_value(&[value.to_owned()], typ, is_list);
let serialized_value_lc = deserialize_attribute_value(&[value_lc.to_owned()], typ, false); let serialized_value_lc = deserialize_attribute_value(&[value_lc.to_owned()], typ, is_list);
match (serialized_value, serialized_value_lc) { match (serialized_value, serialized_value_lc) {
(Ok(v), Ok(v_lc)) => Ok(GroupRequestFilter::Or(vec![ (Ok(v), Ok(v_lc)) => GroupRequestFilter::Or(vec![
GroupRequestFilter::AttributeEquality(field.clone(), v), GroupRequestFilter::AttributeEquality(field.clone(), v),
GroupRequestFilter::AttributeEquality(field.clone(), v_lc), GroupRequestFilter::AttributeEquality(field.clone(), v_lc),
])), ]),
(Ok(_), Err(e)) => { (Ok(_), Err(e)) => {
warn!("Invalid value for attribute {} (lowercased): {}", field, e); warn!("Invalid value for attribute {} (lowercased): {}", field, e);
Ok(GroupRequestFilter::False) GroupRequestFilter::False
} }
(Err(e), _) => { (Err(e), _) => {
warn!("Invalid value for attribute {}: {}", field, e); warn!("Invalid value for attribute {}: {}", field, e);
Ok(GroupRequestFilter::False) GroupRequestFilter::False
} }
} }
} }
@@ -268,9 +259,9 @@ fn convert_group_filter(
} }
Ok(GroupRequestFilter::False) Ok(GroupRequestFilter::False)
} }
GroupFieldType::Attribute(field, typ, is_list) => { GroupFieldType::Attribute(field, typ, is_list) => Ok(
get_group_attribute_equality_filter(&field, typ, is_list, value) get_group_attribute_equality_filter(&field, typ, is_list, value),
} ),
GroupFieldType::CreationDate => Err(LdapError { GroupFieldType::CreationDate => Err(LdapError {
code: LdapResultCode::UnwillingToPerform, code: LdapResultCode::UnwillingToPerform,
message: "Creation date filter for groups not supported".to_owned(), message: "Creation date filter for groups not supported".to_owned(),
@@ -680,82 +671,6 @@ mod tests {
); );
} }
#[tokio::test]
async fn test_equality_filter_on_list_group_attribute_returns_error() {
use lldap_domain::schema::{AttributeList, AttributeSchema, Schema};
let mut mock = MockTestBackendHandler::new();
mock.expect_get_schema().returning(|| {
Ok(Schema {
user_attributes: AttributeList {
attributes: Vec::new(),
},
group_attributes: AttributeList {
attributes: vec![AttributeSchema {
name: "tags".into(),
attribute_type: AttributeType::String,
is_list: true,
is_visible: true,
is_editable: true,
is_hardcoded: false,
is_readonly: false,
}],
},
extra_user_object_classes: Vec::new(),
extra_group_object_classes: Vec::new(),
})
});
let ldap_handler = setup_bound_admin_handler(mock).await;
let request = make_group_search_request(
LdapFilter::Equality("tags".to_string(), "foo".to_string()),
vec!["dn"],
);
assert_eq!(
ldap_handler.do_search_or_dse(&request).await,
Err(LdapError {
code: LdapResultCode::UnwillingToPerform,
message: r#"Equality filter on list attribute "tags" is not supported"#.to_string(),
})
);
}
#[tokio::test]
async fn test_equality_filter_on_non_list_group_attribute() {
use lldap_domain::schema::{AttributeList, AttributeSchema, Schema};
let mut mock = MockTestBackendHandler::new();
mock.expect_get_schema().returning(|| {
Ok(Schema {
user_attributes: AttributeList {
attributes: Vec::new(),
},
group_attributes: AttributeList {
attributes: vec![AttributeSchema {
name: "club_name".into(),
attribute_type: AttributeType::String,
is_list: false,
is_visible: true,
is_editable: true,
is_hardcoded: false,
is_readonly: false,
}],
},
extra_user_object_classes: Vec::new(),
extra_group_object_classes: Vec::new(),
})
});
mock.expect_list_groups()
.times(1)
.return_once(|_| Ok(vec![]));
let ldap_handler = setup_bound_admin_handler(mock).await;
let request = make_group_search_request(
LdapFilter::Equality("club_name".to_string(), "myclub".to_string()),
vec!["dn"],
);
assert_eq!(
ldap_handler.do_search_or_dse(&request).await,
Ok(vec![make_search_success()])
);
}
#[tokio::test] #[tokio::test]
async fn test_search_group_as_scope() { async fn test_search_group_as_scope() {
let mut mock = MockTestBackendHandler::new(); let mut mock = MockTestBackendHandler::new();
+10 -96
View File
@@ -182,31 +182,22 @@ fn get_user_attribute_equality_filter(
typ: AttributeType, typ: AttributeType,
is_list: bool, is_list: bool,
value: &str, value: &str,
) -> LdapResult<UserRequestFilter> { ) -> UserRequestFilter {
if is_list {
return Err(LdapError {
code: LdapResultCode::UnwillingToPerform,
message: format!(
"Equality filter on list attribute \"{}\" is not supported",
field
),
});
}
let value_lc = value.to_ascii_lowercase(); let value_lc = value.to_ascii_lowercase();
let serialized_value = deserialize_attribute_value(&[value.to_owned()], typ, false); let serialized_value = deserialize_attribute_value(&[value.to_owned()], typ, is_list);
let serialized_value_lc = deserialize_attribute_value(&[value_lc.to_owned()], typ, false); let serialized_value_lc = deserialize_attribute_value(&[value_lc.to_owned()], typ, is_list);
match (serialized_value, serialized_value_lc) { match (serialized_value, serialized_value_lc) {
(Ok(v), Ok(v_lc)) => Ok(UserRequestFilter::Or(vec![ (Ok(v), Ok(v_lc)) => UserRequestFilter::Or(vec![
UserRequestFilter::AttributeEquality(field.clone(), v), UserRequestFilter::AttributeEquality(field.clone(), v),
UserRequestFilter::AttributeEquality(field.clone(), v_lc), UserRequestFilter::AttributeEquality(field.clone(), v_lc),
])), ]),
(Ok(_), Err(e)) => { (Ok(_), Err(e)) => {
warn!("Invalid value for attribute {} (lowercased): {}", field, e); warn!("Invalid value for attribute {} (lowercased): {}", field, e);
Ok(UserRequestFilter::False) UserRequestFilter::False
} }
(Err(e), _) => { (Err(e), _) => {
warn!("Invalid value for attribute {}: {}", field, e); warn!("Invalid value for attribute {}: {}", field, e);
Ok(UserRequestFilter::False) UserRequestFilter::False
} }
} }
} }
@@ -288,9 +279,9 @@ fn convert_user_filter(
UserFieldType::PrimaryField(field) => { UserFieldType::PrimaryField(field) => {
Ok(UserRequestFilter::Equality(field, value_lc)) Ok(UserRequestFilter::Equality(field, value_lc))
} }
UserFieldType::Attribute(field, typ, is_list) => { UserFieldType::Attribute(field, typ, is_list) => Ok(
get_user_attribute_equality_filter(&field, typ, is_list, value) get_user_attribute_equality_filter(&field, typ, is_list, value),
} ),
UserFieldType::NoMatch => { UserFieldType::NoMatch => {
if !ldap_info.ignored_user_attributes.contains(&field) { if !ldap_info.ignored_user_attributes.contains(&field) {
warn!( warn!(
@@ -795,83 +786,6 @@ mod tests {
} }
} }
#[tokio::test]
async fn test_equality_filter_on_list_user_attribute_returns_error() {
use lldap_domain::schema::{AttributeList, AttributeSchema, Schema};
let mut mock = MockTestBackendHandler::new();
mock.expect_get_schema().returning(|| {
Ok(Schema {
user_attributes: AttributeList {
attributes: vec![AttributeSchema {
name: "mailalias".into(),
attribute_type: AttributeType::String,
is_list: true,
is_visible: true,
is_editable: true,
is_hardcoded: false,
is_readonly: false,
}],
},
group_attributes: AttributeList {
attributes: Vec::new(),
},
extra_user_object_classes: Vec::new(),
extra_group_object_classes: Vec::new(),
})
});
let ldap_handler = setup_bound_admin_handler(mock).await;
let request = make_user_search_request(
LdapFilter::Equality("mailalias".to_string(), "alias@example.com".to_string()),
vec!["dn"],
);
assert_eq!(
ldap_handler.do_search_or_dse(&request).await,
Err(LdapError {
code: LdapResultCode::UnwillingToPerform,
message: r#"Equality filter on list attribute "mailalias" is not supported"#
.to_string(),
})
);
}
#[tokio::test]
async fn test_equality_filter_on_non_list_user_attribute() {
use lldap_domain::schema::{AttributeList, AttributeSchema, Schema};
let mut mock = MockTestBackendHandler::new();
mock.expect_get_schema().returning(|| {
Ok(Schema {
user_attributes: AttributeList {
attributes: vec![AttributeSchema {
name: "nickname".into(),
attribute_type: AttributeType::String,
is_list: false,
is_visible: true,
is_editable: true,
is_hardcoded: false,
is_readonly: false,
}],
},
group_attributes: AttributeList {
attributes: Vec::new(),
},
extra_user_object_classes: Vec::new(),
extra_group_object_classes: Vec::new(),
})
});
mock.expect_list_users()
.times(1)
.return_once(|_, _| Ok(vec![]));
let ldap_handler = setup_bound_admin_handler(mock).await;
let request = make_user_search_request(
LdapFilter::Equality("nickname".to_string(), "alice".to_string()),
vec!["dn"],
);
assert_eq!(
ldap_handler.do_search_or_dse(&request).await,
Ok(vec![make_search_success()])
);
}
#[tokio::test] #[tokio::test]
async fn test_search_cn_case_insensitive() { async fn test_search_cn_case_insensitive() {
use lldap_domain::uuid; use lldap_domain::uuid;
+3 -3
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "lldap_opaque_handler" name = "lldap_opaque_handler"
version = "0.1.1" version = "0.1.0"
description = "Opaque handler trait for LLDAP" description = "Opaque handler trait for LLDAP"
authors.workspace = true authors.workspace = true
edition.workspace = true edition.workspace = true
@@ -13,7 +13,7 @@ rust-version.workspace = true
test = [] test = []
[dependencies] [dependencies]
async-trait = { workspace = true } async-trait = "0.1"
[dependencies.lldap_auth] [dependencies.lldap_auth]
path = "../auth" path = "../auth"
@@ -26,4 +26,4 @@ path = "../domain"
path = "../domain-model" path = "../domain-model"
[dev-dependencies] [dev-dependencies]
mockall = { workspace = true } mockall = "0.11.4"
+42 -28
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "lldap_sql_backend_handler" name = "lldap_sql_backend_handler"
version = "0.1.1" version = "0.1.0"
description = "SQL backend for LLDAP" description = "SQL backend for LLDAP"
authors.workspace = true authors.workspace = true
edition.workspace = true edition.workspace = true
@@ -13,30 +13,44 @@ rust-version.workspace = true
test = [] test = []
[dependencies] [dependencies]
anyhow = { workspace = true } anyhow = "*"
async-trait = { workspace = true } async-trait = "0.1"
itertools = { workspace = true } base64 = "0.21"
orion = { workspace = true } bincode = "1.3"
serde_json = { workspace = true } itertools = "0.10"
tracing = { workspace = true } ldap3_proto = "0.6.0"
orion = "0.17"
serde_json = "1"
tracing = "*"
bincode = { workspace = true } [dependencies.chrono]
features = ["serde"]
version = "*"
base64 = { workspace = true } [dependencies.rand]
features = ["small_rng", "getrandom"]
version = "0.8"
chrono = { workspace = true } [dependencies.sea-orm]
workspace = true
features = [
"macros",
"with-chrono",
"with-uuid",
"sqlx-all",
"runtime-actix-rustls",
]
rand = { workspace = true } [dependencies.secstr]
features = ["serde"]
version = "*"
sea-orm = { workspace = true } [dependencies.serde]
workspace = true
secstr = { workspace = true } [dependencies.uuid]
version = "1"
serde = { workspace = true } features = ["v1", "v3"]
uuid = { workspace = true }
ldap3_proto = { workspace = true }
[dependencies.lldap_access_control] [dependencies.lldap_access_control]
path = "../access-control" path = "../access-control"
@@ -57,18 +71,18 @@ path = "../domain-model"
[dependencies.lldap_opaque_handler] [dependencies.lldap_opaque_handler]
path = "../opaque-handler" path = "../opaque-handler"
[dev-dependencies.lldap_domain]
path = "../domain"
features = ["test"]
[dev-dependencies.lldap_test_utils] [dev-dependencies.lldap_test_utils]
path = "../test-utils" path = "../test-utils"
[dev-dependencies] [dev-dependencies]
log = { workspace = true } log = "*"
mockall = { workspace = true } mockall = "0.11.4"
pretty_assertions = { workspace = true } pretty_assertions = "1"
tokio = { workspace = true } [dev-dependencies.tokio]
features = ["full"]
version = "1.25"
tracing-subscriber = { workspace = true } [dev-dependencies.tracing-subscriber]
version = "0.3"
features = ["env-filter", "tracing-log"]
@@ -536,7 +536,7 @@ See https://github.com/lldap/lldap/blob/main/docs/migration_guides/v0.5.md for d
Conflicting emails: Conflicting emails:
"#, "#,
); );
let duplicate_users = transaction for (email, users) in &transaction
.query_all( .query_all(
builder.build( builder.build(
Query::select() Query::select()
@@ -568,8 +568,9 @@ Conflicting emails:
row.try_get::<String>("", &Users::Email.to_string()) row.try_get::<String>("", &Users::Email.to_string())
.unwrap(), .unwrap(),
) )
}); })
for (email, users) in &duplicate_users.chunk_by(|(_user, email)| email.to_owned()) { .group_by(|(_user, email)| email.to_owned())
{
warn!("Email: {email}"); warn!("Email: {email}");
for (user, _email) in users { for (user, _email) in users {
warn!(" User: {}", user.as_str()); warn!(" User: {}", user.as_str());
@@ -29,76 +29,28 @@ impl ReadSchemaBackendHandler for SqlBackendHandler {
#[async_trait] #[async_trait]
impl SchemaBackendHandler for SqlBackendHandler { impl SchemaBackendHandler for SqlBackendHandler {
async fn add_user_attribute(&self, request: CreateAttributeRequest) -> Result<()> { async fn add_user_attribute(&self, request: CreateAttributeRequest) -> Result<()> {
self.sql_pool let new_attribute = model::user_attribute_schema::ActiveModel {
.transaction::<_, (), DomainError>(|transaction| { attribute_name: Set(request.name),
Box::pin(async move { attribute_type: Set(request.attribute_type),
// Check for conflicting group attribute with the same name but different type. is_list: Set(request.is_list),
// Done inside the transaction to prevent TOCTOU races. is_user_visible: Set(request.is_visible),
let schema = Self::get_schema_with_transaction(transaction).await?; is_user_editable: Set(request.is_editable),
if let Some(group_attr) = schema is_hardcoded: Set(false),
.group_attributes };
.attributes new_attribute.insert(&self.sql_pool).await?;
.iter()
.find(|a| a.name == request.name)
&& group_attr.attribute_type != request.attribute_type
{
return Err(DomainError::InternalError(format!(
"A group attribute '{}' already exists with type {:?}, \
cannot create a user attribute with type {:?}. \
LDAP requires each attribute name to have a single type.",
request.name, group_attr.attribute_type, request.attribute_type
)));
}
let new_attribute = model::user_attribute_schema::ActiveModel {
attribute_name: Set(request.name),
attribute_type: Set(request.attribute_type),
is_list: Set(request.is_list),
is_user_visible: Set(request.is_visible),
is_user_editable: Set(request.is_editable),
is_hardcoded: Set(false),
};
new_attribute.insert(transaction).await?;
Ok(())
})
})
.await?;
Ok(()) Ok(())
} }
async fn add_group_attribute(&self, request: CreateAttributeRequest) -> Result<()> { async fn add_group_attribute(&self, request: CreateAttributeRequest) -> Result<()> {
self.sql_pool let new_attribute = model::group_attribute_schema::ActiveModel {
.transaction::<_, (), DomainError>(|transaction| { attribute_name: Set(request.name),
Box::pin(async move { attribute_type: Set(request.attribute_type),
// Check for conflicting user attribute with the same name but different type. is_list: Set(request.is_list),
// Done inside the transaction to prevent TOCTOU races. is_group_visible: Set(request.is_visible),
let schema = Self::get_schema_with_transaction(transaction).await?; is_group_editable: Set(request.is_editable),
if let Some(user_attr) = schema is_hardcoded: Set(false),
.user_attributes };
.attributes new_attribute.insert(&self.sql_pool).await?;
.iter()
.find(|a| a.name == request.name)
&& user_attr.attribute_type != request.attribute_type
{
return Err(DomainError::InternalError(format!(
"A user attribute '{}' already exists with type {:?}, \
cannot create a group attribute with type {:?}. \
LDAP requires each attribute name to have a single type.",
request.name, user_attr.attribute_type, request.attribute_type
)));
}
let new_attribute = model::group_attribute_schema::ActiveModel {
attribute_name: Set(request.name),
attribute_type: Set(request.attribute_type),
is_list: Set(request.is_list),
is_group_visible: Set(request.is_visible),
is_group_editable: Set(request.is_editable),
is_hardcoded: Set(false),
};
new_attribute.insert(transaction).await?;
Ok(())
})
})
.await?;
Ok(()) Ok(())
} }
+8 -7
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "lldap_test_utils" name = "lldap_test_utils"
version = "0.1.1" version = "0.1.0"
authors.workspace = true authors.workspace = true
edition.workspace = true edition.workspace = true
homepage.workspace = true homepage.workspace = true
@@ -9,13 +9,14 @@ repository.workspace = true
rust-version.workspace = true rust-version.workspace = true
[dependencies] [dependencies]
async-trait = { workspace = true } async-trait = "0.1"
mockall = { workspace = true } ldap3_proto = "0.6.0"
tracing = { workspace = true } mockall = "0.11.4"
tracing = "*"
uuid = { workspace = true } [dependencies.uuid]
version = "1"
ldap3_proto = { workspace = true } features = ["v1", "v3"]
[dependencies.lldap_access_control] [dependencies.lldap_access_control]
path = "../access-control" path = "../access-control"
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "lldap_validation" name = "lldap_validation"
version = "0.6.3" version = "0.6.0"
authors = ["Simon Broeng Jensen <sbj@cwconsult.dk>"] authors = ["Simon Broeng Jensen <sbj@cwconsult.dk>"]
description = "Validation logic for LLDAP" description = "Validation logic for LLDAP"
edition.workspace = true edition.workspace = true
-1
View File
@@ -86,7 +86,6 @@ services:
# - LLDAP_SMTP_OPTIONS__PASSWORD=PasswordGoesHere # The SMTP password # - LLDAP_SMTP_OPTIONS__PASSWORD=PasswordGoesHere # The SMTP password
# - LLDAP_SMTP_OPTIONS__FROM=no-reply <no-reply@example.com> # The header field, optional: how the sender appears in the email. The first is a free-form name, followed by an email between <>. # - LLDAP_SMTP_OPTIONS__FROM=no-reply <no-reply@example.com> # The header field, optional: how the sender appears in the email. The first is a free-form name, followed by an email between <>.
# - LLDAP_SMTP_OPTIONS__TO=admin <admin@example.com> # Same for reply-to, optional. # - LLDAP_SMTP_OPTIONS__TO=admin <admin@example.com> # Same for reply-to, optional.
# - LLDAP_HTTP_URL=https://lldap.example.com # URL used in the email template to compose the reset link
``` ```
Then the service will listen on two ports, one for LDAP and one for the web Then the service will listen on two ports, one for LDAP and one for the web
-3
View File
@@ -19,13 +19,11 @@ configuration files:
- [Dolibarr](dolibarr.md) - [Dolibarr](dolibarr.md)
- [Duo Auth Proxy](duo_auth_proxy.md) - [Duo Auth Proxy](duo_auth_proxy.md)
- [Ejabberd](ejabberd.md) - [Ejabberd](ejabberd.md)
- [Elasticsearch](elasticsearch.md)
- [Emby](emby.md) - [Emby](emby.md)
- [Ergo IRCd](ergo.md) - [Ergo IRCd](ergo.md)
- [Gerrit](gerrit.md) - [Gerrit](gerrit.md)
- [Gitea](gitea.md) - [Gitea](gitea.md)
- [GitLab](gitlab.md) - [GitLab](gitlab.md)
- [Gogs](gogs.md)
- [Grafana](grafana_ldap_config.toml) - [Grafana](grafana_ldap_config.toml)
- [Grocy](grocy.md) - [Grocy](grocy.md)
- [Harbor](harbor.md) - [Harbor](harbor.md)
@@ -52,7 +50,6 @@ configuration files:
- [Nexus](nexus.md) - [Nexus](nexus.md)
- [OCIS (OwnCloud Infinite Scale)](ocis.md) - [OCIS (OwnCloud Infinite Scale)](ocis.md)
- [OneDev](onedev.md) - [OneDev](onedev.md)
- [OpenCloud](opencloud.md)
- [Organizr](Organizr.md) - [Organizr](Organizr.md)
- [Peertube](peertube.md) - [Peertube](peertube.md)
- [Penpot](penpot.md) - [Penpot](penpot.md)
-56
View File
@@ -1,56 +0,0 @@
# Elasticsearch configuration
> ⚠️ Configuring Elasticsearch to use LDAP auth requires a paid licence. Only the `default` and `file` realms are enabled on a Basic licence. Using the trial licence on cluster init will allow LDAP auth to be configured.
This basic configuration example is for LLDAP auth on [Elastic Cloud on Kubernetes (ECK)](https://www.elastic.co/docs/deploy-manage/deploy/cloud-on-k8s). Advanced configuration can be found in the [Elastic docs](https://www.elastic.co/docs/deploy-manage/users-roles/cluster-or-deployment-auth/ldap).
## Elasticsearch
To perform auth using LLDAP in Elasticsearch, add the following lines to the Elasticsearch spec:
```yaml
spec:
nodesets:
- name: elasticsearch
config:
xpack.security.authc.realms.ldap:
ldap1:
order: 1
enabled: true
url: "ldap://<ip.of.lldap.instance>:3890"
user_dn_templates:
- "uid={0},ou=people,dc=example,dc=com"
bind_dn: "uid=admin,ou=people,dc=example,dc=com"
group_search:
base_dn: "ou=groups,dc=example,dc=com"
unmapped_groups_as_roles: false
secureSettings:
- secretName: elasticsearch-keystore-values
```
Then, create a secret called `elasticsearch-keystore-values`:
```yaml
apiVersion: v1
kind: Secret
type: Opaque
metadata:
name: elasticsearch-keystore-values
namespace: elastic
data:
xpack.security.authc.realms.ldap.ldap1.secure_bind_password: base64_encoded_ldap_admin_password
```
## Kibana
To allow Kibana to auth logins using LLDAP, add the following lines to the Kibana spec:
```yaml
spec:
config:
xpack.security.authc.providers:
basic.ldap1:
order: 0
```
Unless doing additional manifest configuration to automatically map, you will need to create a role mapping between an LLDAP role and an Elasticsearch role (e.g. `superuser`). This can be done by logging in using the default `elastic` user created during cluster init and then creating a role mapping in Stack Management. Once created, you will be able to login using LLDAP auth.
+1 -16
View File
@@ -1,21 +1,6 @@
# Gogs LDAP configuration # Gogs LDAP configuration
## Via Simple Auth (easier) Gogs can make use of LDAP and therefore lldap.
Go to the Administration settings, then go to Authentication. There, you have to add an authentication source.
For type, select "LDAP (Simple Auth)".
Name your authentication source however you'd like.
It is up to you to select your security protocol, but the only two compatible options are "LDAPS" and "Unencrypted".
As your host, put in the IP or FQDN (if you have DNS).
As your port, check your configuration. It will generally be 3890 for unencrypted (once again check your config/docker compose files), and 6360 for LDAPS (once again check your config/docker compose files).
Your User DN should follow this pattern: `uid=%s,ou=people,<your_base_dn>` (for example, `uid=%s,ou=people,dc=example,dc=com`). Replace `<your_base_dn>` with your actual base DN.
It is recommended to have your user filter to be `(&(objectClass=person)(uid=%s))`.
Set username attribute to `uid`, Given Name to `givenName`, surname to `sn`, and email to `mail`
You can (and should if you don't know LDAP) leave the rest empty.
## Via Bind DN (more complicated)
The following configuration is adapted from the example configuration at [their repository](https://github.com/gogs/gogs/blob/main/conf/auth.d/ldap_bind_dn.conf.example). The following configuration is adapted from the example configuration at [their repository](https://github.com/gogs/gogs/blob/main/conf/auth.d/ldap_bind_dn.conf.example).
The example is a container configuration - the file should live within `conf/auth.d/some_name.conf`: The example is a container configuration - the file should live within `conf/auth.d/some_name.conf`:
-55
View File
@@ -1,55 +0,0 @@
# OpenCloud example config
## About OpenCloud
A light-weight file-hosting / webDAV service written in Go and forked from ownCloud Infinite Scale (oCIS).
More information:
* https://opencloud.eu
* https://github.com/opencloud-eu
## LLDAP Configuration
OpenCloud ships an OIDC provider and a built-in LDAP server. It officially supports using a third-party OIDC provider.
This is **not** what this config does. This config leaves the general auth/OIDC infrastructure in place, but replaces the LDAP server from underneath it with LLDAP.
Configuration happens via environment variables. On FreeBSD, these are provided via `/usr/local/etc/opencloud/config.env`; on Linux you can provide them via the Docker configuration.
```dotenv
# Replace with actual IP and Port
OC_LDAP_URI=ldap://<lldap_ip>:3890
# Remove the following if you use LDAPS and your cert is not self-signed
OC_LDAP_INSECURE="true"
# Replace with your bind-user; can be in
OC_LDAP_BIND_DN="cn=<bind_user>,ou=people,dc=example,dc=com"
OC_LDAP_BIND_PASSWORD="<secret>"
OC_LDAP_GROUP_BASE_DN="ou=groups,dc=example,dc=com"
OC_LDAP_GROUP_SCHEMA_ID=entryuuid
OC_LDAP_USER_BASE_DN="ou=people,dc=example,dc=com"
OC_LDAP_USER_SCHEMA_ID=entryuuid
# Only allow users from specific group to login; remove this if everyone's allowed
OC_LDAP_USER_FILTER='(&(objectClass=person)(memberOf=cn=<opencloud_users>,ou=groups,dc=example,dc=com))'
# Other options have not been tested
OC_LDAP_DISABLE_USER_MECHANISM="none"
# If you bind-user is in lldap_strict_readonly set to false (this hides "forgot password"-buttons)
OC_LDAP_SERVER_WRITE_ENABLED="false"
# If your bind-user can change passwords:
OC_LDAP_SERVER_WRITE_ENABLED="true" # Not tested, yet!
# Don't start built-in LDAP, because it's replaced by LLDAP
OC_EXCLUDE_RUN_SERVICES="idm"
```
There is currently no (documented) way to give an LDAP user (or group) admin rights in OpenCloud.
See also [the official LDAP documentation](https://github.com/opencloud-eu/opencloud/blob/main/devtools/deployments/opencloud_full/ldap.yml).
-10
View File
@@ -48,13 +48,3 @@ To integrate with LLDAP,
allow-invalid-certs = true allow-invalid-certs = true
enable = false enable = false
``` ```
## Email alias
If you want to enable [email aliases](https://stalw.art/docs/mta/inbound/rcpt/#catch-all-addresses), you have to create a new *User-defined attribute* under *User schema* of type string. Currently, LLDAP doesn't support multi-value filters. If you want multiple aliases, you will have to create multiple attributes (`mailAlias1`, `mailAlias2`, ..., `mailAliasN`), where `N` is the maximum number of aliases an account will have.
You also need to change your ldap filter for emails.
```toml
[directory.ldap.filter]
# Add one clause per alias attribute you created (example: mailAlias1..mailAlias3)
email = "(&(objectclass=person)(|(mail=?)(mailAlias1=?)(mailAlias2=?)(mailAlias3=?)))"
```
+1 -1
View File
@@ -30,7 +30,7 @@ Unique Identifier Attribute - entryUUID
- Group Object Filter - objectClass=groupOfUniqueNames - Group Object Filter - objectClass=groupOfUniqueNames
Member Attribute Member Attribute
uniqueMember member
Validate Attribute Validate Attribute
enter a user e-mail address who has been added in LLDAP , and click test configuration, test show be successful enter a user e-mail address who has been added in LLDAP , and click test configuration, test show be successful
+2 -2
View File
@@ -22,7 +22,7 @@
}; };
# MSRV from the project # MSRV from the project
rustVersion = "1.91.0"; rustVersion = "1.89.0";
# Rust toolchain with required components # Rust toolchain with required components
rustToolchain = pkgs.rust-bin.stable.${rustVersion}.default.override { rustToolchain = pkgs.rust-bin.stable.${rustVersion}.default.override {
@@ -159,4 +159,4 @@
}; };
}; };
}); });
} }
+18 -11
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "lldap_migration_tool" name = "lldap_migration_tool"
version = "0.4.3" version = "0.4.2"
description = "CLI migration tool to go from OpenLDAP to LLDAP" description = "CLI migration tool to go from OpenLDAP to LLDAP"
edition.workspace = true edition.workspace = true
authors.workspace = true authors.workspace = true
@@ -10,24 +10,31 @@ repository.workspace = true
rust-version.workspace = true rust-version.workspace = true
[dependencies] [dependencies]
anyhow = { workspace = true } anyhow = "*"
base64 = { workspace = true } base64 = "0.13"
ldap3 = { workspace = true } rand = "0.8"
rand = { workspace = true } requestty = "0.4.1"
requestty = "0.6" serde_json = "1"
serde = { workspace = true } smallvec = "*"
serde_json = { workspace = true }
smallvec = "1"
[dependencies.lldap_auth] [dependencies.lldap_auth]
path = "../crates/auth" path = "../crates/auth"
features = ["opaque_client"] features = ["opaque_client"]
[dependencies.graphql_client] [dependencies.graphql_client]
workspace = true
features = ["graphql_query_derive", "reqwest-rustls"] features = ["graphql_query_derive", "reqwest-rustls"]
default-features = false
version = "0.11"
[dependencies.reqwest] [dependencies.reqwest]
workspace = true version = "*"
default-features = false default-features = false
features = ["json", "blocking", "rustls-tls"] features = ["json", "blocking", "rustls-tls"]
[dependencies.ldap3]
version = "*"
default-features = false
features = ["sync", "tls-rustls-ring"]
[dependencies.serde]
workspace = true
+1 -3
View File
@@ -193,9 +193,7 @@ impl TryFrom<ResultEntry> for User {
display_name, display_name,
first_name, first_name,
last_name, last_name,
avatar: avatar.map(|avatar| { avatar: avatar.map(base64::encode),
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, avatar)
}),
attributes: None, attributes: None,
}, },
password, password,
+1 -1
View File
@@ -1,2 +1,2 @@
[toolchain] [toolchain]
channel = "1.91.0" channel = "1.89.0"
+124 -137
View File
@@ -1,33 +1,63 @@
schema { type AttributeValue {
query: Query name: String!
mutation: Mutation value: [String!]!
schema: AttributeSchema!
} }
enum AttributeType { type Mutation {
STRING createUser(user: CreateUserInput!): User!
INTEGER createGroup(name: String!): Group!
JPEG_PHOTO createGroupWithDetails(request: CreateGroupInput!): Group!
DATE_TIME updateUser(user: UpdateUserInput!): Success!
updateGroup(group: UpdateGroupInput!): Success!
addUserToGroup(userId: String!, groupId: Int!): Success!
removeUserFromGroup(userId: String!, groupId: Int!): Success!
deleteUser(userId: String!): Success!
deleteGroup(groupId: Int!): Success!
addUserAttribute(name: String!, attributeType: AttributeType!, isList: Boolean!, isVisible: Boolean!, isEditable: Boolean!): Success!
addGroupAttribute(name: String!, attributeType: AttributeType!, isList: Boolean!, isVisible: Boolean!, isEditable: Boolean!): Success!
deleteUserAttribute(name: String!): Success!
deleteGroupAttribute(name: String!): Success!
addUserObjectClass(name: String!): Success!
addGroupObjectClass(name: String!): Success!
deleteUserObjectClass(name: String!): Success!
deleteGroupObjectClass(name: String!): Success!
} }
input AttributeValueInput { type Group {
""" id: Int!
The name of the attribute. It must be present in the schema, and the type informs how
to interpret the values.
""" name: String!
"""
The values of the attribute.
If the attribute is not a list, the vector must contain exactly one element.
Integers (signed 64 bits) are represented as strings.
Dates are represented as strings in RFC3339 format, e.g. "2019-10-12T07:20:50.52Z".
JpegPhotos are represented as base64 encoded strings. They must be valid JPEGs.
""" value: [String!]!
}
"The details required to create a group."
input CreateGroupInput {
displayName: String! displayName: String!
"User-defined attributes." attributes: [AttributeValueInput!] creationDate: DateTimeUtc!
uuid: String!
"User-defined attributes."
attributes: [AttributeValue!]!
"The groups to which this user belongs."
users: [User!]!
}
"""
A filter for requests, specifying a boolean expression based on field constraints. Only one of
the fields can be set at a time.
"""
input RequestFilter {
any: [RequestFilter!]
all: [RequestFilter!]
not: RequestFilter
eq: EqualityConstraint
memberOf: String
memberOfId: Int
}
"DateTime"
scalar DateTimeUtc
type Query {
apiVersion: String!
user(userId: String!): User!
users(filters: RequestFilter): [User!]!
groups: [Group!]!
group(groupId: Int!): Group!
schema: Schema!
} }
"The details required to create a user." "The details required to create a user."
@@ -50,36 +80,19 @@ input CreateUserInput {
"Attributes." attributes: [AttributeValueInput!] "Attributes." attributes: [AttributeValueInput!]
} }
input EqualityConstraint { type ObjectClassInfo {
field: String! objectClass: String!
value: String! isHardcoded: Boolean!
} }
""" type AttributeSchema {
A filter for requests, specifying a boolean expression based on field constraints. Only one of name: String!
the fields can be set at a time. attributeType: AttributeType!
""" isList: Boolean!
input RequestFilter { isVisible: Boolean!
any: [RequestFilter!] isEditable: Boolean!
all: [RequestFilter!] isHardcoded: Boolean!
not: RequestFilter isReadonly: Boolean!
eq: EqualityConstraint
memberOf: String
memberOfId: Int
}
"The fields that can be updated for a group."
input UpdateGroupInput {
"The group ID." id: Int!
"The new display name." displayName: String
"""
Attribute names to remove.
They are processed before insertions.
""" removeAttributes: [String!]
"""
Inserts or updates the given attributes.
For lists, the entire list must be provided.
""" insertAttributes: [AttributeValueInput!]
} }
"The fields that can be updated for a user." "The fields that can be updated for a user."
@@ -109,87 +122,9 @@ input UpdateUserInput {
""" insertAttributes: [AttributeValueInput!] """ insertAttributes: [AttributeValueInput!]
} }
""" input EqualityConstraint {
Combined date and time (with time zone) in [RFC 3339][0] format. field: String!
value: String!
Represents a description of an exact instant on the time-line (such as the
instant that a user account was created).
[`DateTime` scalar][1] compliant.
See also [`chrono::DateTime`][2] for details.
[0]: https://datatracker.ietf.org/doc/html/rfc3339#section-5
[1]: https://graphql-scalars.dev/docs/scalars/date-time
[2]: https://docs.rs/chrono/latest/chrono/struct.DateTime.html
"""
scalar DateTime
type AttributeList {
attributes: [AttributeSchema!]!
extraLdapObjectClasses: [String!]!
ldapObjectClasses: [ObjectClassInfo!]!
}
type AttributeSchema {
name: String!
attributeType: AttributeType!
isList: Boolean!
isVisible: Boolean!
isEditable: Boolean!
isHardcoded: Boolean!
isReadonly: Boolean!
}
type AttributeValue {
name: String!
value: [String!]!
schema: AttributeSchema!
}
type Group {
id: Int!
displayName: String!
creationDate: DateTime!
uuid: String!
"User-defined attributes."
attributes: [AttributeValue!]!
"The groups to which this user belongs."
users: [User!]!
}
type Mutation {
createUser(user: CreateUserInput!): User!
createGroup(name: String!): Group!
createGroupWithDetails(request: CreateGroupInput!): Group!
updateUser(user: UpdateUserInput!): Success!
updateGroup(group: UpdateGroupInput!): Success!
addUserToGroup(userId: String!, groupId: Int!): Success!
removeUserFromGroup(userId: String!, groupId: Int!): Success!
deleteUser(userId: String!): Success!
deleteGroup(groupId: Int!): Success!
addUserAttribute(name: String!, attributeType: AttributeType!, isList: Boolean!, isVisible: Boolean!, isEditable: Boolean!): Success!
addGroupAttribute(name: String!, attributeType: AttributeType!, isList: Boolean!, isVisible: Boolean!, isEditable: Boolean!): Success!
deleteUserAttribute(name: String!): Success!
deleteGroupAttribute(name: String!): Success!
addUserObjectClass(name: String!): Success!
addGroupObjectClass(name: String!): Success!
deleteUserObjectClass(name: String!): Success!
deleteGroupObjectClass(name: String!): Success!
}
type ObjectClassInfo {
objectClass: String!
isHardcoded: Boolean!
}
type Query {
apiVersion: String!
user(userId: String!): User!
users(filters: RequestFilter): [User!]!
groups: [Group!]!
group(groupId: Int!): Group!
schema: Schema!
} }
type Schema { type Schema {
@@ -197,8 +132,38 @@ type Schema {
groupSchema: AttributeList! groupSchema: AttributeList!
} }
type Success { "The fields that can be updated for a group."
ok: Boolean! input UpdateGroupInput {
"The group ID." id: Int!
"The new display name." displayName: String
"""
Attribute names to remove.
They are processed before insertions.
""" removeAttributes: [String!]
"""
Inserts or updates the given attributes.
For lists, the entire list must be provided.
""" insertAttributes: [AttributeValueInput!]
}
input AttributeValueInput {
"""
The name of the attribute. It must be present in the schema, and the type informs how
to interpret the values.
""" name: String!
"""
The values of the attribute.
If the attribute is not a list, the vector must contain exactly one element.
Integers (signed 64 bits) are represented as strings.
Dates are represented as strings in RFC3339 format, e.g. "2019-10-12T07:20:50.52Z".
JpegPhotos are represented as base64 encoded strings. They must be valid JPEGs.
""" value: [String!]!
}
"The details required to create a group."
input CreateGroupInput {
displayName: String!
"User-defined attributes." attributes: [AttributeValueInput!]
} }
type User { type User {
@@ -208,10 +173,32 @@ type User {
firstName: String! firstName: String!
lastName: String! lastName: String!
avatar: String avatar: String
creationDate: DateTime! creationDate: DateTimeUtc!
uuid: String! uuid: String!
"User-defined attributes." "User-defined attributes."
attributes: [AttributeValue!]! attributes: [AttributeValue!]!
"The groups to which this user belongs." "The groups to which this user belongs."
groups: [Group!]! groups: [Group!]!
} }
enum AttributeType {
STRING
INTEGER
JPEG_PHOTO
DATE_TIME
}
type AttributeList {
attributes: [AttributeSchema!]!
extraLdapObjectClasses: [String!]!
ldapObjectClasses: [ObjectClassInfo!]!
}
type Success {
ok: Boolean!
}
schema {
query: Query
mutation: Mutation
}
+2 -2
View File
@@ -712,9 +712,9 @@ main() {
redundant_users="$(printf '%s' "$redundant_users" | jq --compact-output --arg id "$id" '. - [$id]')" redundant_users="$(printf '%s' "$redundant_users" | jq --compact-output --arg id "$id" '. - [$id]')"
if [[ "$password_file" != 'null' ]] && [[ "$password_file" != '""' ]]; then if [[ "$password_file" != 'null' ]] && [[ "$password_file" != '""' ]]; then
LLDAP_USER_PASSWORD="$(< "$password_file")" "$LLDAP_SET_PASSWORD_PATH" --base-url "$LLDAP_URL" --token "$TOKEN" --username "$id" "$LLDAP_SET_PASSWORD_PATH" --base-url "$LLDAP_URL" --token "$TOKEN" --username "$id" --password "$(cat $password_file)"
elif [[ "$password" != 'null' ]] && [[ "$password" != '""' ]]; then elif [[ "$password" != 'null' ]] && [[ "$password" != '""' ]]; then
LLDAP_USER_PASSWORD="$password" "$LLDAP_SET_PASSWORD_PATH" --base-url "$LLDAP_URL" --token "$TOKEN" --username "$id" "$LLDAP_SET_PASSWORD_PATH" --base-url "$LLDAP_URL" --token "$TOKEN" --username "$id" --password "$password"
fi fi
# Process custom attributes # Process custom attributes
+96 -43
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "lldap" name = "lldap"
version = "0.6.3" version = "0.6.2"
description = "Super-simple and lightweight LDAP server" description = "Super-simple and lightweight LDAP server"
categories = ["authentication", "command-line-utilities"] categories = ["authentication", "command-line-utilities"]
edition.workspace = true edition.workspace = true
@@ -19,46 +19,35 @@ actix-rt = "2"
actix-server = "2" actix-server = "2"
actix-service = "2" actix-service = "2"
actix-web-httpauth = "0.8" actix-web-httpauth = "0.8"
anyhow = { workspace = true } anyhow = "*"
async-trait = { workspace = true } async-trait = "0.1"
bincode = { workspace = true } bincode = "1.3"
chrono = { workspace = true } cron = "*"
clap = { workspace = true } derive_builder = "0.12"
cron = "0"
derive_more = { workspace = true }
derive_builder = "0.20"
figment_file_provider_adapter = "0.1" figment_file_provider_adapter = "0.1"
futures = "0" futures = "*"
futures-util = "0" futures-util = "*"
hmac = "0.12" hmac = "0.12"
juniper = { workspace = true } http = "*"
jwt = { workspace = true } juniper = "0.15"
ldap3_proto = { workspace = true } jwt = "0.16"
log = { workspace = true } ldap3_proto = "0.6.0"
opaque-ke = { workspace = true } log = "*"
rand = { workspace = true }
rand_chacha = "0.3" rand_chacha = "0.3"
rustls-pemfile = "2" rustls-pemfile = "2"
sea-orm = { workspace = true } serde_json = "1"
secstr = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = "0.10" sha2 = "0.10"
strum = { workspace = true } thiserror = "2"
thiserror = { workspace = true }
time = "0.3" time = "0.3"
tokio = { workspace = true }
tokio-rustls = "0.26" tokio-rustls = "0.26"
tokio-stream = "0" tokio-stream = "*"
tokio-util = "0.7" tokio-util = "0.7"
tracing = { workspace = true } tracing = "*"
tracing-actix-web = "0.7" tracing-actix-web = "0.7"
tracing-attributes = "^0.1.21" tracing-attributes = "^0.1.21"
tracing-log = "0" tracing-log = "*"
tracing-subscriber = { workspace = true } urlencoding = "2"
urlencoding = { workspace = true } webpki-roots = "0.26"
uuid = { workspace = true }
webpki-roots = "1"
[dependencies.actix-web] [dependencies.actix-web]
features = ["rustls-0_23"] features = ["rustls-0_23"]
@@ -73,9 +62,26 @@ version = "0.23"
features = ["std"] features = ["std"]
version = "1" version = "1"
[dependencies.chrono]
features = ["serde"]
version = "*"
[dependencies.clap]
features = ["std", "color", "suggestions", "derive", "env"]
version = "4"
[dependencies.derive_more]
features = ["debug", "display", "from", "from_str"]
default-features = false
version = "1"
[dependencies.figment] [dependencies.figment]
features = ["env", "toml"] features = ["env", "toml"]
version = "0" version = "*"
[dependencies.tracing-subscriber]
version = "0.3"
features = ["env-filter", "tracing-log"]
[dependencies.lettre] [dependencies.lettre]
features = ["builder", "serde", "smtp-transport", "tokio1-rustls-tls"] features = ["builder", "serde", "smtp-transport", "tokio1-rustls-tls"]
@@ -120,16 +126,52 @@ path = "../crates/opaque-handler"
[dependencies.lldap_validation] [dependencies.lldap_validation]
path = "../crates/validation" path = "../crates/validation"
[dependencies.opaque-ke]
version = "0.7"
[dependencies.rand]
features = ["small_rng", "getrandom"]
version = "0.8"
[dependencies.secstr]
features = ["serde"]
version = "*"
[dependencies.serde]
workspace = true
[dependencies.strum]
features = ["derive"]
version = "0.25"
[dependencies.tokio]
features = ["full"]
version = "1.25"
[dependencies.uuid]
features = ["v1", "v3", "v4"]
version = "1"
[dependencies.tracing-forest] [dependencies.tracing-forest]
features = ["smallvec", "chrono", "tokio"] features = ["smallvec", "chrono", "tokio"]
version = "0.3" version = "^0.1.6"
[dependencies.actix-tls] [dependencies.actix-tls]
features = ["default", "rustls-0_23"] features = ["default", "rustls-0_23"]
version = "3" version = "3"
[dependencies.reqwest] [dependencies.sea-orm]
workspace = true workspace = true
features = [
"macros",
"with-chrono",
"with-uuid",
"sqlx-all",
"runtime-actix-rustls",
]
[dependencies.reqwest]
version = "0.11"
default-features = false default-features = false
features = ["rustls-tls-webpki-roots"] features = ["rustls-tls-webpki-roots"]
@@ -138,13 +180,20 @@ version = "2"
features = ["serde"] features = ["serde"]
[dev-dependencies] [dev-dependencies]
assert_cmd = "2" assert_cmd = "2.0"
graphql_client = { workspace = true, features = ["graphql_query_derive", "reqwest-rustls"] } mockall = "0.11.4"
ldap3 = { workspace = true } nix = "0.26.2"
mockall = { workspace = true } pretty_assertions = "1"
nix = { version = "0.31", features = ["process", "signal"] }
pretty_assertions = { workspace = true } [dev-dependencies.graphql_client]
uuid = { workspace = true } features = ["graphql_query_derive", "reqwest-rustls"]
default-features = false
version = "0.11"
[dev-dependencies.ldap3]
version = "*"
default-features = false
features = ["sync", "tls-rustls-ring"]
[dev-dependencies.lldap_auth] [dev-dependencies.lldap_auth]
path = "../crates/auth" path = "../crates/auth"
@@ -162,7 +211,7 @@ path = "../crates/sql-backend-handler"
features = ["test"] features = ["test"]
[dev-dependencies.reqwest] [dev-dependencies.reqwest]
workspace = true version = "*"
default-features = false default-features = false
features = ["json", "blocking", "rustls-tls"] features = ["json", "blocking", "rustls-tls"]
@@ -171,6 +220,10 @@ version = "2.0.0"
default-features = false default-features = false
features = ["file_locks"] features = ["file_locks"]
[dev-dependencies.uuid]
version = "1"
features = ["v4"]
[dev-dependencies.figment] [dev-dependencies.figment]
features = ["test"] features = ["test"]
version = "0" version = "*"
+3 -3
View File
@@ -135,7 +135,7 @@ pub struct RunOpts {
#[clap(long, env = "LLDAP_SERVER_KEY_SEED")] #[clap(long, env = "LLDAP_SERVER_KEY_SEED")]
pub server_key_seed: Option<String>, pub server_key_seed: Option<String>,
/// Change ldap host. Default: "::" /// Change ldap host. Default: "0.0.0.0"
#[clap(long, env = "LLDAP_LDAP_HOST")] #[clap(long, env = "LLDAP_LDAP_HOST")]
pub ldap_host: Option<String>, pub ldap_host: Option<String>,
@@ -143,7 +143,7 @@ pub struct RunOpts {
#[clap(long, env = "LLDAP_LDAP_PORT")] #[clap(long, env = "LLDAP_LDAP_PORT")]
pub ldap_port: Option<u16>, pub ldap_port: Option<u16>,
/// Change HTTP API host. Default: "::" /// Change HTTP API host. Default: "0.0.0.0"
#[clap(long, env = "LLDAP_HTTP_HOST")] #[clap(long, env = "LLDAP_HTTP_HOST")]
pub http_host: Option<String>, pub http_host: Option<String>,
@@ -151,7 +151,7 @@ pub struct RunOpts {
#[clap(long, env = "LLDAP_HTTP_PORT")] #[clap(long, env = "LLDAP_HTTP_PORT")]
pub http_port: Option<u16>, pub http_port: Option<u16>,
/// URL of the server, for password reset links. Default: "http://localhost" /// URL of the server, for password reset links.
#[clap(long, env = "LLDAP_HTTP_URL")] #[clap(long, env = "LLDAP_HTTP_URL")]
pub http_url: Option<Url>, pub http_url: Option<Url>,
+2 -2
View File
@@ -105,11 +105,11 @@ pub struct HttpUrl(pub Url);
#[derive(Clone, Deserialize, Serialize, derive_builder::Builder, derive_more::Debug)] #[derive(Clone, Deserialize, Serialize, derive_builder::Builder, derive_more::Debug)]
#[builder(pattern = "owned", build_fn(name = "private_build"))] #[builder(pattern = "owned", build_fn(name = "private_build"))]
pub struct Configuration { pub struct Configuration {
#[builder(default = r#"String::from("::")"#)] #[builder(default = r#"String::from("0.0.0.0")"#)]
pub ldap_host: String, pub ldap_host: String,
#[builder(default = "3890")] #[builder(default = "3890")]
pub ldap_port: u16, pub ldap_port: u16,
#[builder(default = r#"String::from("::")"#)] #[builder(default = r#"String::from("0.0.0.0")"#)]
pub http_host: String, pub http_host: String,
#[builder(default = "17170")] #[builder(default = "17170")]
pub http_port: u16, pub http_port: u16,
+2 -2
View File
@@ -52,7 +52,7 @@ where
/// Actix GraphQL Handler for GET requests /// Actix GraphQL Handler for GET requests
pub async fn get_graphql_handler<Query, Mutation, Subscription, CtxT, S>( pub async fn get_graphql_handler<Query, Mutation, Subscription, CtxT, S>(
schema: &juniper::RootNode<Query, Mutation, Subscription, S>, schema: &juniper::RootNode<'static, Query, Mutation, Subscription, S>,
context: &CtxT, context: &CtxT,
req: HttpRequest, req: HttpRequest,
) -> Result<HttpResponse, Error> ) -> Result<HttpResponse, Error>
@@ -81,7 +81,7 @@ where
/// Actix GraphQL Handler for POST requests /// Actix GraphQL Handler for POST requests
pub async fn post_graphql_handler<Query, Mutation, Subscription, CtxT, S>( pub async fn post_graphql_handler<Query, Mutation, Subscription, CtxT, S>(
schema: &juniper::RootNode<Query, Mutation, Subscription, S>, schema: &juniper::RootNode<'static, Query, Mutation, Subscription, S>,
context: &CtxT, context: &CtxT,
req: HttpRequest, req: HttpRequest,
mut payload: actix_http::Payload, mut payload: actix_http::Payload,
+1 -2
View File
@@ -4,8 +4,7 @@ use anyhow::{Context, Result, anyhow};
use graphql_client::GraphQLQuery; use graphql_client::GraphQLQuery;
use reqwest::blocking::Client; use reqwest::blocking::Client;
pub type DateTime = chrono::DateTime<chrono::Utc>; pub type DateTimeUtc = chrono::DateTime<chrono::Utc>;
pub type DateTimeUtc = DateTime;
#[derive(GraphQLQuery)] #[derive(GraphQLQuery)]
#[graphql( #[graphql(
+12 -7
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "lldap_set_password" name = "lldap_set_password"
version = "0.1.1" version = "0.1.0"
description = "CLI tool to set a user password in LLDAP" description = "CLI tool to set a user password in LLDAP"
edition.workspace = true edition.workspace = true
authors.workspace = true authors.workspace = true
@@ -12,17 +12,22 @@ rust-version.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
anyhow = { workspace = true } anyhow = "*"
rand = { workspace = true } rand = "0.8"
serde = { workspace = true } serde_json = "1"
serde_json = { workspace = true }
clap = { workspace = true } [dependencies.clap]
features = ["std", "color", "suggestions", "derive", "env"]
version = "4"
[dependencies.lldap_auth] [dependencies.lldap_auth]
path = "../crates/auth" path = "../crates/auth"
features = ["opaque_client"] features = ["opaque_client"]
[dependencies.reqwest] [dependencies.reqwest]
workspace = true version = "*"
default-features = false
features = ["json", "blocking", "rustls-tls", "rustls-tls-native-roots"] features = ["json", "blocking", "rustls-tls", "rustls-tls-native-roots"]
[dependencies.serde]
workspace = true