Compare commits

...

34 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
dbc85d9594 server: reject LDAP equality filters on list custom attributes with UnwillingToPerform error 2026-05-27 00:06:55 +02:00
Xre0uS
e567d17062 fix: percent-decode user_id route params for users with spaces in ID (#585) 2026-05-26 23:42:08 +02:00
Valentin Tolmer
d293941a44 chore: fmt and clippy 2026-05-26 23:37:26 +02:00
aokblast
203169fe4a server: Bind to :: by default
Healthcheck connects to `localhost`, which may resolve to either IPv4 or
Ipv6 depending on the platform. However, the server was previously bound
to 0.0.0.0, limiting it to IPv4-only.

Switching to :: allows the server to listen on both IPv6 and IPv4 when
the socket is not restricted with IPV6_V6ONLY, as described in RFC 3493.

This improves cross-platform comaptibility and fixes "cargo test"
failures in the healthcheck on FreeBSD.
2026-05-26 12:48:32 +02:00
Matt Van Horn
3bf9ea5206 server: prevent attributes with conflicting types across users/groups (#1426)
Before creating a user attribute, check if a group attribute with
the same name exists with a different type (and vice versa). Return
an error if the types conflict, as LDAP requires each attribute
name to have a single associated type (RFC 4512).

Partial fix for #1202

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-05-26 00:28:34 +02:00
René Neumann
ddd6b469f2 bootstrap: Reintroduce LLDAP_USER_PASSWORD
Ensure no passwords are leaked via cmdline.
2026-05-26 00:05:07 +02:00
Reefsoft
36f10a9947 Fixed a port numbering issue 2026-05-26 00:00:02 +02:00
Reefsoft
fc1731fad6 Added Gogs to example config readme 2026-05-26 00:00:02 +02:00
Reefsoft
f949575a01 Fix style again 2026-05-26 00:00:02 +02:00
Reefsoft
35d0d8005d Fix style issues 2026-05-26 00:00:02 +02:00
Reefsoft
92d1d83282 Fixed the same nitpicks again 2026-05-26 00:00:02 +02:00
Reefsoft
29b2411c6d Fix a port issue and a Bind DN issue 2026-05-26 00:00:02 +02:00
Reefsoft
e96e4c4adf Fixed a problem regarding User DN in SimpleAuth method 2026-05-26 00:00:02 +02:00
Reefsoft
2cd33c7215 Apply patches proposed by CodeRabbit 2026-05-26 00:00:02 +02:00
Reefsoft
dfe0773549 Add Simple Auth to Gogs example config 2026-05-26 00:00:02 +02:00
Tim Beermann
52a08d3ad9 doc: added LLDAP_HTTP_URL env variable for SMTP settings
Signed-off-by: Tim Beermann <tibeer@berryit.de>
2026-05-25 23:57:40 +02:00
Valentin Tolmer
2dc6178bd0 chore: bump MSRV to 1.91
Otherwise cargo install wasm-pack doesn't work
2026-05-25 23:45:46 +02:00
Kieran
ed7484bffb Added more details to licence warning 2026-05-25 23:45:14 +02:00
Kieran
68fc426ba3 Added example configuration for Elasticsearch + Kibanna 2026-05-25 23:45:14 +02:00
Glujaz
a3d4eb04be Update udm_identity_end_point.md
Replaced Member by uniqueMember in Member Attribute. Otherwise the groups are not detected, and the rules are not applied.
2026-05-25 23:41:32 +02:00
VisableSampling
dc883a060a Update Alpine release image to 3.22 2026-05-05 02:39:41 +02:00
Valentin Tolmer
82b16a3716 changelog: fix security advisory description 2026-05-01 00:51:22 +02:00
Valentin Tolmer
48a0a8d961 release: v0.6.3
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-01 00:45:35 +02:00
Valentin Tolmer
49dc766184 chore: pin CI wasm-pack install to lockfile
Otherwise cargo install wasm-pack fails in CI
2026-05-01 00:45:35 +02:00
Valentin Tolmer
2b3dbb46de chore: upgrade Juniper to 0.17 2026-05-01 00:45:35 +02:00
Valentin Tolmer
40121b80b7 chore: centralize and upgrade shared Cargo dependencies
Move duplicated Cargo dependencies to the root workspace manifest, switch member crates to workspace dependencies, and align non-conflicting shared feature sets at the workspace level.

Upgrade a number of shared dependencies while consolidating versions across the workspace. This also consolidates the ldap3 dependency in response to GHSA-qcxq-75wr-5cm8: https://github.com/kanidm/ldap3/security/advisories/GHSA-qcxq-75wr-5cm8

Update frontend and migration code for dependency upgrades and clean up manifest structure.
2026-05-01 00:45:35 +02:00
fredericrous
b8465212b5 fix(sql-backend-handler): enable lldap_domain "test" feature in dev-deps
lldap_domain::JpegPhoto::for_tests() and uuid helpers are gated behind
the "test" feature on the lldap_domain crate. The sql-backend-handler
dev-deps did not enable that feature, causing ~12 compilation errors in
sql_user_backend_handler.rs and sql_tables.rs when building the test
binary. This unblocks cargo test -p lldap_sql_backend_handler --lib.
2026-04-30 23:44:29 +02:00
Hannes Hauswedell
bb2ea7bf36 doc: add opencloud.md 2026-03-29 10:19:27 +02:00
Federico Scodelaro
9fb252759a chore: Better example config 2026-03-24 07:55:28 +01:00
Federico Scodelaro
3a26d2ec4c Update example_configs/stalwart.md
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-24 07:55:28 +01:00
Federico Scodelaro
86d9ea10d6 docs(stalwart): Add alias example 2026-03-24 07:55:28 +01:00
dependabot[bot]
2ad634deda build(deps): bump docker/setup-qemu-action from 3 to 4
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-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 22:13:24 +01:00
dependabot[bot]
155bda6bbf build(deps): bump actions/download-artifact from 7 to 8
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 7 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v7...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-27 22:01:45 +01:00
dependabot[bot]
7d1593e266 build(deps): bump actions/upload-artifact from 6 to 7
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-27 18:48:41 +01:00
57 changed files with 2119 additions and 2050 deletions

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 \
&& chmod a+r -R .
FROM alpine:3.19
FROM alpine:3.22
WORKDIR /app
ENV UID=1000
ENV GID=1000

View File

@@ -1,5 +1,5 @@
# Keep tracking base image
FROM rust:1.89-slim-bookworm
FROM rust:1.91-slim-bookworm
# 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"

View File

@@ -24,7 +24,7 @@ on:
env:
CARGO_TERM_COLOR: always
MSRV: "1.89.0"
MSRV: "1.91.0"
### CI Docs
@@ -106,7 +106,7 @@ jobs:
restore-keys: |
lldap-ui-
- name: Install wasm-pack with cargo
run: cargo install wasm-pack || true
run: cargo install --locked wasm-pack || true
env:
RUSTFLAGS: ""
- name: Build frontend
@@ -114,7 +114,7 @@ jobs:
- name: Check build path
run: ls -al app/
- name: Upload ui artifacts
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: ui
path: app/
@@ -159,17 +159,17 @@ jobs:
- name: Check path
run: ls -al target/release
- name: Upload ${{ matrix.target}} lldap artifacts
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.target}}-lldap-bin
path: target/${{ matrix.target }}/release/lldap
- name: Upload ${{ matrix.target }} migration tool artifacts
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.target }}-lldap_migration_tool-bin
path: target/${{ matrix.target }}/release/lldap_migration_tool
- name: Upload ${{ matrix.target }} password tool artifacts
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.target }}-lldap_set_password-bin
path: target/${{ matrix.target }}/release/lldap_set_password
@@ -209,7 +209,7 @@ jobs:
steps:
- name: Download artifacts
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: x86_64-unknown-linux-musl-lldap-bin
path: bin/
@@ -315,13 +315,13 @@ jobs:
sparse-checkout: 'scripts'
- name: Download LLDAP artifacts
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: x86_64-unknown-linux-musl-lldap-bin
path: bin/
- name: Download LLDAP set password
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: x86_64-unknown-linux-musl-lldap_set_password-bin
path: bin/
@@ -509,18 +509,18 @@ jobs:
uses: actions/checkout@v6.0.2
- name: Download all artifacts
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
path: bin
- name: Download llap ui artifacts
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: ui
path: web
- name: Setup QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Setup buildx
uses: docker/setup-buildx-action@v3
with:
@@ -691,7 +691,7 @@ jobs:
contents: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
path: bin/
- name: Check file
@@ -712,7 +712,7 @@ jobs:
chmod +x bin/*-lldap_set_password
- name: Download llap ui artifacts
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: ui
path: web

View File

@@ -8,7 +8,7 @@ on:
env:
CARGO_TERM_COLOR: always
MSRV: "1.89.0"
MSRV: "1.91.0"
jobs:
pre_job:

View File

@@ -5,6 +5,55 @@ 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/),
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
Small release, focused on LDAP improvements and ongoing maintenance.

2519
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,7 @@ edition = "2024"
homepage = "https://github.com/lldap/lldap"
license = "GPL-3.0-only"
repository = "https://github.com/lldap/lldap"
rust-version = "1.89.0"
rust-version = "1.91.0"
[profile.release]
lto = true
@@ -24,9 +24,122 @@ lto = true
[profile.release.package.lldap_app]
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]
version = "1.1.8"
default-features = false
features = ["macros", "with-chrono", "with-uuid", "sqlx-all", "runtime-actix-rustls"]
[workspace.dependencies.secstr]
version = "0"
features = ["serde"]
[workspace.dependencies.serde]
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"]

View File

@@ -1,6 +1,6 @@
[package]
name = "lldap_app"
version = "0.6.2"
version = "0.6.3"
description = "Frontend for LLDAP"
edition.workspace = true
include = ["src/**/*", "queries/**/*", "Cargo.toml", "../schema.graphql"]
@@ -11,27 +11,34 @@ repository.workspace = true
rust-version.workspace = true
[dependencies]
anyhow = "1"
base64 = "0.13"
gloo-console = "0.2.3"
gloo-file = "0.2.3"
gloo-net = "*"
graphql_client = "0.10"
http = "0.2"
jwt = "0.13"
rand = "0.8"
serde_json = "1"
anyhow = { workspace = true }
chrono = { workspace = true }
derive_more = { workspace = true }
gloo-console = "0.4"
gloo-file = "0.4"
gloo-net = "0.7"
graphql_client = { workspace = true }
image = { workspace = true }
jwt = { workspace = true }
rand = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
strum = { workspace = true }
percent-encoding = "2"
url-escape = "0.1.1"
validator = "0.14"
validator_derive = "0.14"
wasm-bindgen = "0.2.100"
wasm-bindgen-futures = "*"
wasm-bindgen-futures = "0"
yew = "0.19.3"
yew-router = "0.16"
# Needed because of https://github.com/tkaitchuck/aHash/issues/95
indexmap = "=1.6.2"
base64 = { workspace = true }
[dependencies.web-sys]
version = "0.3"
features = [
@@ -50,17 +57,6 @@ features = [
"console",
]
[dependencies.chrono]
version = "*"
features = [
"wasmbind"
]
[dependencies.derive_more]
features = ["debug", "display", "from", "from_str"]
default-features = false
version = "1"
[dependencies.lldap_auth]
path = "../crates/auth"
features = [ "opaque_client" ]
@@ -71,18 +67,6 @@ path = "../crates/frontend-options"
[dependencies.lldap_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]
git = "https://github.com/jfbilodeau/yew_form"
rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9"

View File

@@ -183,6 +183,16 @@ 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(
switch: &AppRoute,
link: &Scope<Self>,
@@ -248,11 +258,17 @@ impl App {
AppRoute::GroupDetails { group_id } => html! {
<GroupDetails group_id={*group_id} is_admin={is_admin} />
},
AppRoute::UserDetails { user_id } => html! {
<UserDetails username={user_id.clone()} is_admin={is_admin} />
AppRoute::UserDetails { user_id } => match Self::decode_user_id(user_id) {
Some(decoded_id) => html! {
<UserDetails username={decoded_id} is_admin={is_admin} />
},
None => html! { <Redirect to={AppRoute::Login} /> },
},
AppRoute::ChangePassword { user_id } => html! {
<ChangePasswordForm username={user_id.clone()} is_admin={is_admin} />
AppRoute::ChangePassword { user_id } => match Self::decode_user_id(user_id) {
Some(decoded_id) => html! {
<ChangePasswordForm username={decoded_id} is_admin={is_admin} />
},
None => html! { <Redirect to={AppRoute::Login} /> },
},
AppRoute::StartResetPassword => match password_reset_enabled {
Some(true) => html! { <ResetPasswordStep1Form /> },

View File

@@ -112,7 +112,7 @@ impl CommonComponent<CreateGroupForm> for CreateGroupForm {
let model = self.form.model();
let req = create_group::Variables {
group: create_group::CreateGroupInput {
displayName: model.groupname,
display_name: model.groupname,
attributes,
},
};

View File

@@ -143,9 +143,9 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
user: create_user::CreateUserInput {
id: model.username,
email: None,
displayName: None,
firstName: None,
lastName: None,
display_name: None,
first_name: None,
last_name: None,
avatar: None,
attributes,
},

View File

@@ -1,6 +1,7 @@
use std::{fmt::Display, str::FromStr};
use anyhow::{Error, Ok, Result, bail};
use base64::Engine;
use gloo_file::{
File,
callbacks::{FileReader, read_as_bytes},
@@ -54,12 +55,12 @@ fn to_base64(file: &JsFile) -> Result<String> {
if !is_valid_jpeg(data.as_slice()) {
bail!("Chosen image is not a valid JPEG");
}
Ok(base64::encode(data))
Ok(base64::engine::general_purpose::STANDARD.encode(data))
}
JsFile {
file: None,
contents: Some(data),
} => Ok(base64::encode(data)),
} => Ok(base64::engine::general_purpose::STANDARD.encode(data)),
}
}
@@ -98,7 +99,7 @@ impl Component for JpegFileInput {
.props()
.value
.as_ref()
.and_then(|x| base64::decode(x).ok()),
.and_then(|x| base64::engine::general_purpose::STANDARD.decode(x).ok()),
}),
reader: None,
}
@@ -111,7 +112,7 @@ impl Component for JpegFileInput {
.props()
.value
.as_ref()
.and_then(|x| base64::decode(x).ok()),
.and_then(|x| base64::engine::general_purpose::STANDARD.decode(x).ok()),
});
self.reader = None;
true
@@ -230,7 +231,7 @@ impl JpegFileInput {
}
fn is_valid_jpeg(bytes: &[u8]) -> bool {
image::io::Reader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg)
image::ImageReader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg)
.decode()
.is_ok()
}

View File

@@ -248,13 +248,13 @@ impl GroupDetailsForm {
};
let mut group_input = update_group::UpdateGroupInput {
id: self.group.id,
displayName: None,
removeAttributes: None,
insertAttributes: None,
display_name: None,
remove_attributes: None,
insert_attributes: None,
};
let default_group_input = group_input.clone();
group_input.removeAttributes = remove_attributes;
group_input.insertAttributes = insert_attributes;
group_input.remove_attributes = remove_attributes;
group_input.insert_attributes = insert_attributes;
// Nothing changed.
if group_input == default_group_input {
return Ok(false);

View File

@@ -260,16 +260,16 @@ impl UserDetailsForm {
let mut user_input = update_user::UpdateUserInput {
id: self.user.id.clone(),
email: None,
displayName: None,
firstName: None,
lastName: None,
display_name: None,
first_name: None,
last_name: None,
avatar: None,
removeAttributes: None,
insertAttributes: None,
remove_attributes: None,
insert_attributes: None,
};
let default_user_input = user_input.clone();
user_input.removeAttributes = remove_attributes;
user_input.insertAttributes = insert_attributes;
user_input.remove_attributes = remove_attributes;
user_input.insert_attributes = insert_attributes;
// Nothing changed.
if user_input == default_user_input {
return Ok(false);

View File

@@ -94,7 +94,7 @@ impl HostService {
where
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(|| {
anyhow!(
"Errors: [{}]",

View File

@@ -1 +1,2 @@
pub type DateTimeUtc = chrono::DateTime<chrono::Utc>;
pub type DateTime = chrono::DateTime<chrono::Utc>;
pub type DateTimeUtc = DateTime;

View File

@@ -1,6 +1,6 @@
[package]
name = "lldap_access_control"
version = "0.1.0"
version = "0.1.1"
description = "Access control wrappers for LLDAP"
authors.workspace = true
edition.workspace = true
@@ -10,8 +10,8 @@ repository.workspace = true
rust-version.workspace = true
[dependencies]
tracing = "*"
async-trait = "0.1"
tracing = { workspace = true }
async-trait = { workspace = true }
[dependencies.lldap_auth]
path = "../auth"

View File

@@ -1,6 +1,6 @@
[package]
name = "lldap_auth"
version = "0.6.0"
version = "0.6.3"
description = "Authentication protocol for LLDAP"
edition.workspace = true
authors.workspace = true
@@ -18,35 +18,23 @@ sea_orm = ["dep:sea-orm"]
test = []
[dependencies]
chrono = { workspace = true }
derive_more = { workspace = true }
opaque-ke = { workspace = true }
serde = { workspace = true }
rust-argon2 = "2"
curve25519-dalek = "3"
digest = "0.9"
generic-array = "0.14"
rand = "0.8"
rand = { workspace = true }
sha2 = "0.9"
thiserror = "2"
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"]
thiserror = { workspace = true }
uuid = { workspace = true, features = ["serde"] }
[dependencies.sea-orm]
workspace = true
features = ["macros"]
optional = true
[dependencies.serde]
workspace = true
# For WASM targets, use the JS getrandom.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.getrandom]
version = "0.2"

View File

@@ -1,6 +1,6 @@
[package]
name = "lldap_domain_handlers"
version = "0.1.0"
version = "0.1.1"
edition.workspace = true
authors.workspace = true
homepage.workspace = true
@@ -12,22 +12,17 @@ rust-version.workspace = true
test = []
[dependencies]
async-trait = "0.1"
base64 = "0.21"
ldap3_proto = "0.6.0"
serde_bytes = "0.11"
async-trait = { workspace = true }
base64 = { workspace = true }
chrono = { workspace = true }
derive_more = { workspace = true }
ldap3_proto = { workspace = true }
serde = { workspace = true }
serde_bytes = { workspace = true }
uuid = { workspace = true }
[dev-dependencies]
pretty_assertions = "1"
[dependencies.chrono]
features = ["serde"]
version = "0.4"
[dependencies.derive_more]
features = ["debug", "display", "from", "from_str"]
default-features = false
version = "1"
pretty_assertions = { workspace = true }
[dependencies.lldap_auth]
path = "../auth"
@@ -38,10 +33,3 @@ path = "../domain"
[dependencies.lldap_domain_model]
path = "../domain-model"
[dependencies.serde]
workspace = true
[dependencies.uuid]
features = ["v1", "v3"]
version = "1"

View File

@@ -1,6 +1,6 @@
[package]
name = "lldap_domain_model"
version = "0.1.0"
version = "0.1.1"
edition.workspace = true
authors.workspace = true
homepage.workspace = true
@@ -12,23 +12,19 @@ rust-version.workspace = true
test = []
[dependencies]
base64 = "0.21"
bincode = "1.3"
orion = "0.17"
serde_bytes = "0.11"
thiserror = "2"
base64 = { workspace = true }
bincode = { workspace = true }
chrono = { workspace = true }
derive_more = { workspace = true }
orion = { workspace = true }
sea-orm = { workspace = true }
serde = { workspace = true }
serde_bytes = { workspace = true }
thiserror = { workspace = true }
uuid = { workspace = true }
[dev-dependencies]
pretty_assertions = "1"
[dependencies.chrono]
features = ["serde"]
version = "0.4"
[dependencies.derive_more]
features = ["debug", "display", "from", "from_str"]
default-features = false
version = "1"
pretty_assertions = { workspace = true }
[dependencies.lldap_auth]
path = "../auth"
@@ -36,14 +32,3 @@ features = ["opaque_server", "opaque_client", "sea_orm"]
[dependencies.lldap_domain]
path = "../domain"
[dependencies.sea-orm]
workspace = true
features = ["macros"]
[dependencies.serde]
workspace = true
[dependencies.uuid]
features = ["v1", "v3"]
version = "1"

View File

@@ -1,6 +1,6 @@
[package]
name = "lldap_domain"
version = "0.1.0"
version = "0.1.1"
authors = [
"Valentin Tolmer <valentin@tolmer.fr>",
"Simon Broeng Jensen <sbj@cwconsult.dk>",
@@ -15,51 +15,23 @@ rust-version.workspace = true
test = []
[dependencies]
anyhow = "*"
base64 = "0.21"
bincode = "1.3"
itertools = "0.10"
juniper = "0.15"
serde_bytes = "0.11"
[dev-dependencies]
pretty_assertions = "1"
[dependencies.chrono]
features = ["serde"]
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"
anyhow = { workspace = true }
base64 = { workspace = true }
bincode = { workspace = true }
chrono = { workspace = true }
derive_more = { workspace = true }
image = { workspace = true }
itertools = { workspace = true }
juniper = { workspace = true }
sea-orm = { workspace = true }
serde = { workspace = true }
serde_bytes = { workspace = true }
strum = { workspace = true }
uuid = { workspace = true }
[dependencies.lldap_auth]
path = "../auth"
features = ["opaque_server", "opaque_client", "sea_orm"]
[dependencies.sea-orm]
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"
[dev-dependencies]
pretty_assertions = { workspace = true }

View File

@@ -333,7 +333,7 @@ impl TryFrom<&[u8]> for JpegPhoto {
return Ok(JpegPhoto::null());
}
// Confirm that it's a valid Jpeg, then store only the bytes.
image::io::Reader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg)
image::ImageReader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg)
.decode()?;
Ok(JpegPhoto(bytes.to_vec()))
}
@@ -346,7 +346,7 @@ impl TryFrom<Vec<u8>> for JpegPhoto {
return Ok(JpegPhoto::null());
}
// Confirm that it's a valid Jpeg, then store only the bytes.
image::io::Reader::with_format(
image::ImageReader::with_format(
std::io::Cursor::new(bytes.as_slice()),
image::ImageFormat::Jpeg,
)
@@ -397,7 +397,7 @@ impl JpegPhoto {
#[cfg(any(feature = "test", test))]
pub fn for_tests() -> Self {
use image::{ImageOutputFormat, Rgb, RgbImage};
use image::{ImageFormat, Rgb, RgbImage};
let img = RgbImage::from_fn(32, 32, |x, y| {
if (x + y) % 2 == 0 {
Rgb([0, 0, 0])
@@ -406,11 +406,8 @@ impl JpegPhoto {
}
});
let mut bytes: Vec<u8> = Vec::new();
img.write_to(
&mut std::io::Cursor::new(&mut bytes),
ImageOutputFormat::Jpeg(0),
)
.unwrap();
img.write_to(&mut std::io::Cursor::new(&mut bytes), ImageFormat::Jpeg)
.unwrap();
Self(bytes)
}
}
@@ -688,7 +685,7 @@ mod tests {
);
assert_eq!(
&format!("{:?}", Serialized::from(&JpegPhoto::for_tests())),
"Serialized(\"hash: 0xB947C77A16F3C3BD\")"
"Serialized(\"hash: 0xBB3017828B2F3DEF\")"
);
}

View File

@@ -1,6 +1,6 @@
[package]
name = "lldap_frontend_options"
version = "0.1.0"
version = "0.1.1"
description = "Frontend options for LLDAP"
authors.workspace = true
edition.workspace = true
@@ -9,5 +9,5 @@ license.workspace = true
repository.workspace = true
rust-version.workspace = true
[dependencies.serde]
workspace = true
[dependencies]
serde = { workspace = true }

View File

@@ -1,6 +1,6 @@
[package]
name = "lldap_graphql_server"
version = "0.1.0"
version = "0.1.1"
description = "GraphQL server for LLDAP"
edition.workspace = true
authors.workspace = true
@@ -10,15 +10,14 @@ repository.workspace = true
rust-version.workspace = true
[dependencies]
anyhow = "*"
juniper = "0.15"
serde_json = "1"
tracing = "*"
urlencoding = "2"
[dependencies.chrono]
features = ["serde"]
version = "*"
anyhow = { workspace = true }
chrono = { workspace = true }
juniper = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
urlencoding = { workspace = true }
uuid = { workspace = true }
[dependencies.lldap_access_control]
path = "../access-control"
@@ -45,16 +44,10 @@ path = "../sql-backend-handler"
[dependencies.lldap_validation]
path = "../validation"
[dependencies.serde]
workspace = true
[dependencies.uuid]
features = ["v1", "v3"]
version = "1"
[dev-dependencies]
mockall = "0.11.4"
pretty_assertions = "1"
mockall = { workspace = true }
pretty_assertions = { workspace = true }
tokio = { workspace = true }
#[dev-dependencies.lldap_auth]
#path = "../auth"
@@ -70,7 +63,3 @@ path = "../test-utils"
#[dev-dependencies.lldap_sql_backend_handler]
#path = "../sql-backend-handler"
#features = ["test"]
[dev-dependencies.tokio]
features = ["full"]
version = "1.25"

View File

@@ -60,7 +60,7 @@ impl<Handler: BackendHandler> Context<Handler> {
impl<Handler: BackendHandler> juniper::Context for Context<Handler> {}
type Schema<Handler> =
RootNode<'static, Query<Handler>, Mutation<Handler>, EmptySubscription<Context<Handler>>>;
RootNode<Query<Handler>, Mutation<Handler>, EmptySubscription<Context<Handler>>>;
pub fn schema<Handler: BackendHandler>() -> Schema<Handler> {
Schema::new(
@@ -73,7 +73,7 @@ pub fn schema<Handler: BackendHandler>() -> Schema<Handler> {
pub fn export_schema(output_file: Option<String>) -> anyhow::Result<()> {
use anyhow::Context;
use lldap_sql_backend_handler::SqlBackendHandler;
let output = schema::<SqlBackendHandler>().as_schema_language();
let output = schema::<SqlBackendHandler>().as_sdl();
match output_file {
None => println!("{output}"),
Some(path) => {

View File

@@ -561,13 +561,13 @@ mod tests {
use mockall::predicate::eq;
use pretty_assertions::assert_eq;
fn mutation_schema<'q, C, Q, M>(
fn mutation_schema<C, Q, M>(
query_root: Q,
mutation_root: M,
) -> RootNode<'q, Q, M, EmptySubscription<C>>
) -> RootNode<Q, M, EmptySubscription<C>>
where
Q: GraphQLType<DefaultScalarValue, Context = C, TypeInfo = ()> + 'q,
M: GraphQLType<DefaultScalarValue, Context = C, TypeInfo = ()> + 'q,
Q: GraphQLType<DefaultScalarValue, Context = C, TypeInfo = ()>,
M: GraphQLType<DefaultScalarValue, Context = C, TypeInfo = ()>,
{
RootNode::new(query_root, mutation_root, EmptySubscription::<C>::new())
}

View File

@@ -93,7 +93,7 @@ impl<Handler: BackendHandler> AttributeValue<Handler> {
}
}
pub(super) fn name(&self) -> &str {
pub(super) fn attribute_name(&self) -> &str {
self.attribute.name.as_str()
}
}

View File

@@ -47,7 +47,11 @@ impl<Handler: BackendHandler> Query<Handler> {
"1.0"
}
pub async fn user(context: &Context<Handler>, user_id: String) -> FieldResult<User<Handler>> {
pub async fn user(
&self,
context: &Context<Handler>,
user_id: String,
) -> FieldResult<User<Handler>> {
use anyhow::Context;
let span = debug_span!("[GraphQL query] user");
span.in_scope(|| {
@@ -61,14 +65,15 @@ impl<Handler: BackendHandler> Query<Handler> {
&span,
"Unauthorized access to user data",
))?;
let schema = Arc::new(self.get_schema(context, span.clone()).await?);
let schema: Arc<PublicSchema> = Arc::new(self.get_schema(context, span.clone()).await?);
let user = handler.get_user_details(&user_id).instrument(span).await?;
User::<Handler>::from_user(user, schema)
}
async fn users(
&self,
context: &Context<Handler>,
#[graphql(name = "where")] filters: Option<RequestFilter>,
filters: Option<RequestFilter>,
) -> FieldResult<Vec<User<Handler>>> {
let span = debug_span!("[GraphQL query] users");
span.in_scope(|| {
@@ -80,7 +85,7 @@ impl<Handler: BackendHandler> Query<Handler> {
&span,
"Unauthorized access to user list",
))?;
let schema = Arc::new(self.get_schema(context, span.clone()).await?);
let schema: Arc<PublicSchema> = Arc::new(self.get_schema(context, span.clone()).await?);
let users = handler
.list_users(
filters
@@ -96,7 +101,7 @@ impl<Handler: BackendHandler> Query<Handler> {
.collect()
}
async fn groups(context: &Context<Handler>) -> FieldResult<Vec<Group<Handler>>> {
async fn groups(&self, context: &Context<Handler>) -> FieldResult<Vec<Group<Handler>>> {
let span = debug_span!("[GraphQL query] groups");
let handler = context
.get_readonly_handler()
@@ -104,7 +109,7 @@ impl<Handler: BackendHandler> Query<Handler> {
&span,
"Unauthorized access to group list",
))?;
let schema = Arc::new(self.get_schema(context, span.clone()).await?);
let schema: Arc<PublicSchema> = Arc::new(self.get_schema(context, span.clone()).await?);
let domain_groups = handler.list_groups(None).instrument(span).await?;
domain_groups
.into_iter()
@@ -112,7 +117,11 @@ impl<Handler: BackendHandler> Query<Handler> {
.collect()
}
async fn group(context: &Context<Handler>, group_id: i32) -> FieldResult<Group<Handler>> {
async fn group(
&self,
context: &Context<Handler>,
group_id: i32,
) -> FieldResult<Group<Handler>> {
let span = debug_span!("[GraphQL query] group");
span.in_scope(|| {
debug!(?group_id);
@@ -123,7 +132,7 @@ impl<Handler: BackendHandler> Query<Handler> {
&span,
"Unauthorized access to group data",
))?;
let schema = Arc::new(self.get_schema(context, span.clone()).await?);
let schema: Arc<PublicSchema> = Arc::new(self.get_schema(context, span.clone()).await?);
let group_details = handler
.get_group_details(GroupId(group_id))
.instrument(span)
@@ -131,7 +140,7 @@ impl<Handler: BackendHandler> Query<Handler> {
Group::<Handler>::from_group_details(group_details, schema.clone())
}
async fn schema(context: &Context<Handler>) -> FieldResult<Schema<Handler>> {
async fn schema(&self, context: &Context<Handler>) -> FieldResult<Schema<Handler>> {
let span = debug_span!("[GraphQL query] get_schema");
self.get_schema(context, span).await.map(Into::into)
}
@@ -175,9 +184,9 @@ mod tests {
use pretty_assertions::assert_eq;
use std::collections::HashSet;
fn schema<'q, C, Q>(query_root: Q) -> RootNode<'q, Q, EmptyMutation<C>, EmptySubscription<C>>
fn schema<C, Q>(query_root: Q) -> RootNode<Q, EmptyMutation<C>, EmptySubscription<C>>
where
Q: GraphQLType<DefaultScalarValue, Context = C, TypeInfo = ()> + 'q,
Q: GraphQLType<DefaultScalarValue, Context = C, TypeInfo = ()>,
{
RootNode::new(
query_root,

View File

@@ -70,7 +70,7 @@ impl<Handler: BackendHandler> User<Handler> {
fn first_name(&self) -> &str {
self.attributes
.iter()
.find(|a| a.name() == "first_name")
.find(|a| a.attribute_name() == "first_name")
.map(|a| a.attribute.value.as_str().unwrap_or_default())
.unwrap_or_default()
}
@@ -78,7 +78,7 @@ impl<Handler: BackendHandler> User<Handler> {
fn last_name(&self) -> &str {
self.attributes
.iter()
.find(|a| a.name() == "last_name")
.find(|a| a.attribute_name() == "last_name")
.map(|a| a.attribute.value.as_str().unwrap_or_default())
.unwrap_or_default()
}
@@ -86,7 +86,7 @@ impl<Handler: BackendHandler> User<Handler> {
fn avatar(&self) -> Option<String> {
self.attributes
.iter()
.find(|a| a.name() == "avatar")
.find(|a| a.attribute_name() == "avatar")
.map(|a| {
String::from(
a.attribute

View File

@@ -1,6 +1,6 @@
[package]
name = "lldap_ldap"
version = "0.1.0"
version = "0.1.1"
description = "LDAP operations support"
authors.workspace = true
edition.workspace = true
@@ -10,27 +10,19 @@ repository.workspace = true
rust-version.workspace = true
[dependencies]
anyhow = "*"
ldap3_proto = "0.6.0"
tracing = "*"
itertools = "0.10"
anyhow = { workspace = true }
tracing = { workspace = true }
itertools = { workspace = true }
[dependencies.derive_more]
features = ["from"]
default-features = false
version = "1"
derive_more = { workspace = true }
[dependencies.chrono]
features = ["serde"]
version = "*"
chrono = { workspace = true }
[dependencies.rand]
features = ["small_rng", "getrandom"]
version = "0.8"
rand = { workspace = true }
[dependencies.uuid]
version = "1"
features = ["v1", "v3"]
uuid = { workspace = true }
ldap3_proto = { workspace = true }
[dependencies.lldap_access_control]
path = "../access-control"
@@ -55,12 +47,10 @@ path = "../opaque-handler"
path = "../test-utils"
[dev-dependencies]
mockall = "0.11.4"
pretty_assertions = "1"
mockall = { workspace = true }
pretty_assertions = { workspace = true }
[dev-dependencies.tokio]
features = ["full"]
version = "1.25"
tokio = { workspace = true }
[dev-dependencies.lldap_domain]
path = "../domain"

View File

@@ -173,22 +173,31 @@ fn get_group_attribute_equality_filter(
typ: AttributeType,
is_list: bool,
value: &str,
) -> GroupRequestFilter {
) -> LdapResult<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 serialized_value = deserialize_attribute_value(&[value.to_owned()], typ, is_list);
let serialized_value_lc = deserialize_attribute_value(&[value_lc.to_owned()], typ, is_list);
let serialized_value = deserialize_attribute_value(&[value.to_owned()], typ, false);
let serialized_value_lc = deserialize_attribute_value(&[value_lc.to_owned()], typ, false);
match (serialized_value, serialized_value_lc) {
(Ok(v), Ok(v_lc)) => GroupRequestFilter::Or(vec![
(Ok(v), Ok(v_lc)) => Ok(GroupRequestFilter::Or(vec![
GroupRequestFilter::AttributeEquality(field.clone(), v),
GroupRequestFilter::AttributeEquality(field.clone(), v_lc),
]),
])),
(Ok(_), Err(e)) => {
warn!("Invalid value for attribute {} (lowercased): {}", field, e);
GroupRequestFilter::False
Ok(GroupRequestFilter::False)
}
(Err(e), _) => {
warn!("Invalid value for attribute {}: {}", field, e);
GroupRequestFilter::False
Ok(GroupRequestFilter::False)
}
}
}
@@ -259,9 +268,9 @@ fn convert_group_filter(
}
Ok(GroupRequestFilter::False)
}
GroupFieldType::Attribute(field, typ, is_list) => Ok(
get_group_attribute_equality_filter(&field, typ, is_list, value),
),
GroupFieldType::Attribute(field, typ, is_list) => {
get_group_attribute_equality_filter(&field, typ, is_list, value)
}
GroupFieldType::CreationDate => Err(LdapError {
code: LdapResultCode::UnwillingToPerform,
message: "Creation date filter for groups not supported".to_owned(),
@@ -671,6 +680,82 @@ 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]
async fn test_search_group_as_scope() {
let mut mock = MockTestBackendHandler::new();

View File

@@ -182,22 +182,31 @@ fn get_user_attribute_equality_filter(
typ: AttributeType,
is_list: bool,
value: &str,
) -> UserRequestFilter {
) -> LdapResult<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 serialized_value = deserialize_attribute_value(&[value.to_owned()], typ, is_list);
let serialized_value_lc = deserialize_attribute_value(&[value_lc.to_owned()], typ, is_list);
let serialized_value = deserialize_attribute_value(&[value.to_owned()], typ, false);
let serialized_value_lc = deserialize_attribute_value(&[value_lc.to_owned()], typ, false);
match (serialized_value, serialized_value_lc) {
(Ok(v), Ok(v_lc)) => UserRequestFilter::Or(vec![
(Ok(v), Ok(v_lc)) => Ok(UserRequestFilter::Or(vec![
UserRequestFilter::AttributeEquality(field.clone(), v),
UserRequestFilter::AttributeEquality(field.clone(), v_lc),
]),
])),
(Ok(_), Err(e)) => {
warn!("Invalid value for attribute {} (lowercased): {}", field, e);
UserRequestFilter::False
Ok(UserRequestFilter::False)
}
(Err(e), _) => {
warn!("Invalid value for attribute {}: {}", field, e);
UserRequestFilter::False
Ok(UserRequestFilter::False)
}
}
}
@@ -279,9 +288,9 @@ fn convert_user_filter(
UserFieldType::PrimaryField(field) => {
Ok(UserRequestFilter::Equality(field, value_lc))
}
UserFieldType::Attribute(field, typ, is_list) => Ok(
get_user_attribute_equality_filter(&field, typ, is_list, value),
),
UserFieldType::Attribute(field, typ, is_list) => {
get_user_attribute_equality_filter(&field, typ, is_list, value)
}
UserFieldType::NoMatch => {
if !ldap_info.ignored_user_attributes.contains(&field) {
warn!(
@@ -786,6 +795,83 @@ 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]
async fn test_search_cn_case_insensitive() {
use lldap_domain::uuid;

View File

@@ -1,6 +1,6 @@
[package]
name = "lldap_opaque_handler"
version = "0.1.0"
version = "0.1.1"
description = "Opaque handler trait for LLDAP"
authors.workspace = true
edition.workspace = true
@@ -13,7 +13,7 @@ rust-version.workspace = true
test = []
[dependencies]
async-trait = "0.1"
async-trait = { workspace = true }
[dependencies.lldap_auth]
path = "../auth"
@@ -26,4 +26,4 @@ path = "../domain"
path = "../domain-model"
[dev-dependencies]
mockall = "0.11.4"
mockall = { workspace = true }

View File

@@ -1,6 +1,6 @@
[package]
name = "lldap_sql_backend_handler"
version = "0.1.0"
version = "0.1.1"
description = "SQL backend for LLDAP"
authors.workspace = true
edition.workspace = true
@@ -13,44 +13,30 @@ rust-version.workspace = true
test = []
[dependencies]
anyhow = "*"
async-trait = "0.1"
base64 = "0.21"
bincode = "1.3"
itertools = "0.10"
ldap3_proto = "0.6.0"
orion = "0.17"
serde_json = "1"
tracing = "*"
anyhow = { workspace = true }
async-trait = { workspace = true }
itertools = { workspace = true }
orion = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
[dependencies.chrono]
features = ["serde"]
version = "*"
bincode = { workspace = true }
[dependencies.rand]
features = ["small_rng", "getrandom"]
version = "0.8"
base64 = { workspace = true }
[dependencies.sea-orm]
workspace = true
features = [
"macros",
"with-chrono",
"with-uuid",
"sqlx-all",
"runtime-actix-rustls",
]
chrono = { workspace = true }
[dependencies.secstr]
features = ["serde"]
version = "*"
rand = { workspace = true }
[dependencies.serde]
workspace = true
sea-orm = { workspace = true }
[dependencies.uuid]
version = "1"
features = ["v1", "v3"]
secstr = { workspace = true }
serde = { workspace = true }
uuid = { workspace = true }
ldap3_proto = { workspace = true }
[dependencies.lldap_access_control]
path = "../access-control"
@@ -71,18 +57,18 @@ path = "../domain-model"
[dependencies.lldap_opaque_handler]
path = "../opaque-handler"
[dev-dependencies.lldap_domain]
path = "../domain"
features = ["test"]
[dev-dependencies.lldap_test_utils]
path = "../test-utils"
[dev-dependencies]
log = "*"
mockall = "0.11.4"
pretty_assertions = "1"
log = { workspace = true }
mockall = { workspace = true }
pretty_assertions = { workspace = true }
[dev-dependencies.tokio]
features = ["full"]
version = "1.25"
tokio = { workspace = true }
[dev-dependencies.tracing-subscriber]
version = "0.3"
features = ["env-filter", "tracing-log"]
tracing-subscriber = { workspace = true }

View File

@@ -536,7 +536,7 @@ See https://github.com/lldap/lldap/blob/main/docs/migration_guides/v0.5.md for d
Conflicting emails:
"#,
);
for (email, users) in &transaction
let duplicate_users = transaction
.query_all(
builder.build(
Query::select()
@@ -568,9 +568,8 @@ Conflicting emails:
row.try_get::<String>("", &Users::Email.to_string())
.unwrap(),
)
})
.group_by(|(_user, email)| email.to_owned())
{
});
for (email, users) in &duplicate_users.chunk_by(|(_user, email)| email.to_owned()) {
warn!("Email: {email}");
for (user, _email) in users {
warn!(" User: {}", user.as_str());

View File

@@ -29,28 +29,76 @@ impl ReadSchemaBackendHandler for SqlBackendHandler {
#[async_trait]
impl SchemaBackendHandler for SqlBackendHandler {
async fn add_user_attribute(&self, request: CreateAttributeRequest) -> Result<()> {
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(&self.sql_pool).await?;
self.sql_pool
.transaction::<_, (), DomainError>(|transaction| {
Box::pin(async move {
// Check for conflicting group attribute with the same name but different type.
// Done inside the transaction to prevent TOCTOU races.
let schema = Self::get_schema_with_transaction(transaction).await?;
if let Some(group_attr) = schema
.group_attributes
.attributes
.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(())
}
async fn add_group_attribute(&self, request: CreateAttributeRequest) -> Result<()> {
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(&self.sql_pool).await?;
self.sql_pool
.transaction::<_, (), DomainError>(|transaction| {
Box::pin(async move {
// Check for conflicting user attribute with the same name but different type.
// Done inside the transaction to prevent TOCTOU races.
let schema = Self::get_schema_with_transaction(transaction).await?;
if let Some(user_attr) = schema
.user_attributes
.attributes
.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(())
}

View File

@@ -1,6 +1,6 @@
[package]
name = "lldap_test_utils"
version = "0.1.0"
version = "0.1.1"
authors.workspace = true
edition.workspace = true
homepage.workspace = true
@@ -9,14 +9,13 @@ repository.workspace = true
rust-version.workspace = true
[dependencies]
async-trait = "0.1"
ldap3_proto = "0.6.0"
mockall = "0.11.4"
tracing = "*"
async-trait = { workspace = true }
mockall = { workspace = true }
tracing = { workspace = true }
[dependencies.uuid]
version = "1"
features = ["v1", "v3"]
uuid = { workspace = true }
ldap3_proto = { workspace = true }
[dependencies.lldap_access_control]
path = "../access-control"

View File

@@ -1,6 +1,6 @@
[package]
name = "lldap_validation"
version = "0.6.0"
version = "0.6.3"
authors = ["Simon Broeng Jensen <sbj@cwconsult.dk>"]
description = "Validation logic for LLDAP"
edition.workspace = true

View File

@@ -86,6 +86,7 @@ services:
# - 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__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

View File

@@ -19,11 +19,13 @@ configuration files:
- [Dolibarr](dolibarr.md)
- [Duo Auth Proxy](duo_auth_proxy.md)
- [Ejabberd](ejabberd.md)
- [Elasticsearch](elasticsearch.md)
- [Emby](emby.md)
- [Ergo IRCd](ergo.md)
- [Gerrit](gerrit.md)
- [Gitea](gitea.md)
- [GitLab](gitlab.md)
- [Gogs](gogs.md)
- [Grafana](grafana_ldap_config.toml)
- [Grocy](grocy.md)
- [Harbor](harbor.md)
@@ -50,6 +52,7 @@ configuration files:
- [Nexus](nexus.md)
- [OCIS (OwnCloud Infinite Scale)](ocis.md)
- [OneDev](onedev.md)
- [OpenCloud](opencloud.md)
- [Organizr](Organizr.md)
- [Peertube](peertube.md)
- [Penpot](penpot.md)

View File

@@ -0,0 +1,56 @@
# 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.

View File

@@ -1,6 +1,21 @@
# Gogs LDAP configuration
Gogs can make use of LDAP and therefore lldap.
## Via Simple Auth (easier)
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 example is a container configuration - the file should live within `conf/auth.d/some_name.conf`:

View File

@@ -0,0 +1,55 @@
# 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).

View File

@@ -48,3 +48,13 @@ To integrate with LLDAP,
allow-invalid-certs = true
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=?)))"
```

View File

@@ -30,7 +30,7 @@ Unique Identifier Attribute - entryUUID
- Group Object Filter - objectClass=groupOfUniqueNames
Member Attribute
member
uniqueMember
Validate Attribute
enter a user e-mail address who has been added in LLDAP , and click test configuration, test show be successful

View File

@@ -22,7 +22,7 @@
};
# MSRV from the project
rustVersion = "1.89.0";
rustVersion = "1.91.0";
# Rust toolchain with required components
rustToolchain = pkgs.rust-bin.stable.${rustVersion}.default.override {
@@ -159,4 +159,4 @@
};
};
});
}
}

View File

@@ -1,6 +1,6 @@
[package]
name = "lldap_migration_tool"
version = "0.4.2"
version = "0.4.3"
description = "CLI migration tool to go from OpenLDAP to LLDAP"
edition.workspace = true
authors.workspace = true
@@ -10,31 +10,24 @@ repository.workspace = true
rust-version.workspace = true
[dependencies]
anyhow = "*"
base64 = "0.13"
rand = "0.8"
requestty = "0.4.1"
serde_json = "1"
smallvec = "*"
anyhow = { workspace = true }
base64 = { workspace = true }
ldap3 = { workspace = true }
rand = { workspace = true }
requestty = "0.6"
serde = { workspace = true }
serde_json = { workspace = true }
smallvec = "1"
[dependencies.lldap_auth]
path = "../crates/auth"
features = ["opaque_client"]
[dependencies.graphql_client]
workspace = true
features = ["graphql_query_derive", "reqwest-rustls"]
default-features = false
version = "0.11"
[dependencies.reqwest]
version = "*"
workspace = true
default-features = false
features = ["json", "blocking", "rustls-tls"]
[dependencies.ldap3]
version = "*"
default-features = false
features = ["sync", "tls-rustls-ring"]
[dependencies.serde]
workspace = true

View File

@@ -193,7 +193,9 @@ impl TryFrom<ResultEntry> for User {
display_name,
first_name,
last_name,
avatar: avatar.map(base64::encode),
avatar: avatar.map(|avatar| {
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, avatar)
}),
attributes: None,
},
password,

View File

@@ -1,2 +1,2 @@
[toolchain]
channel = "1.89.0"
channel = "1.91.0"

261
schema.graphql generated
View File

@@ -1,63 +1,33 @@
type AttributeValue {
name: String!
value: [String!]!
schema: AttributeSchema!
schema {
query: Query
mutation: Mutation
}
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!
enum AttributeType {
STRING
INTEGER
JPEG_PHOTO
DATE_TIME
}
type Group {
id: Int!
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!
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!
"User-defined attributes." attributes: [AttributeValueInput!]
}
"The details required to create a user."
@@ -80,19 +50,36 @@ input CreateUserInput {
"Attributes." attributes: [AttributeValueInput!]
}
type ObjectClassInfo {
objectClass: String!
isHardcoded: Boolean!
input EqualityConstraint {
field: String!
value: String!
}
type AttributeSchema {
name: String!
attributeType: AttributeType!
isList: Boolean!
isVisible: Boolean!
isEditable: Boolean!
isHardcoded: Boolean!
isReadonly: Boolean!
"""
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
}
"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."
@@ -122,9 +109,87 @@ input UpdateUserInput {
""" insertAttributes: [AttributeValueInput!]
}
input EqualityConstraint {
field: String!
value: String!
"""
Combined date and time (with time zone) in [RFC 3339][0] format.
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 {
@@ -132,38 +197,8 @@ type Schema {
groupSchema: AttributeList!
}
"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!]
}
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 Success {
ok: Boolean!
}
type User {
@@ -173,32 +208,10 @@ type User {
firstName: String!
lastName: String!
avatar: String
creationDate: DateTimeUtc!
creationDate: DateTime!
uuid: String!
"User-defined attributes."
attributes: [AttributeValue!]!
"The groups to which this user belongs."
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
}

View File

@@ -712,9 +712,9 @@ main() {
redundant_users="$(printf '%s' "$redundant_users" | jq --compact-output --arg id "$id" '. - [$id]')"
if [[ "$password_file" != 'null' ]] && [[ "$password_file" != '""' ]]; then
"$LLDAP_SET_PASSWORD_PATH" --base-url "$LLDAP_URL" --token "$TOKEN" --username "$id" --password "$(cat $password_file)"
LLDAP_USER_PASSWORD="$(< "$password_file")" "$LLDAP_SET_PASSWORD_PATH" --base-url "$LLDAP_URL" --token "$TOKEN" --username "$id"
elif [[ "$password" != 'null' ]] && [[ "$password" != '""' ]]; then
"$LLDAP_SET_PASSWORD_PATH" --base-url "$LLDAP_URL" --token "$TOKEN" --username "$id" --password "$password"
LLDAP_USER_PASSWORD="$password" "$LLDAP_SET_PASSWORD_PATH" --base-url "$LLDAP_URL" --token "$TOKEN" --username "$id"
fi
# Process custom attributes

View File

@@ -1,6 +1,6 @@
[package]
name = "lldap"
version = "0.6.2"
version = "0.6.3"
description = "Super-simple and lightweight LDAP server"
categories = ["authentication", "command-line-utilities"]
edition.workspace = true
@@ -19,35 +19,46 @@ actix-rt = "2"
actix-server = "2"
actix-service = "2"
actix-web-httpauth = "0.8"
anyhow = "*"
async-trait = "0.1"
bincode = "1.3"
cron = "*"
derive_builder = "0.12"
anyhow = { workspace = true }
async-trait = { workspace = true }
bincode = { workspace = true }
chrono = { workspace = true }
clap = { workspace = true }
cron = "0"
derive_more = { workspace = true }
derive_builder = "0.20"
figment_file_provider_adapter = "0.1"
futures = "*"
futures-util = "*"
futures = "0"
futures-util = "0"
hmac = "0.12"
http = "*"
juniper = "0.15"
jwt = "0.16"
ldap3_proto = "0.6.0"
log = "*"
juniper = { workspace = true }
jwt = { workspace = true }
ldap3_proto = { workspace = true }
log = { workspace = true }
opaque-ke = { workspace = true }
rand = { workspace = true }
rand_chacha = "0.3"
rustls-pemfile = "2"
serde_json = "1"
sea-orm = { workspace = true }
secstr = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = "0.10"
thiserror = "2"
strum = { workspace = true }
thiserror = { workspace = true }
time = "0.3"
tokio = { workspace = true }
tokio-rustls = "0.26"
tokio-stream = "*"
tokio-stream = "0"
tokio-util = "0.7"
tracing = "*"
tracing = { workspace = true }
tracing-actix-web = "0.7"
tracing-attributes = "^0.1.21"
tracing-log = "*"
urlencoding = "2"
webpki-roots = "0.26"
tracing-log = "0"
tracing-subscriber = { workspace = true }
urlencoding = { workspace = true }
uuid = { workspace = true }
webpki-roots = "1"
[dependencies.actix-web]
features = ["rustls-0_23"]
@@ -62,26 +73,9 @@ version = "0.23"
features = ["std"]
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]
features = ["env", "toml"]
version = "*"
[dependencies.tracing-subscriber]
version = "0.3"
features = ["env-filter", "tracing-log"]
version = "0"
[dependencies.lettre]
features = ["builder", "serde", "smtp-transport", "tokio1-rustls-tls"]
@@ -126,52 +120,16 @@ path = "../crates/opaque-handler"
[dependencies.lldap_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]
features = ["smallvec", "chrono", "tokio"]
version = "^0.1.6"
version = "0.3"
[dependencies.actix-tls]
features = ["default", "rustls-0_23"]
version = "3"
[dependencies.sea-orm]
workspace = true
features = [
"macros",
"with-chrono",
"with-uuid",
"sqlx-all",
"runtime-actix-rustls",
]
[dependencies.reqwest]
version = "0.11"
workspace = true
default-features = false
features = ["rustls-tls-webpki-roots"]
@@ -180,20 +138,13 @@ version = "2"
features = ["serde"]
[dev-dependencies]
assert_cmd = "2.0"
mockall = "0.11.4"
nix = "0.26.2"
pretty_assertions = "1"
[dev-dependencies.graphql_client]
features = ["graphql_query_derive", "reqwest-rustls"]
default-features = false
version = "0.11"
[dev-dependencies.ldap3]
version = "*"
default-features = false
features = ["sync", "tls-rustls-ring"]
assert_cmd = "2"
graphql_client = { workspace = true, features = ["graphql_query_derive", "reqwest-rustls"] }
ldap3 = { workspace = true }
mockall = { workspace = true }
nix = { version = "0.31", features = ["process", "signal"] }
pretty_assertions = { workspace = true }
uuid = { workspace = true }
[dev-dependencies.lldap_auth]
path = "../crates/auth"
@@ -211,7 +162,7 @@ path = "../crates/sql-backend-handler"
features = ["test"]
[dev-dependencies.reqwest]
version = "*"
workspace = true
default-features = false
features = ["json", "blocking", "rustls-tls"]
@@ -220,10 +171,6 @@ version = "2.0.0"
default-features = false
features = ["file_locks"]
[dev-dependencies.uuid]
version = "1"
features = ["v4"]
[dev-dependencies.figment]
features = ["test"]
version = "*"
version = "0"

View File

@@ -135,7 +135,7 @@ pub struct RunOpts {
#[clap(long, env = "LLDAP_SERVER_KEY_SEED")]
pub server_key_seed: Option<String>,
/// Change ldap host. Default: "0.0.0.0"
/// Change ldap host. Default: "::"
#[clap(long, env = "LLDAP_LDAP_HOST")]
pub ldap_host: Option<String>,
@@ -143,7 +143,7 @@ pub struct RunOpts {
#[clap(long, env = "LLDAP_LDAP_PORT")]
pub ldap_port: Option<u16>,
/// Change HTTP API host. Default: "0.0.0.0"
/// Change HTTP API host. Default: "::"
#[clap(long, env = "LLDAP_HTTP_HOST")]
pub http_host: Option<String>,
@@ -151,7 +151,7 @@ pub struct RunOpts {
#[clap(long, env = "LLDAP_HTTP_PORT")]
pub http_port: Option<u16>,
/// URL of the server, for password reset links.
/// URL of the server, for password reset links. Default: "http://localhost"
#[clap(long, env = "LLDAP_HTTP_URL")]
pub http_url: Option<Url>,

View File

@@ -105,11 +105,11 @@ pub struct HttpUrl(pub Url);
#[derive(Clone, Deserialize, Serialize, derive_builder::Builder, derive_more::Debug)]
#[builder(pattern = "owned", build_fn(name = "private_build"))]
pub struct Configuration {
#[builder(default = r#"String::from("0.0.0.0")"#)]
#[builder(default = r#"String::from("::")"#)]
pub ldap_host: String,
#[builder(default = "3890")]
pub ldap_port: u16,
#[builder(default = r#"String::from("0.0.0.0")"#)]
#[builder(default = r#"String::from("::")"#)]
pub http_host: String,
#[builder(default = "17170")]
pub http_port: u16,

View File

@@ -52,7 +52,7 @@ where
/// Actix GraphQL Handler for GET requests
pub async fn get_graphql_handler<Query, Mutation, Subscription, CtxT, S>(
schema: &juniper::RootNode<'static, Query, Mutation, Subscription, S>,
schema: &juniper::RootNode<Query, Mutation, Subscription, S>,
context: &CtxT,
req: HttpRequest,
) -> Result<HttpResponse, Error>
@@ -81,7 +81,7 @@ where
/// Actix GraphQL Handler for POST requests
pub async fn post_graphql_handler<Query, Mutation, Subscription, CtxT, S>(
schema: &juniper::RootNode<'static, Query, Mutation, Subscription, S>,
schema: &juniper::RootNode<Query, Mutation, Subscription, S>,
context: &CtxT,
req: HttpRequest,
mut payload: actix_http::Payload,

View File

@@ -4,7 +4,8 @@ use anyhow::{Context, Result, anyhow};
use graphql_client::GraphQLQuery;
use reqwest::blocking::Client;
pub type DateTimeUtc = chrono::DateTime<chrono::Utc>;
pub type DateTime = chrono::DateTime<chrono::Utc>;
pub type DateTimeUtc = DateTime;
#[derive(GraphQLQuery)]
#[graphql(

View File

@@ -1,6 +1,6 @@
[package]
name = "lldap_set_password"
version = "0.1.0"
version = "0.1.1"
description = "CLI tool to set a user password in LLDAP"
edition.workspace = true
authors.workspace = true
@@ -12,22 +12,17 @@ rust-version.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "*"
rand = "0.8"
serde_json = "1"
anyhow = { workspace = true }
rand = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
[dependencies.clap]
features = ["std", "color", "suggestions", "derive", "env"]
version = "4"
clap = { workspace = true }
[dependencies.lldap_auth]
path = "../crates/auth"
features = ["opaque_client"]
[dependencies.reqwest]
version = "*"
default-features = false
features = ["json", "blocking", "rustls-tls", "rustls-tls-native-roots"]
[dependencies.serde]
workspace = true
features = ["json", "blocking", "rustls-tls", "rustls-tls-native-roots"]