Compare commits

..

2 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
07d1948190 Add native certificate support to lldap_set_password
Co-authored-by: nitnelave <796633+nitnelave@users.noreply.github.com>
2025-07-08 20:27:45 +00:00
copilot-swe-agent[bot]
4c9036d4ee Initial plan 2025-07-08 20:14:31 +00:00
132 changed files with 4123 additions and 6619 deletions

View File

@@ -1,46 +0,0 @@
# docs: https://docs.coderabbit.ai/reference/yaml-template for full configuration options
tone_instructions: "Be concise"
reviews:
profile: "chill"
high_level_summary: false
review_status: false
commit_status: false
collapse_walkthrough: true
changed_files_summary: false
sequence_diagrams: false
estimate_code_review_effort: false
assess_linked_issues: false
related_issues: false
related_prs: false
suggested_labels: false
suggested_reviewers: false
poem: false
auto_review:
enabled: true
auto_incremental_review: true
finishing_touches:
docstrings:
enabled: false
unit_tests:
enabled: false
pre_merge_checks:
docstrings:
mode: "off"
title:
mode: "off"
description:
mode: "off"
issue_assessment:
mode: "off"
chat:
art: false
auto_reply: false
knowledge_base:
web_search:
enabled: true
code_guidelines:
enabled: false

View File

@@ -1,4 +1,4 @@
FROM rust:1.89
FROM rust:1.74
ARG USERNAME=lldapdev
# We need to keep the user as 1001 to match the GitHub runner's UID.

View File

@@ -1,26 +1,8 @@
{
"name": "LLDAP dev",
"build": {
"dockerfile": "Dockerfile"
},
"customizations": {
"vscode": {
"extensions": [
"rust-lang.rust-analyzer"
],
"settings": {
"rust-analyzer.linkedProjects": [
"./Cargo.toml"
]
}
}
},
"features": {
"ghcr.io/devcontainers/features/rust:1": {}
},
"build": { "dockerfile": "Dockerfile" },
"forwardPorts": [
3890,
17170
],
"remoteUser": "lldapdev"
]
}

1
.envrc
View File

@@ -1 +0,0 @@
use flake

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @nitnelave

13
.github/codecov.yml vendored
View File

@@ -1,16 +1,19 @@
codecov:
require_ci_to_pass: yes
comment:
layout: "condensed_header, diff, condensed_files"
hide_project_coverage: true
require_changes: "coverage_drop"
layout: "header,diff,files"
require_changes: true
require_base: true
require_head: true
coverage:
range: "70...100"
status:
project:
default:
target: "75%"
threshold: 5
threshold: "0.1%"
removed_code_behavior: adjust_base
github_checks:
annotations: true
ignore:
- "app"
- "docs"

View File

@@ -1,159 +0,0 @@
# LLDAP - Light LDAP implementation for authentication
LLDAP is a lightweight LDAP authentication server written in Rust with a WebAssembly frontend. It provides an opinionated, simplified LDAP interface for authentication and integrates with many popular services.
**ALWAYS reference these instructions first and fallback to search or bash commands only when you encounter unexpected information that does not match the info here.**
## Working Effectively
### Bootstrap and Build the Repository
- Install dependencies: `sudo apt-get update && sudo apt-get install -y curl gzip binaryen`
- Install Rust if not available: `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` then `source ~/.cargo/env`
- Install wasm-pack for frontend: `cargo install wasm-pack` -- takes 90 seconds. NEVER CANCEL. Set timeout to 180+ seconds.
- Build entire workspace: `cargo build --workspace` -- takes 3-4 minutes. NEVER CANCEL. Set timeout to 300+ seconds.
- Build release server binary: `cargo build --release -p lldap` -- takes 5-6 minutes. NEVER CANCEL. Set timeout to 420+ seconds.
- Build frontend WASM: `./app/build.sh` -- takes 3-4 minutes including wasm-pack installation. NEVER CANCEL. Set timeout to 300+ seconds.
### Testing and Validation
- Run all tests: `cargo test --workspace` -- takes 2-3 minutes. NEVER CANCEL. Set timeout to 240+ seconds.
- Check formatting: `cargo fmt --all --check` -- takes <5 seconds.
- Run linting: `cargo clippy --tests --all -- -D warnings` -- takes 60-90 seconds. NEVER CANCEL. Set timeout to 120+ seconds.
- Export GraphQL schema: `./export_schema.sh` -- takes 70-80 seconds. NEVER CANCEL. Set timeout to 120+ seconds.
### Running the Application
- **ALWAYS run the build steps first before starting the server.**
- Start development server: `cargo run -- run --config-file <config_file>`
- **CRITICAL**: Server requires a valid configuration file. Use `lldap_config.docker_template.toml` as reference.
- **CRITICAL**: Avoid key conflicts by removing existing `server_key*` files when testing with `key_seed` in config.
- Server binds to:
- LDAP: port 3890 (configurable)
- Web interface: port 17170 (configurable)
- LDAPS: port 6360 (optional, disabled by default)
### Manual Validation Requirements
- **ALWAYS test both LDAP and web interfaces after making changes.**
- Test web interface: `curl -s http://localhost:17170/` should return HTML with "LLDAP Administration" title.
- Test GraphQL API: `curl -s -X POST -H "Content-Type: application/json" -d '{"query": "query { __schema { queryType { name } } }"}' http://localhost:17170/api/graphql`
- Run healthcheck: `cargo run -- healthcheck --config-file <config_file>` (requires running server)
- **ALWAYS ensure server starts without errors and serves the web interface before considering changes complete.**
## Validation Scenarios
After making code changes, ALWAYS:
1. **Build validation**: Run `cargo build --workspace` to ensure compilation succeeds.
2. **Test validation**: Run `cargo test --workspace` to ensure existing functionality works.
3. **Lint validation**: Run `cargo clippy --tests --all -- -D warnings` to catch potential issues.
4. **Format validation**: Run `cargo fmt --all --check` to ensure code style compliance.
5. **Frontend validation**: Run `./app/build.sh` to ensure WASM compilation succeeds.
6. **Runtime validation**: Start the server and verify web interface accessibility.
7. **Schema validation**: If GraphQL changes made, run `./export_schema.sh` to update schema.
### Test User Scenarios
- **Login flow**: Access web interface at `http://localhost:17170`, attempt login with admin/password (default).
- **LDAP binding**: Test LDAP connection on port 3890 with appropriate LDAP tools if available.
- **Configuration changes**: Test with different configuration files to validate config parsing.
## Project Structure and Key Components
### Backend (Rust)
- **Server**: `/server` - Main application binary
- **Crates**: `/crates/*` - Modularized components:
- `auth` - Authentication and OPAQUE protocol
- `domain*` - Domain models and handlers
- `ldap` - LDAP protocol implementation
- `graphql-server` - GraphQL API server
- `sql-backend-handler` - Database operations
- `validation` - Input validation utilities
### Frontend (Rust + WASM)
- **App**: `/app` - Yew-based WebAssembly frontend
- **Build**: `./app/build.sh` - Compiles Rust to WASM using wasm-pack
- **Assets**: `/app/static` - Static web assets
### Configuration and Deployment
- **Config template**: `lldap_config.docker_template.toml` - Reference configuration
- **Docker**: `Dockerfile` - Container build definition
- **Scripts**:
- `prepare-release.sh` - Cross-platform release builds
- `export_schema.sh` - GraphQL schema export
- `generate_secrets.sh` - Random secret generation
- `scripts/bootstrap.sh` - User/group management script
## Common Development Workflows
### Making Backend Changes
1. Edit Rust code in `/server` or `/crates`
2. Run `cargo build --workspace` to test compilation
3. Run `cargo test --workspace` to ensure tests pass
4. Run `cargo clippy --tests --all -- -D warnings` to check for warnings
5. If GraphQL schema affected, run `./export_schema.sh`
6. Test by running server and validating functionality
### Making Frontend Changes
1. Edit code in `/app/src`
2. Run `./app/build.sh` to rebuild WASM package
3. Start server and test web interface functionality
4. Verify no JavaScript errors in browser console
### Adding New Dependencies
- Backend: Add to appropriate `Cargo.toml` in `/server` or `/crates/*`
- Frontend: Add to `/app/Cargo.toml`
- **Always rebuild after dependency changes**
## CI/CD Integration
The repository uses GitHub Actions (`.github/workflows/rust.yml`):
- **Build job**: Validates workspace compilation
- **Test job**: Runs full test suite
- **Clippy job**: Linting with warnings as errors
- **Format job**: Code formatting validation
- **Coverage job**: Code coverage analysis
**ALWAYS ensure your changes pass all CI checks by running equivalent commands locally.**
## Timing Expectations and Timeouts
| Command | Expected Time | Timeout Setting |
|---------|---------------|-----------------|
| `cargo build --workspace` | 3-4 minutes | 300+ seconds |
| `cargo build --release -p lldap` | 5-6 minutes | 420+ seconds |
| `cargo test --workspace` | 2-3 minutes | 240+ seconds |
| `./app/build.sh` | 3-4 minutes | 300+ seconds |
| `cargo clippy --tests --all -- -D warnings` | 60-90 seconds | 120+ seconds |
| `./export_schema.sh` | 70-80 seconds | 120+ seconds |
| `cargo install wasm-pack` | 90 seconds | 180+ seconds |
**NEVER CANCEL** any of these commands. Builds may take longer on slower systems.
## Troubleshooting Common Issues
### Build Issues
- **Missing wasm-pack**: Run `cargo install wasm-pack`
- **Missing binaryen**: Run `sudo apt-get install -y binaryen` or disable wasm-opt
- **Clippy warnings**: Fix all warnings as they are treated as errors in CI
- **GraphQL schema mismatch**: Run `./export_schema.sh` to update schema
### Runtime Issues
- **Key conflicts**: Remove `server_key*` files when using `key_seed` in config
- **Port conflicts**: Check if ports 3890/17170 are available
- **Database issues**: Ensure database URL in config is valid and accessible
- **Asset missing**: Ensure frontend is built with `./app/build.sh`
### Development Environment
- **Rust version**: Use stable Rust toolchain (2024 edition)
- **System dependencies**: curl, gzip, build tools
- **Database**: SQLite (default), MySQL, or PostgreSQL supported
## Configuration Reference
Essential configuration parameters:
- `ldap_base_dn`: LDAP base DN (e.g., "dc=example,dc=com")
- `ldap_user_dn`: Admin user DN
- `ldap_user_pass`: Admin password
- `jwt_secret`: Secret for JWT tokens (generate with `./generate_secrets.sh`)
- `key_seed`: Encryption key seed
- `database_url`: Database connection string
- `http_port`: Web interface port (default: 17170)
- `ldap_port`: LDAP server port (default: 3890)
**Always use the provided config template as starting point for new configurations.**

View File

@@ -1,26 +0,0 @@
name: Copilot Setup Steps for LLDAP Development
steps:
- name: Update package list
run: sudo apt-get update
- name: Install system dependencies
run: sudo apt-get install -y curl gzip binaryen build-essential
- name: Install Rust toolchain
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source ~/.cargo/env
echo 'source ~/.cargo/env' >> ~/.bashrc
- name: Install wasm-pack for frontend builds
run: |
source ~/.cargo/env
cargo install wasm-pack
- name: Verify installations
run: |
source ~/.cargo/env
rustc --version
cargo --version
wasm-pack --version

View File

@@ -1,6 +1,6 @@
FROM localhost:5000/lldap/lldap:alpine-base
# Taken directly from https://github.com/tianon/gosu/blob/master/INSTALL.md
ENV GOSU_VERSION=1.17
ENV GOSU_VERSION 1.17
RUN set -eux; \
\
apk add --no-cache --virtual .gosu-deps \
@@ -15,18 +15,7 @@ RUN set -eux; \
\
# verify the signature
export GNUPGHOME="$(mktemp -d)"; \
for server in \
hkps://keys.openpgp.org \
ha.pool.sks-keyservers.net \
hkp://p80.pool.sks-keyservers.net:80 \
keyserver.ubuntu.com \
hkp://keyserver.ubuntu.com:80 \
pgp.mit.edu \
; do \
if gpg --batch --keyserver "$server" --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; then \
break; \
fi; \
done; \
gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \
gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
gpgconf --kill all; \
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \

View File

@@ -1,15 +1,12 @@
FROM localhost:5000/lldap/lldap:debian-base
# Taken directly from https://github.com/tianon/gosu/blob/master/INSTALL.md
ENV GOSU_VERSION=1.17
ENV GOSU_VERSION 1.17
RUN set -eux; \
# save list of currently installed packages for later so we can clean up
savedAptMark="$(apt-mark showmanual)"; \
for i in 1 2 3; do \
apt-get update && \
apt-get install -y --no-install-recommends wget ca-certificates gnupg && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* && break || sleep 5; \
done; \
apt-get update; \
apt-get install -y --no-install-recommends ca-certificates gnupg wget; \
rm -rf /var/lib/apt/lists/*; \
\
dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \
wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \
@@ -17,18 +14,7 @@ RUN set -eux; \
\
# verify the signature
export GNUPGHOME="$(mktemp -d)"; \
for server in \
hkps://keys.openpgp.org \
ha.pool.sks-keyservers.net \
hkp://p80.pool.sks-keyservers.net:80 \
keyserver.ubuntu.com \
hkp://keyserver.ubuntu.com:80 \
pgp.mit.edu \
; do \
if gpg --batch --keyserver "$server" --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; then \
break; \
fi; \
done; \
gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \
gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
gpgconf --kill all; \
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \

View File

@@ -1,5 +1,5 @@
# Keep tracking base image
FROM rust:1.89-slim-bookworm
FROM rust:1.85-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"
### CI Docs
@@ -87,14 +87,8 @@ jobs:
image: lldap/rust-dev:latest
steps:
- name: Checkout repository
uses: actions/checkout@v6.0.2
- name: Install Rust
id: toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: "${{ env.MSRV }}"
targets: "wasm32-unknown-unknown"
- uses: actions/cache@v5
uses: actions/checkout@v4.2.2
- uses: actions/cache@v4
with:
path: |
/usr/local/cargo/bin
@@ -105,6 +99,8 @@ jobs:
key: lldap-ui-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
lldap-ui-
- name: Add wasm target (rust)
run: rustup target add wasm32-unknown-unknown
- name: Install wasm-pack with cargo
run: cargo install wasm-pack || true
env:
@@ -114,7 +110,7 @@ jobs:
- name: Check build path
run: ls -al app/
- name: Upload ui artifacts
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: ui
path: app/
@@ -136,14 +132,8 @@ jobs:
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
steps:
- name: Checkout repository
uses: actions/checkout@v6.0.2
- name: Install Rust
id: toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: "${{ env.MSRV }}"
targets: "${{ matrix.target }}"
- uses: actions/cache@v5
uses: actions/checkout@v4.2.2
- uses: actions/cache@v4
with:
path: |
.cargo/bin
@@ -159,17 +149,17 @@ jobs:
- name: Check path
run: ls -al target/release
- name: Upload ${{ matrix.target}} lldap artifacts
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.target}}-lldap-bin
path: target/${{ matrix.target }}/release/lldap
- name: Upload ${{ matrix.target }} migration tool artifacts
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
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@v7
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.target }}-lldap_set_password-bin
path: target/${{ matrix.target }}/release/lldap_set_password
@@ -209,7 +199,7 @@ jobs:
steps:
- name: Download artifacts
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
name: x86_64-unknown-linux-musl-lldap-bin
path: bin/
@@ -310,18 +300,18 @@ jobs:
steps:
- name: Checkout scripts
uses: actions/checkout@v6.0.2
uses: actions/checkout@v4.2.2
with:
sparse-checkout: 'scripts'
- name: Download LLDAP artifacts
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
name: x86_64-unknown-linux-musl-lldap-bin
path: bin/
- name: Download LLDAP set password
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
name: x86_64-unknown-linux-musl-lldap_set_password-bin
path: bin/
@@ -506,21 +496,21 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6.0.2
uses: actions/checkout@v4.2.2
- name: Download all artifacts
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
path: bin
- name: Download llap ui artifacts
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
name: ui
path: web
- name: Setup QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v3
- name: Setup buildx
uses: docker/setup-buildx-action@v3
with:
@@ -528,7 +518,7 @@ jobs:
- name: Docker ${{ matrix.container }} Base meta
id: meta-base
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
# list of Docker images to use as base name for tags
images: |
@@ -556,7 +546,7 @@ jobs:
- name: Docker ${{ matrix.container }}-rootless meta
id: meta-rootless
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
# list of Docker images to use as base name for tags
images: |
@@ -589,7 +579,7 @@ jobs:
- name: Docker ${{ matrix.container }} meta
id: meta-standard
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
# list of Docker images to use as base name for tags
images: |
@@ -691,7 +681,7 @@ jobs:
contents: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
path: bin/
- name: Check file
@@ -712,7 +702,7 @@ jobs:
chmod +x bin/*-lldap_set_password
- name: Download llap ui artifacts
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
name: ui
path: web

View File

@@ -8,7 +8,6 @@ on:
env:
CARGO_TERM_COLOR: always
MSRV: "1.89.0"
jobs:
pre_job:
@@ -34,19 +33,14 @@ jobs:
steps:
- name: Checkout sources
uses: actions/checkout@v6.0.2
- name: Install Rust
id: toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: "${{ env.MSRV }}"
uses: actions/checkout@v4.2.2
- uses: Swatinem/rust-cache@v2
- name: Build
run: cargo +${{steps.toolchain.outputs.name}} build --verbose --workspace
run: cargo build --verbose --workspace
- name: Run tests
run: cargo +${{steps.toolchain.outputs.name}} test --verbose --workspace
run: cargo test --verbose --workspace
- name: Generate GraphQL schema
run: cargo +${{steps.toolchain.outputs.name}} run -- export_graphql_schema -o generated_schema.graphql
run: cargo run -- export_graphql_schema -o generated_schema.graphql
- name: Check schema
run: diff schema.graphql generated_schema.graphql || (echo "The schema file is out of date. Please run `./export_schema.sh`" && false)
@@ -58,15 +52,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v6.0.2
- name: Install Rust
id: toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: "${{ env.MSRV }}"
components: clippy
uses: actions/checkout@v4.2.2
- uses: Swatinem/rust-cache@v2
- run: cargo +${{steps.toolchain.outputs.name}} clippy --tests --workspace -- -D warnings
- name: Run cargo clippy
uses: actions-rs/cargo@v1
with:
command: clippy
args: --tests --all -- -D warnings
format:
name: cargo fmt
@@ -75,15 +69,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v6.0.2
- name: Install Rust
id: toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: "${{ env.MSRV }}"
components: rustfmt
uses: actions/checkout@v4.2.2
- uses: Swatinem/rust-cache@v2
- run: cargo +${{steps.toolchain.outputs.name}} fmt --check --all
- name: Run cargo fmt
uses: actions-rs/cargo@v1
with:
command: fmt
args: --all -- --check
coverage:
name: Code coverage
@@ -94,7 +88,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v6.0.2
uses: actions/checkout@v4.2.2
- name: Install Rust
run: rustup toolchain install nightly --component llvm-tools-preview && rustup component add llvm-tools-preview --toolchain stable-x86_64-unknown-linux-gnu

5
.gitignore vendored
View File

@@ -29,8 +29,3 @@ recipe.json
lldap_config.toml
cert.pem
key.pem
# Nix
result
result-*
.direnv

View File

@@ -5,61 +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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.6.2] 2025-07-21
Small release, focused on LDAP improvements and ongoing maintenance.
### Added
- LDAP
- Support for searching groups by their `groupid`
- Support for `whoamiOID`
- Support for creating groups
- Support for subschema entry
- Custom assets path.
- New endpoint for requesting client settings
### Changed
- A missing JWT secret now prevents startup.
- Attributes with invalid characters (such as underscores) cannot be created anymore.
- Searching custom (string) attributes is now case insensitive.
- Using the top-level `firstName`, `lastName` and `avatar` GraphQL fields for users is now deprecated. Use the `attributes` field instead.
### Fixed
- `lldap_set_password` now uses the system's SSL certificates.
### Cleanups
- Split the main `lldap` crate into many sub-crates
- Various dependency version bumps
- Upgraded to 2024 Rust edition
- Docs/FAQ improvements
### Bootstrap script
- Custom attributes support
- Read the paswsord from a file
- Resilient to no user or group files
### New services
- Discord integration (Discord role to LLDAP user)
- HashiCorp
- Jellyfin 2FA with Duo
- Kimai
- Mailcow
- Peertube
- Penpot
- PgAdmin
- Project Quay
- Quadlet
- Snipe-IT
- SSSD
- Stalwart
- UnifiOS
## [0.6.1] 2024-11-22
Small release, mainly to fix a migration issue with Sqlite and Postgresql.

View File

@@ -46,9 +46,7 @@ advanced guides (scripting, migrations, ...) you can contribute to.
### Code
If you don't know what to start with, check out the
[good first issues](https://github.com/lldap/lldap/labels/good%20first%20issue).
For an alternative development environment setup, see [docs/nix-development.md](docs/nix-development.md).
[good first issues](https://github.com/lldap/lldap/labels/good%20first%20issue).
Otherwise, if you want to fix a specific bug or implement a feature, make sure
to start by creating an issue for it (if it doesn't already exist). There, we

2439
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,6 @@ edition = "2024"
homepage = "https://github.com/lldap/lldap"
license = "GPL-3.0-only"
repository = "https://github.com/lldap/lldap"
rust-version = "1.89.0"
[profile.release]
lto = true
@@ -24,6 +23,9 @@ lto = true
[profile.release.package.lldap_app]
opt-level = 's'
[patch.crates-io.lber]
git = 'https://github.com/inejge/ldap3/'
[workspace.dependencies.sea-orm]
version = "1.1.8"
default-features = false

View File

@@ -83,7 +83,7 @@ MySQL/MariaDB or PostgreSQL.
## Installation
It's possible to install lldap from OCI images ([docker](docs/install.md#with-docker)/[podman](docs/install.md#with-podman)), from [Kubernetes](docs/install.md#with-kubernetes), [TrueNAS](docs/install.md#truenas-scale), or from [a regular distribution package manager](docs/install.md/#from-a-package-repository) (Archlinux, Debian, CentOS, Fedora, OpenSuse, Ubuntu, FreeBSD).
It's possible to install lldap from OCI images ([docker](docs/install.md#with-docker)/[podman](docs/install.md#with-podman)), from [Kubernetes](docs/install.md#with-kubernetes), or from [a regular distribution package manager](docs/install.md/#from-a-package-repository) (Archlinux, Debian, CentOS, Fedora, OpenSuse, Ubuntu, FreeBSD).
Building [from source](docs/install.md#from-source) and [cross-compiling](docs/install.md#cross-compilation) to a different hardware architecture is also supported.
@@ -145,7 +145,7 @@ the relevant details (logs of the service, LLDAP logs with `verbose=true` in
the config).
Some specific clients have been tested to work and come with sample
configuration files, or guides. See the [`example_configs`](example_configs/README.md)
configuration files, or guides. See the [`example_configs`](example_configs)
folder for example configs for integration with specific services.
Integration with Linux accounts is possible, through PAM and nslcd. See [PAM
@@ -200,7 +200,7 @@ service that seems definitely incompatible with LLDAP.
- [I can't login](docs/faq.md#i-cant-log-in)
- [Discord Integration](docs/faq.md#discord-integration)
- [Migrating from SQLite](docs/faq.md#migrating-from-sqlite)
- How does lldap compare [with OpenLDAP](docs/faq.md#how-does-lldap-compare-with-openldap)? [With FreeIPA](docs/faq.md#how-does-lldap-compare-with-freeipa)? [With Kanidm](docs/faq.md#how-does-lldap-compare-with-kanidm)?
- How does lldap compare [with OpenLDAP](docs/faq.md#how-does-lldap-compare-with-openldap)? [With FreeIPA](docs/faq.md#how-does-lldap-compare-with-freeipa)? [With Kanidm]?(docs/faq.md#how-does-lldap-compare-with-kanidm)
- [Does lldap support vhosts?](docs/faq.md#does-lldap-support-vhosts)
- [Does lldap provide commercial support contracts?](docs/faq.md#does-lldap-provide-commercial-support-contracts)
- [Can I make a donation to fund development?](docs/faq.md#can-i-make-a-donation-to-fund-development)

View File

@@ -1,6 +1,6 @@
[package]
name = "lldap_app"
version = "0.6.2"
version = "0.6.2-alpha"
description = "Frontend for LLDAP"
edition.workspace = true
include = ["src/**/*", "queries/**/*", "Cargo.toml", "../schema.graphql"]
@@ -8,7 +8,6 @@ authors.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
[dependencies]
anyhow = "1"
@@ -56,11 +55,6 @@ 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" ]
@@ -79,10 +73,6 @@ 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

@@ -197,19 +197,17 @@ impl App {
<CreateUserForm/>
},
AppRoute::Index | AppRoute::ListUsers => {
let user_button = |key| {
html! {
<Link classes="btn btn-primary" key={key} to={AppRoute::CreateUser}>
<i class="bi-person-plus me-2"></i>
{"Create a user"}
</Link>
}
let user_button = html! {
<Link classes="btn btn-primary" to={AppRoute::CreateUser}>
<i class="bi-person-plus me-2"></i>
{"Create a user"}
</Link>
};
html! {
<div>
{ user_button("top-create-user") }
{ user_button.clone() }
<UserTable />
{ user_button("bottom-create-user") }
{ user_button }
</div>
}
}
@@ -223,19 +221,19 @@ impl App {
<CreateGroupAttributeForm/>
},
AppRoute::ListGroups => {
let group_button = |key| {
html! {
<Link classes="btn btn-primary" key={key} to={AppRoute::CreateGroup}>
<i class="bi-plus-circle me-2"></i>
{"Create a group"}
</Link>
}
let group_button = html! {
<Link classes="btn btn-primary" to={AppRoute::CreateGroup}>
<i class="bi-plus-circle me-2"></i>
{"Create a group"}
</Link>
};
// Note: There's a weird bug when switching from the users page to the groups page
// where the two groups buttons are at the bottom. I don't know why.
html! {
<div>
{ group_button("top-create-group") }
{ group_button.clone() }
<GroupTable />
{ group_button("bottom-create-group") }
{ group_button }
</div>
}
}

View File

@@ -7,6 +7,7 @@ use crate::{
},
router::AppRoute,
},
convert_attribute_type,
infra::{
common_component::{CommonComponent, CommonComponentParts},
form_utils::{
@@ -29,8 +30,7 @@ use yew_router::{prelude::History, scope_ext::RouterScopeExt};
schema_path = "../schema.graphql",
query_path = "queries/get_group_attributes_schema.graphql",
response_derives = "Debug,Clone,PartialEq,Eq",
custom_scalars_module = "crate::infra::graphql",
extern_enums("AttributeType")
custom_scalars_module = "crate::infra::graphql"
)]
pub struct GetGroupAttributesSchema;
@@ -39,6 +39,8 @@ use get_group_attributes_schema::ResponseData;
pub type Attribute =
get_group_attributes_schema::GetGroupAttributesSchemaSchemaGroupSchemaAttributes;
convert_attribute_type!(get_group_attributes_schema::AttributeType);
impl From<&Attribute> for GraphQlAttributeSchema {
fn from(attr: &Attribute) -> Self {
Self {
@@ -216,14 +218,14 @@ fn get_custom_attribute_input(attribute_schema: &Attribute) -> Html {
html! {
<ListAttributeInput
name={attribute_schema.name.clone()}
attribute_type={attribute_schema.attribute_type}
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
/>
}
} else {
html! {
<SingleAttributeInput
name={attribute_schema.name.clone()}
attribute_type={attribute_schema.attribute_type}
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
/>
}
}

View File

@@ -3,6 +3,7 @@ use crate::{
form::{checkbox::CheckBox, field::Field, select::Select, submit::Submit},
router::AppRoute,
},
convert_attribute_type,
infra::{
common_component::{CommonComponent, CommonComponentParts},
schema::{AttributeType, validate_attribute_type},
@@ -22,11 +23,12 @@ use yew_router::{prelude::History, scope_ext::RouterScopeExt};
schema_path = "../schema.graphql",
query_path = "queries/create_group_attribute.graphql",
response_derives = "Debug",
custom_scalars_module = "crate::infra::graphql",
extern_enums("AttributeType")
custom_scalars_module = "crate::infra::graphql"
)]
pub struct CreateGroupAttribute;
convert_attribute_type!(create_group_attribute::AttributeType);
pub struct CreateGroupAttributeForm {
common: CommonComponentParts<Self>,
form: yew_form::Form<CreateGroupAttributeModel>,
@@ -68,11 +70,10 @@ impl CommonComponent<CreateGroupAttributeForm> for CreateGroupAttributeForm {
invalid
);
})?;
let attribute_type =
AttributeType::try_from(model.attribute_type.as_str()).unwrap();
let attribute_type = model.attribute_type.parse::<AttributeType>().unwrap();
let req = create_group_attribute::Variables {
name: model.attribute_name,
attribute_type,
attribute_type: create_group_attribute::AttributeType::from(attribute_type),
is_list: model.is_list,
is_visible: model.is_visible,
};
@@ -144,7 +145,7 @@ impl Component for CreateGroupAttributeForm {
oninput={link.callback(|_| Msg::Update)}>
<option selected=true value="String">{"String"}</option>
<option value="Integer">{"Integer"}</option>
<option value="JpegPhoto">{"Jpeg"}</option>
<option value="Jpeg">{"Jpeg"}</option>
<option value="DateTime">{"DateTime"}</option>
</Select<CreateGroupAttributeModel>>
<CheckBox<CreateGroupAttributeModel>

View File

@@ -7,6 +7,7 @@ use crate::{
},
router::AppRoute,
},
convert_attribute_type,
infra::{
api::HostService,
common_component::{CommonComponent, CommonComponentParts},
@@ -31,8 +32,7 @@ use yew_router::{prelude::History, scope_ext::RouterScopeExt};
schema_path = "../schema.graphql",
query_path = "queries/get_user_attributes_schema.graphql",
response_derives = "Debug,Clone,PartialEq,Eq",
custom_scalars_module = "crate::infra::graphql",
extern_enums("AttributeType")
custom_scalars_module = "crate::infra::graphql"
)]
pub struct GetUserAttributesSchema;
@@ -40,6 +40,8 @@ use get_user_attributes_schema::ResponseData;
pub type Attribute = get_user_attributes_schema::GetUserAttributesSchemaSchemaUserSchemaAttributes;
convert_attribute_type!(get_user_attributes_schema::AttributeType);
impl From<&Attribute> for GraphQlAttributeSchema {
fn from(attr: &Attribute) -> Self {
Self {
@@ -304,22 +306,18 @@ impl Component for CreateUserForm {
}
fn get_custom_attribute_input(attribute_schema: &Attribute) -> Html {
let mail_is_required = attribute_schema.name.as_str() == "mail";
if attribute_schema.is_list {
html! {
<ListAttributeInput
name={attribute_schema.name.clone()}
attribute_type={attribute_schema.attribute_type}
required={mail_is_required}
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
/>
}
} else {
html! {
<SingleAttributeInput
name={attribute_schema.name.clone()}
attribute_type={attribute_schema.attribute_type}
required={mail_is_required}
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
/>
}
}

View File

@@ -3,6 +3,7 @@ use crate::{
form::{checkbox::CheckBox, field::Field, select::Select, submit::Submit},
router::AppRoute,
},
convert_attribute_type,
infra::{
common_component::{CommonComponent, CommonComponentParts},
schema::{AttributeType, validate_attribute_type},
@@ -22,11 +23,12 @@ use yew_router::{prelude::History, scope_ext::RouterScopeExt};
schema_path = "../schema.graphql",
query_path = "queries/create_user_attribute.graphql",
response_derives = "Debug",
custom_scalars_module = "crate::infra::graphql",
extern_enums("AttributeType")
custom_scalars_module = "crate::infra::graphql"
)]
pub struct CreateUserAttribute;
convert_attribute_type!(create_user_attribute::AttributeType);
pub struct CreateUserAttributeForm {
common: CommonComponentParts<Self>,
form: yew_form::Form<CreateUserAttributeModel>,
@@ -72,11 +74,10 @@ impl CommonComponent<CreateUserAttributeForm> for CreateUserAttributeForm {
invalid
);
})?;
let attribute_type =
AttributeType::try_from(model.attribute_type.as_str()).unwrap();
let attribute_type = model.attribute_type.parse::<AttributeType>().unwrap();
let req = create_user_attribute::Variables {
name: model.attribute_name,
attribute_type,
attribute_type: create_user_attribute::AttributeType::from(attribute_type),
is_editable: model.is_editable,
is_list: model.is_list,
is_visible: model.is_visible,
@@ -146,7 +147,7 @@ impl Component for CreateUserAttributeForm {
oninput={link.callback(|_| Msg::Update)}>
<option selected=true value="String">{"String"}</option>
<option value="Integer">{"Integer"}</option>
<option value="JpegPhoto">{"Jpeg"}</option>
<option value="Jpeg">{"Jpeg"}</option>
<option value="DateTime">{"DateTime"}</option>
</Select<CreateUserAttributeModel>>
<CheckBox<CreateUserAttributeModel>

View File

@@ -26,7 +26,7 @@ fn attribute_input(props: &AttributeInputProps) -> Html {
<DateTimeInput name={props.name.clone()} value={props.value.clone()} />
};
}
AttributeType::JpegPhoto => {
AttributeType::Jpeg => {
return html! {
<JpegFileInput name={props.name.clone()} value={props.value.clone()} />
};
@@ -45,8 +45,6 @@ fn attribute_input(props: &AttributeInputProps) -> Html {
#[derive(Properties, PartialEq)]
struct AttributeLabelProps {
pub name: String,
#[prop_or(false)]
pub required: bool,
}
#[function_component(AttributeLabel)]
fn attribute_label(props: &AttributeLabelProps) -> Html {
@@ -68,9 +66,7 @@ fn attribute_label(props: &AttributeLabelProps) -> Html {
<label for={props.name.clone()}
class="form-label col-4 col-form-label"
>
{props.name[0..1].to_uppercase() + &props.name[1..].replace('_', " ")}
{if props.required { html!{<span class="text-danger">{"*"}</span>} } else { html!{} }}
{":"}
{props.name[0..1].to_uppercase() + &props.name[1..].replace('_', " ")}{":"}
<button
class="btn btn-sm btn-link"
type="button"
@@ -86,21 +82,19 @@ fn attribute_label(props: &AttributeLabelProps) -> Html {
#[derive(Properties, PartialEq)]
pub struct SingleAttributeInputProps {
pub name: String,
pub(crate) attribute_type: AttributeType,
pub attribute_type: AttributeType,
#[prop_or(None)]
pub value: Option<String>,
#[prop_or(false)]
pub required: bool,
}
#[function_component(SingleAttributeInput)]
pub fn single_attribute_input(props: &SingleAttributeInputProps) -> Html {
html! {
<div class="row mb-3">
<AttributeLabel name={props.name.clone()} required={props.required} />
<AttributeLabel name={props.name.clone()} />
<div class="col-8">
<AttributeInput
attribute_type={props.attribute_type}
attribute_type={props.attribute_type.clone()}
name={props.name.clone()}
value={props.value.clone()} />
</div>
@@ -111,11 +105,9 @@ pub fn single_attribute_input(props: &SingleAttributeInputProps) -> Html {
#[derive(Properties, PartialEq)]
pub struct ListAttributeInputProps {
pub name: String,
pub(crate) attribute_type: AttributeType,
pub attribute_type: AttributeType,
#[prop_or(vec!())]
pub values: Vec<String>,
#[prop_or(false)]
pub required: bool,
}
pub enum ListAttributeInputMsg {
@@ -168,12 +160,12 @@ impl Component for ListAttributeInput {
let link = &ctx.link();
html! {
<div class="row mb-3">
<AttributeLabel name={props.name.clone()} required={props.required} />
<AttributeLabel name={props.name.clone()} />
<div class="col-8">
{self.indices.iter().map(|&i| html! {
<div class="input-group mb-2" key={i}>
<AttributeInput
attribute_type={props.attribute_type}
attribute_type={props.attribute_type.clone()}
name={props.name.clone()}
value={props.values.get(i).cloned().unwrap_or_default()} />
<button

View File

@@ -147,18 +147,20 @@ impl Component for JpegFileInput {
true
}
Msg::FileLoaded(file_name, data) => {
if let Some(avatar) = &mut self.avatar
&& let Some(file) = &avatar.file
&& file.name() == file_name
&& let Result::Ok(data) = data
{
if !is_valid_jpeg(data.as_slice()) {
// Clear the selection.
self.avatar = Some(JsFile::default());
// TODO: bail!("Chosen image is not a valid JPEG");
} else {
avatar.contents = Some(data);
return true;
if let Some(avatar) = &mut self.avatar {
if let Some(file) = &avatar.file {
if file.name() == file_name {
if let Result::Ok(data) = data {
if !is_valid_jpeg(data.as_slice()) {
// Clear the selection.
self.avatar = Some(JsFile::default());
// TODO: bail!("Chosen image is not a valid JPEG");
} else {
avatar.contents = Some(data);
return true;
}
}
}
}
}
self.reader = None;

View File

@@ -5,10 +5,10 @@ use crate::{
remove_user_from_group::RemoveUserFromGroupComponent,
router::{AppRoute, Link},
},
convert_attribute_type,
infra::{
common_component::{CommonComponent, CommonComponentParts},
form_utils::GraphQlAttributeSchema,
schema::AttributeType,
},
};
use anyhow::{Error, Result, bail};
@@ -20,8 +20,7 @@ use yew::prelude::*;
schema_path = "../schema.graphql",
query_path = "queries/get_group_details.graphql",
response_derives = "Debug, Hash, PartialEq, Eq, Clone",
custom_scalars_module = "crate::infra::graphql",
extern_enums("AttributeType")
custom_scalars_module = "crate::infra::graphql"
)]
pub struct GetGroupDetails;
@@ -30,6 +29,9 @@ pub type User = get_group_details::GetGroupDetailsGroupUsers;
pub type AddGroupMemberUser = add_group_member::User;
pub type Attribute = get_group_details::GetGroupDetailsGroupAttributes;
pub type AttributeSchema = get_group_details::GetGroupDetailsSchemaGroupSchemaAttributes;
pub type AttributeType = get_group_details::AttributeType;
convert_attribute_type!(AttributeType);
impl From<&AttributeSchema> for GraphQlAttributeSchema {
fn from(attr: &AttributeSchema) -> Self {

View File

@@ -10,6 +10,7 @@ use crate::{
infra::{
common_component::{CommonComponent, CommonComponentParts},
form_utils::{AttributeValue, EmailIsRequired, IsAdmin, read_all_form_attributes},
schema::AttributeType,
},
};
use anyhow::{Ok, Result};
@@ -173,7 +174,7 @@ fn get_custom_attribute_input(
html! {
<ListAttributeInput
name={attribute_schema.name.clone()}
attribute_type={attribute_schema.attribute_type}
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
values={values}
/>
}
@@ -181,7 +182,7 @@ fn get_custom_attribute_input(
html! {
<SingleAttributeInput
name={attribute_schema.name.clone()}
attribute_type={attribute_schema.attribute_type}
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
value={values.first().cloned().unwrap_or_default()}
/>
}

View File

@@ -4,6 +4,7 @@ use crate::{
fragments::attribute_schema::render_attribute_name,
router::{AppRoute, Link},
},
convert_attribute_type,
infra::{
attributes::group,
common_component::{CommonComponent, CommonComponentParts},
@@ -20,8 +21,7 @@ use yew::prelude::*;
schema_path = "../schema.graphql",
query_path = "queries/get_group_attributes_schema.graphql",
response_derives = "Debug,Clone,PartialEq,Eq",
custom_scalars_module = "crate::infra::graphql",
extern_enums("AttributeType")
custom_scalars_module = "crate::infra::graphql"
)]
pub struct GetGroupAttributesSchema;
@@ -30,6 +30,8 @@ use get_group_attributes_schema::ResponseData;
pub type Attribute =
get_group_attributes_schema::GetGroupAttributesSchemaSchemaGroupSchemaAttributes;
convert_attribute_type!(get_group_attributes_schema::AttributeType);
#[derive(yew::Properties, Clone, PartialEq, Eq)]
pub struct Props {
pub hardcoded: bool,
@@ -145,7 +147,7 @@ impl GroupSchemaTable {
fn view_attribute(&self, ctx: &Context<Self>, attribute: &Attribute) -> Html {
let link = ctx.link();
let attribute_type = attribute.attribute_type;
let attribute_type = AttributeType::from(attribute.attribute_type.clone());
let checkmark = html! {
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check" viewBox="0 0 16 16">
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z"></path>

View File

@@ -27,7 +27,7 @@ pub struct LoginForm {
pub struct FormModel {
#[validate(length(min = 1, message = "Missing username"))]
username: String,
#[validate(length(min = 1, message = "Missing password"))]
#[validate(length(min = 8, message = "Invalid password. Min length: 8"))]
password: String,
}

View File

@@ -5,10 +5,10 @@ use crate::{
router::{AppRoute, Link},
user_details_form::UserDetailsForm,
},
convert_attribute_type,
infra::{
common_component::{CommonComponent, CommonComponentParts},
form_utils::GraphQlAttributeSchema,
schema::AttributeType,
},
};
use anyhow::{Error, Result, bail};
@@ -20,8 +20,7 @@ use yew::prelude::*;
schema_path = "../schema.graphql",
query_path = "queries/get_user_details.graphql",
response_derives = "Debug, Hash, PartialEq, Eq, Clone",
custom_scalars_module = "crate::infra::graphql",
extern_enums("AttributeType")
custom_scalars_module = "crate::infra::graphql"
)]
pub struct GetUserDetails;
@@ -29,6 +28,9 @@ pub type User = get_user_details::GetUserDetailsUser;
pub type Group = get_user_details::GetUserDetailsUserGroups;
pub type Attribute = get_user_details::GetUserDetailsUserAttributes;
pub type AttributeSchema = get_user_details::GetUserDetailsSchemaUserSchemaAttributes;
pub type AttributeType = get_user_details::AttributeType;
convert_attribute_type!(AttributeType);
impl From<&AttributeSchema> for GraphQlAttributeSchema {
fn from(attr: &AttributeSchema) -> Self {

View File

@@ -14,7 +14,6 @@ use crate::{
},
};
use anyhow::{Ok, Result};
use gloo_console::console;
use graphql_client::GraphQLQuery;
use yew::prelude::*;
@@ -169,7 +168,7 @@ fn get_custom_attribute_input(
html! {
<ListAttributeInput
name={attribute_schema.name.clone()}
attribute_type={attribute_schema.attribute_type}
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
values={values}
/>
}
@@ -177,7 +176,7 @@ fn get_custom_attribute_input(
html! {
<SingleAttributeInput
name={attribute_schema.name.clone()}
attribute_type={attribute_schema.attribute_type}
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
value={values.first().cloned().unwrap_or_default()}
/>
}
@@ -193,19 +192,9 @@ fn get_custom_attribute_static(
.find(|a| a.name == attribute_schema.name)
.map(|attribute| attribute.value.clone())
.unwrap_or_default();
let value_to_str = match attribute_schema.attribute_type {
AttributeType::String | AttributeType::Integer => |v: String| v,
AttributeType::DateTime => |v: String| {
console!(format!("Parsing date: {}", &v));
chrono::DateTime::parse_from_rfc3339(&v)
.map(|dt| dt.naive_utc().to_string())
.unwrap_or_else(|_| "Invalid date".to_string())
},
AttributeType::JpegPhoto => |_: String| "Unimplemented JPEG display".to_string(),
};
html! {
<StaticValue label={attribute_schema.name.clone()} id={attribute_schema.name.clone()}>
{values.into_iter().map(|x| html!{<div>{value_to_str(x)}</div>}).collect::<Vec<_>>()}
{values.into_iter().map(|x| html!{<div>{x}</div>}).collect::<Vec<_>>()}
</StaticValue>
}
}

View File

@@ -4,6 +4,7 @@ use crate::{
fragments::attribute_schema::render_attribute_name,
router::{AppRoute, Link},
},
convert_attribute_type,
infra::{
attributes::user,
common_component::{CommonComponent, CommonComponentParts},
@@ -20,8 +21,7 @@ use yew::prelude::*;
schema_path = "../schema.graphql",
query_path = "queries/get_user_attributes_schema.graphql",
response_derives = "Debug,Clone,PartialEq,Eq",
custom_scalars_module = "crate::infra::graphql",
extern_enums("AttributeType")
custom_scalars_module = "crate::infra::graphql"
)]
pub struct GetUserAttributesSchema;
@@ -29,6 +29,8 @@ use get_user_attributes_schema::ResponseData;
pub type Attribute = get_user_attributes_schema::GetUserAttributesSchemaSchemaUserSchemaAttributes;
convert_attribute_type!(get_user_attributes_schema::AttributeType);
#[derive(yew::Properties, Clone, PartialEq, Eq)]
pub struct Props {
pub hardcoded: bool,
@@ -144,7 +146,7 @@ impl UserSchemaTable {
fn view_attribute(&self, ctx: &Context<Self>, attribute: &Attribute) -> Html {
let link = ctx.link();
let attribute_type = attribute.attribute_type;
let attribute_type = AttributeType::from(attribute.attribute_type.clone());
let checkmark = html! {
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check" viewBox="0 0 16 16">
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z"></path>

View File

@@ -8,17 +8,12 @@ pub mod group {
use super::AttributeDescription;
pub fn resolve_group_attribute_description(name: &'_ str) -> Option<AttributeDescription<'_>> {
pub fn resolve_group_attribute_description(name: &str) -> Option<AttributeDescription> {
match name {
"creation_date" => Some(AttributeDescription {
attribute_identifier: name,
attribute_name: "creationdate",
aliases: vec![name, "createtimestamp"],
}),
"modified_date" => Some(AttributeDescription {
attribute_identifier: name,
attribute_name: "modifydate",
aliases: vec![name, "modifytimestamp"],
aliases: vec![name, "createtimestamp", "modifytimestamp"],
}),
"display_name" => Some(AttributeDescription {
attribute_identifier: name,
@@ -39,9 +34,7 @@ pub mod group {
}
}
pub fn resolve_group_attribute_description_or_default(
name: &'_ str,
) -> AttributeDescription<'_> {
pub fn resolve_group_attribute_description_or_default(name: &str) -> AttributeDescription {
match resolve_group_attribute_description(name) {
Some(d) => d,
None => AttributeDescription {
@@ -57,7 +50,7 @@ pub mod user {
use super::AttributeDescription;
pub fn resolve_user_attribute_description(name: &'_ str) -> Option<AttributeDescription<'_>> {
pub fn resolve_user_attribute_description(name: &str) -> Option<AttributeDescription> {
match name {
"avatar" => Some(AttributeDescription {
attribute_identifier: name,
@@ -67,17 +60,7 @@ pub mod user {
"creation_date" => Some(AttributeDescription {
attribute_identifier: name,
attribute_name: "creationdate",
aliases: vec![name, "createtimestamp"],
}),
"modified_date" => Some(AttributeDescription {
attribute_identifier: name,
attribute_name: "modifydate",
aliases: vec![name, "modifytimestamp"],
}),
"password_modified_date" => Some(AttributeDescription {
attribute_identifier: name,
attribute_name: "passwordmodifydate",
aliases: vec![name, "pwdchangedtime"],
aliases: vec![name, "createtimestamp", "modifytimestamp"],
}),
"display_name" => Some(AttributeDescription {
attribute_identifier: name,
@@ -113,9 +96,7 @@ pub mod user {
}
}
pub fn resolve_user_attribute_description_or_default(
name: &'_ str,
) -> AttributeDescription<'_> {
pub fn resolve_user_attribute_description_or_default(name: &str) -> AttributeDescription {
match resolve_user_attribute_description(name) {
Some(d) => d,
None => AttributeDescription {

View File

@@ -1,42 +1,66 @@
use derive_more::Display;
use serde::{Deserialize, Serialize};
use strum::EnumString;
use anyhow::Result;
use std::{fmt::Display, str::FromStr};
use validator::ValidationError;
#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq, Hash, EnumString, Display)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[strum(ascii_case_insensitive)]
pub(crate) enum AttributeType {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AttributeType {
String,
Integer,
#[strum(serialize = "DATE_TIME", serialize = "DATETIME")]
DateTime,
#[strum(serialize = "JPEG_PHOTO", serialize = "JPEGPHOTO")]
JpegPhoto,
Jpeg,
}
impl Display for AttributeType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
impl FromStr for AttributeType {
type Err = ();
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"String" => Ok(AttributeType::String),
"Integer" => Ok(AttributeType::Integer),
"DateTime" => Ok(AttributeType::DateTime),
"Jpeg" => Ok(AttributeType::Jpeg),
_ => Err(()),
}
}
}
// Macro to generate traits for converting between AttributeType and the
// graphql generated equivalents.
#[macro_export]
macro_rules! convert_attribute_type {
($source_type:ty) => {
impl From<$source_type> for $crate::infra::schema::AttributeType {
fn from(value: $source_type) -> Self {
match value {
<$source_type>::STRING => $crate::infra::schema::AttributeType::String,
<$source_type>::INTEGER => $crate::infra::schema::AttributeType::Integer,
<$source_type>::DATE_TIME => $crate::infra::schema::AttributeType::DateTime,
<$source_type>::JPEG_PHOTO => $crate::infra::schema::AttributeType::Jpeg,
_ => panic!("Unknown attribute type"),
}
}
}
impl From<$crate::infra::schema::AttributeType> for $source_type {
fn from(value: $crate::infra::schema::AttributeType) -> Self {
match value {
$crate::infra::schema::AttributeType::String => <$source_type>::STRING,
$crate::infra::schema::AttributeType::Integer => <$source_type>::INTEGER,
$crate::infra::schema::AttributeType::DateTime => <$source_type>::DATE_TIME,
$crate::infra::schema::AttributeType::Jpeg => <$source_type>::JPEG_PHOTO,
}
}
}
};
}
pub fn validate_attribute_type(attribute_type: &str) -> Result<(), ValidationError> {
AttributeType::try_from(attribute_type)
AttributeType::from_str(attribute_type)
.map_err(|_| ValidationError::new("Invalid attribute type"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_deserialize_attribute_type() {
let attr_type: AttributeType = "STRING".try_into().unwrap();
assert_eq!(attr_type, AttributeType::String);
let attr_type: AttributeType = "Integer".try_into().unwrap();
assert_eq!(attr_type, AttributeType::Integer);
let attr_type: AttributeType = "DATE_TIME".try_into().unwrap();
assert_eq!(attr_type, AttributeType::DateTime);
let attr_type: AttributeType = "JpegPhoto".try_into().unwrap();
assert_eq!(attr_type, AttributeType::JpegPhoto);
}
}

View File

@@ -2,7 +2,6 @@
#![forbid(non_ascii_idents)]
#![allow(clippy::uninlined_format_args)]
#![allow(clippy::let_unit_value)]
#![allow(clippy::unnecessary_operation)] // Doesn't work well with the html macro.
pub mod components;
pub mod infra;

View File

@@ -7,7 +7,6 @@ edition.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
[dependencies]
tracing = "*"

View File

@@ -7,7 +7,6 @@ authors.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
[features]
default = ["opaque_server", "opaque_client"]
@@ -25,7 +24,6 @@ generic-array = "0.14"
rand = "0.8"
sha2 = "0.9"
thiserror = "2"
uuid = { version = "1.18.1", features = ["serde"] }
[dependencies.derive_more]
features = ["debug", "display"]

View File

@@ -4,7 +4,6 @@ use chrono::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fmt;
use uuid::Uuid;
pub mod access_control;
pub mod opaque;
@@ -209,11 +208,8 @@ pub mod types {
#[derive(Clone, Serialize, Deserialize)]
pub struct JWTClaims {
#[serde(with = "chrono::serde::ts_seconds")]
pub exp: DateTime<Utc>,
#[serde(with = "chrono::serde::ts_seconds")]
pub iat: DateTime<Utc>,
pub jti: Uuid,
pub user: String,
pub groups: HashSet<String>,
}

View File

@@ -6,7 +6,6 @@ authors.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
[features]
test = []

View File

@@ -6,7 +6,6 @@ authors.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
[features]
test = []

View File

@@ -14,7 +14,6 @@ pub struct Model {
pub lowercase_display_name: String,
pub creation_date: chrono::NaiveDateTime,
pub uuid: Uuid,
pub modified_date: chrono::NaiveDateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
@@ -40,7 +39,6 @@ impl From<Model> for lldap_domain::types::Group {
uuid: group.uuid,
users: vec![],
attributes: Vec::new(),
modified_date: group.modified_date,
}
}
}
@@ -53,7 +51,6 @@ impl From<Model> for lldap_domain::types::GroupDetails {
creation_date: group.creation_date,
uuid: group.uuid,
attributes: Vec::new(),
modified_date: group.modified_date,
}
}
}

View File

@@ -21,8 +21,6 @@ pub struct Model {
pub totp_secret: Option<String>,
pub mfa_type: Option<String>,
pub uuid: Uuid,
pub modified_date: chrono::NaiveDateTime,
pub password_modified_date: chrono::NaiveDateTime,
}
impl EntityName for Entity {
@@ -42,8 +40,6 @@ pub enum Column {
TotpSecret,
MfaType,
Uuid,
ModifiedDate,
PasswordModifiedDate,
}
impl ColumnTrait for Column {
@@ -60,8 +56,6 @@ impl ColumnTrait for Column {
Column::TotpSecret => ColumnType::String(StringLen::N(64)),
Column::MfaType => ColumnType::String(StringLen::N(64)),
Column::Uuid => ColumnType::String(StringLen::N(36)),
Column::ModifiedDate => ColumnType::DateTime,
Column::PasswordModifiedDate => ColumnType::DateTime,
}
.def()
}
@@ -127,8 +121,6 @@ impl From<Model> for lldap_domain::types::User {
creation_date: user.creation_date,
uuid: user.uuid,
attributes: Vec::new(),
modified_date: user.modified_date,
password_modified_date: user.password_modified_date,
}
}
}

View File

@@ -9,7 +9,6 @@ edition.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
[features]
test = []

View File

@@ -12,11 +12,11 @@ pub fn deserialize_attribute_value(
let parse_int = |value: &String| -> Result<i64> {
value
.parse::<i64>()
.with_context(|| format!("Invalid integer value {value}"))
.with_context(|| format!("Invalid integer value {}", value))
};
let parse_date = |value: &String| -> Result<chrono::NaiveDateTime> {
Ok(chrono::DateTime::parse_from_rfc3339(value)
.with_context(|| format!("Invalid date value {value}"))?
.with_context(|| format!("Invalid date value {}", value))?
.naive_utc())
};
let parse_photo = |value: &String| -> Result<JpegPhoto> {

View File

@@ -34,24 +34,6 @@ impl From<Schema> for PublicSchema {
is_hardcoded: true,
is_readonly: true,
},
AttributeSchema {
name: "modified_date".into(),
attribute_type: AttributeType::DateTime,
is_list: false,
is_visible: true,
is_editable: false,
is_hardcoded: true,
is_readonly: true,
},
AttributeSchema {
name: "password_modified_date".into(),
attribute_type: AttributeType::DateTime,
is_list: false,
is_visible: true,
is_editable: false,
is_hardcoded: true,
is_readonly: true,
},
AttributeSchema {
name: "mail".into(),
attribute_type: AttributeType::String,
@@ -103,15 +85,6 @@ impl From<Schema> for PublicSchema {
is_hardcoded: true,
is_readonly: true,
},
AttributeSchema {
name: "modified_date".into(),
attribute_type: AttributeType::DateTime,
is_list: false,
is_visible: true,
is_editable: false,
is_hardcoded: true,
is_readonly: true,
},
AttributeSchema {
name: "uuid".into(),
attribute_type: AttributeType::String,

View File

@@ -7,8 +7,8 @@ use sea_orm::{
DbErr, DeriveValueType, QueryResult, TryFromU64, TryGetError, TryGetable, Value,
entity::IntoActiveValue,
sea_query::{
ArrayType, ColumnType, SeaRc, StringLen, ValueTypeErr, extension::mysql::MySqlType,
value::ValueType,
ArrayType, ColumnType, Nullable, SeaRc, StringLen, ValueTypeErr,
extension::mysql::MySqlType, value::ValueType,
},
};
use serde::{Deserialize, Serialize};
@@ -377,7 +377,7 @@ impl std::fmt::Debug for JpegPhoto {
encoded.push_str(" ...");
};
f.debug_tuple("JpegPhoto")
.field(&format!("b64[{encoded}]"))
.field(&format!("b64[{}]", encoded))
.finish()
}
}
@@ -415,6 +415,12 @@ impl JpegPhoto {
}
}
impl Nullable for JpegPhoto {
fn null() -> Value {
JpegPhoto::null().into()
}
}
impl IntoActiveValue<Serialized> for JpegPhoto {
fn into_active_value(self) -> sea_orm::ActiveValue<Serialized> {
if self.is_empty() {
@@ -540,8 +546,6 @@ pub struct User {
pub creation_date: NaiveDateTime,
pub uuid: Uuid,
pub attributes: Vec<Attribute>,
pub modified_date: NaiveDateTime,
pub password_modified_date: NaiveDateTime,
}
#[cfg(feature = "test")]
@@ -555,8 +559,6 @@ impl Default for User {
creation_date: epoch,
uuid: Uuid::from_name_and_date("", &epoch),
attributes: Vec::new(),
modified_date: epoch,
password_modified_date: epoch,
}
}
}
@@ -652,7 +654,6 @@ pub struct Group {
pub uuid: Uuid,
pub users: Vec<UserId>,
pub attributes: Vec<Attribute>,
pub modified_date: NaiveDateTime,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -662,7 +663,6 @@ pub struct GroupDetails {
pub creation_date: NaiveDateTime,
pub uuid: Uuid,
pub attributes: Vec<Attribute>,
pub modified_date: NaiveDateTime,
}
#[derive(Debug, Clone, PartialEq, Eq)]

View File

@@ -7,7 +7,6 @@ edition.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
[dependencies.serde]
workspace = true

View File

@@ -7,7 +7,6 @@ authors.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
[dependencies]
anyhow = "*"

View File

@@ -75,7 +75,7 @@ pub fn export_schema(output_file: Option<String>) -> anyhow::Result<()> {
use lldap_sql_backend_handler::SqlBackendHandler;
let output = schema::<SqlBackendHandler>().as_schema_language();
match output_file {
None => println!("{output}"),
None => println!("{}", output),
Some(path) => {
use std::fs::File;
use std::io::prelude::*;

View File

@@ -1,30 +1,27 @@
pub mod helpers;
pub mod inputs;
// Re-export public types
pub use inputs::{
AttributeValue, CreateGroupInput, CreateUserInput, Success, UpdateGroupInput, UpdateUserInput,
};
use crate::api::{Context, field_error_callback};
use anyhow::anyhow;
use juniper::{FieldError, FieldResult, graphql_object};
use anyhow::{Context as AnyhowContext, anyhow};
use juniper::{FieldError, FieldResult, GraphQLInputObject, GraphQLObject, graphql_object};
use lldap_access_control::{
AdminBackendHandler, UserReadableBackendHandler, UserWriteableBackendHandler,
AdminBackendHandler, ReadonlyBackendHandler, UserReadableBackendHandler,
UserWriteableBackendHandler,
};
use lldap_domain::{
requests::{CreateAttributeRequest, CreateUserRequest, UpdateGroupRequest, UpdateUserRequest},
types::{AttributeName, AttributeType, Email, GroupId, LdapObjectClass, UserId},
deserialize::deserialize_attribute_value,
public_schema::PublicSchema,
requests::{
CreateAttributeRequest, CreateGroupRequest, CreateUserRequest, UpdateGroupRequest,
UpdateUserRequest,
},
schema::AttributeList,
types::{
Attribute as DomainAttribute, AttributeName, AttributeType, Email, GroupId,
LdapObjectClass, UserId,
},
};
use lldap_domain_handlers::handler::BackendHandler;
use lldap_validation::attributes::{ALLOWED_CHARACTERS_DESCRIPTION, validate_attribute_name};
use std::sync::Arc;
use tracing::{Instrument, debug, debug_span};
use helpers::{
UnpackedAttributes, consolidate_attributes, create_group_with_details, deserialize_attribute,
unpack_attributes,
};
use std::{collections::BTreeMap, sync::Arc};
use tracing::{Instrument, Span, debug, debug_span};
#[derive(PartialEq, Eq, Debug)]
/// The top-level GraphQL mutation type.
@@ -45,6 +42,183 @@ impl<Handler: BackendHandler> Mutation<Handler> {
}
}
}
#[derive(Clone, PartialEq, Eq, Debug, GraphQLInputObject)]
// This conflicts with the attribute values returned by the user/group queries.
#[graphql(name = "AttributeValueInput")]
struct AttributeValue {
/// 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: Vec<String>,
}
#[derive(PartialEq, Eq, Debug, GraphQLInputObject)]
/// The details required to create a user.
pub struct CreateUserInput {
id: String,
// The email can be specified as an attribute, but one of the two is required.
email: Option<String>,
display_name: Option<String>,
/// First name of user. Deprecated: use attribute instead.
/// If both field and corresponding attribute is supplied, the attribute will take precedence.
first_name: Option<String>,
/// Last name of user. Deprecated: use attribute instead.
/// If both field and corresponding attribute is supplied, the attribute will take precedence.
last_name: Option<String>,
/// Base64 encoded JpegPhoto. Deprecated: use attribute instead.
/// If both field and corresponding attribute is supplied, the attribute will take precedence.
avatar: Option<String>,
/// Attributes.
attributes: Option<Vec<AttributeValue>>,
}
#[derive(PartialEq, Eq, Debug, GraphQLInputObject)]
/// The details required to create a group.
pub struct CreateGroupInput {
display_name: String,
/// User-defined attributes.
attributes: Option<Vec<AttributeValue>>,
}
#[derive(PartialEq, Eq, Debug, GraphQLInputObject)]
/// The fields that can be updated for a user.
pub struct UpdateUserInput {
id: String,
email: Option<String>,
display_name: Option<String>,
/// First name of user. Deprecated: use attribute instead.
/// If both field and corresponding attribute is supplied, the attribute will take precedence.
first_name: Option<String>,
/// Last name of user. Deprecated: use attribute instead.
/// If both field and corresponding attribute is supplied, the attribute will take precedence.
last_name: Option<String>,
/// Base64 encoded JpegPhoto. Deprecated: use attribute instead.
/// If both field and corresponding attribute is supplied, the attribute will take precedence.
avatar: Option<String>,
/// Attribute names to remove.
/// They are processed before insertions.
remove_attributes: Option<Vec<String>>,
/// Inserts or updates the given attributes.
/// For lists, the entire list must be provided.
insert_attributes: Option<Vec<AttributeValue>>,
}
#[derive(PartialEq, Eq, Debug, GraphQLInputObject)]
/// The fields that can be updated for a group.
pub struct UpdateGroupInput {
/// The group ID.
id: i32,
/// The new display name.
display_name: Option<String>,
/// Attribute names to remove.
/// They are processed before insertions.
remove_attributes: Option<Vec<String>>,
/// Inserts or updates the given attributes.
/// For lists, the entire list must be provided.
insert_attributes: Option<Vec<AttributeValue>>,
}
#[derive(PartialEq, Eq, Debug, GraphQLObject)]
pub struct Success {
ok: bool,
}
impl Success {
fn new() -> Self {
Self { ok: true }
}
}
struct UnpackedAttributes {
email: Option<Email>,
display_name: Option<String>,
attributes: Vec<DomainAttribute>,
}
fn unpack_attributes(
attributes: Vec<AttributeValue>,
schema: &PublicSchema,
is_admin: bool,
) -> FieldResult<UnpackedAttributes> {
let email = attributes
.iter()
.find(|attr| attr.name == "mail")
.cloned()
.map(|attr| deserialize_attribute(&schema.get_schema().user_attributes, attr, is_admin))
.transpose()?
.map(|attr| attr.value.into_string().unwrap())
.map(Email::from);
let display_name = attributes
.iter()
.find(|attr| attr.name == "display_name")
.cloned()
.map(|attr| deserialize_attribute(&schema.get_schema().user_attributes, attr, is_admin))
.transpose()?
.map(|attr| attr.value.into_string().unwrap());
let attributes = attributes
.into_iter()
.filter(|attr| attr.name != "mail" && attr.name != "display_name")
.map(|attr| deserialize_attribute(&schema.get_schema().user_attributes, attr, is_admin))
.collect::<Result<Vec<_>, _>>()?;
Ok(UnpackedAttributes {
email,
display_name,
attributes,
})
}
/// Consolidates caller supplied user fields and attributes into a list of attributes.
///
/// A number of user fields are internally represented as attributes, but are still also
/// available as fields on user objects. This function consolidates these fields and the
/// given attributes into a resulting attribute list. If a value is supplied for both a
/// field and the corresponding attribute, the attribute will take precedence.
fn consolidate_attributes(
attributes: Vec<AttributeValue>,
first_name: Option<String>,
last_name: Option<String>,
avatar: Option<String>,
) -> Vec<AttributeValue> {
// Prepare map of the client provided attributes
let mut provided_attributes: BTreeMap<AttributeName, AttributeValue> = attributes
.into_iter()
.map(|x| {
(
x.name.clone().into(),
AttributeValue {
name: x.name.to_ascii_lowercase(),
value: x.value,
},
)
})
.collect::<BTreeMap<_, _>>();
// Prepare list of fallback attribute values
let field_attrs = [
("first_name", first_name),
("last_name", last_name),
("avatar", avatar),
];
for (name, value) in field_attrs.into_iter() {
if let Some(val) = value {
let attr_name: AttributeName = name.into();
provided_attributes
.entry(attr_name)
.or_insert_with(|| AttributeValue {
name: name.to_string(),
value: vec![val],
});
}
}
// Return the values of the resulting map
provided_attributes.into_values().collect()
}
#[graphql_object(context = Context<Handler>)]
impl<Handler: BackendHandler> Mutation<Handler> {
async fn create_user(
@@ -547,6 +721,66 @@ impl<Handler: BackendHandler> Mutation<Handler> {
Ok(Success::new())
}
}
async fn create_group_with_details<Handler: BackendHandler>(
context: &Context<Handler>,
request: CreateGroupInput,
span: Span,
) -> FieldResult<super::query::Group<Handler>> {
let handler = context
.get_admin_handler()
.ok_or_else(field_error_callback(&span, "Unauthorized group creation"))?;
let schema = handler.get_schema().await?;
let attributes = request
.attributes
.unwrap_or_default()
.into_iter()
.map(|attr| deserialize_attribute(&schema.get_schema().group_attributes, attr, true))
.collect::<Result<Vec<_>, _>>()?;
let request = CreateGroupRequest {
display_name: request.display_name.into(),
attributes,
};
let group_id = handler.create_group(request).await?;
let group_details = handler.get_group_details(group_id).instrument(span).await?;
super::query::Group::<Handler>::from_group_details(group_details, Arc::new(schema))
}
fn deserialize_attribute(
attribute_schema: &AttributeList,
attribute: AttributeValue,
is_admin: bool,
) -> FieldResult<DomainAttribute> {
let attribute_name = AttributeName::from(attribute.name.as_str());
let attribute_schema = attribute_schema
.get_attribute_schema(&attribute_name)
.ok_or_else(|| anyhow!("Attribute {} is not defined in the schema", attribute.name))?;
if attribute_schema.is_readonly {
return Err(anyhow!(
"Permission denied: Attribute {} is read-only",
attribute.name
)
.into());
}
if !is_admin && !attribute_schema.is_editable {
return Err(anyhow!(
"Permission denied: Attribute {} is not editable by regular users",
attribute.name
)
.into());
}
let deserialized_values = deserialize_attribute_value(
&attribute.value,
attribute_schema.attribute_type,
attribute_schema.is_list,
)
.context(format!("While deserializing attribute {}", attribute.name))?;
Ok(DomainAttribute {
name: attribute_name,
value: deserialized_values,
})
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -1,160 +0,0 @@
use anyhow::{Context as AnyhowContext, anyhow};
use juniper::FieldResult;
use lldap_access_control::{AdminBackendHandler, ReadonlyBackendHandler};
use lldap_domain::{
deserialize::deserialize_attribute_value,
public_schema::PublicSchema,
requests::CreateGroupRequest,
schema::AttributeList,
types::{Attribute as DomainAttribute, AttributeName, Email},
};
use lldap_domain_handlers::handler::{BackendHandler, ReadSchemaBackendHandler};
use std::{collections::BTreeMap, sync::Arc};
use tracing::{Instrument, Span};
use super::inputs::AttributeValue;
use crate::api::{Context, field_error_callback};
pub struct UnpackedAttributes {
pub email: Option<Email>,
pub display_name: Option<String>,
pub attributes: Vec<DomainAttribute>,
}
pub fn unpack_attributes(
attributes: Vec<AttributeValue>,
schema: &PublicSchema,
is_admin: bool,
) -> FieldResult<UnpackedAttributes> {
let email = attributes
.iter()
.find(|attr| attr.name == "mail")
.cloned()
.map(|attr| deserialize_attribute(&schema.get_schema().user_attributes, attr, is_admin))
.transpose()?
.map(|attr| attr.value.into_string().unwrap())
.map(Email::from);
let display_name = attributes
.iter()
.find(|attr| attr.name == "display_name")
.cloned()
.map(|attr| deserialize_attribute(&schema.get_schema().user_attributes, attr, is_admin))
.transpose()?
.map(|attr| attr.value.into_string().unwrap());
let attributes = attributes
.into_iter()
.filter(|attr| attr.name != "mail" && attr.name != "display_name")
.map(|attr| deserialize_attribute(&schema.get_schema().user_attributes, attr, is_admin))
.collect::<Result<Vec<_>, _>>()?;
Ok(UnpackedAttributes {
email,
display_name,
attributes,
})
}
/// Consolidates caller supplied user fields and attributes into a list of attributes.
///
/// A number of user fields are internally represented as attributes, but are still also
/// available as fields on user objects. This function consolidates these fields and the
/// given attributes into a resulting attribute list. If a value is supplied for both a
/// field and the corresponding attribute, the attribute will take precedence.
pub fn consolidate_attributes(
attributes: Vec<AttributeValue>,
first_name: Option<String>,
last_name: Option<String>,
avatar: Option<String>,
) -> Vec<AttributeValue> {
// Prepare map of the client provided attributes
let mut provided_attributes: BTreeMap<AttributeName, AttributeValue> = attributes
.into_iter()
.map(|x| {
(
x.name.clone().into(),
AttributeValue {
name: x.name.to_ascii_lowercase(),
value: x.value,
},
)
})
.collect::<BTreeMap<_, _>>();
// Prepare list of fallback attribute values
let field_attrs = [
("first_name", first_name),
("last_name", last_name),
("avatar", avatar),
];
for (name, value) in field_attrs.into_iter() {
if let Some(val) = value {
let attr_name: AttributeName = name.into();
provided_attributes
.entry(attr_name)
.or_insert_with(|| AttributeValue {
name: name.to_string(),
value: vec![val],
});
}
}
// Return the values of the resulting map
provided_attributes.into_values().collect()
}
pub async fn create_group_with_details<Handler: BackendHandler>(
context: &Context<Handler>,
request: super::inputs::CreateGroupInput,
span: Span,
) -> FieldResult<crate::query::Group<Handler>> {
let handler = context
.get_admin_handler()
.ok_or_else(field_error_callback(&span, "Unauthorized group creation"))?;
let schema = handler.get_schema().await?;
let public_schema: PublicSchema = schema.into();
let attributes = request
.attributes
.unwrap_or_default()
.into_iter()
.map(|attr| deserialize_attribute(&public_schema.get_schema().group_attributes, attr, true))
.collect::<Result<Vec<_>, _>>()?;
let request = CreateGroupRequest {
display_name: request.display_name.into(),
attributes,
};
let group_id = handler.create_group(request).await?;
let group_details = handler.get_group_details(group_id).instrument(span).await?;
crate::query::Group::<Handler>::from_group_details(group_details, Arc::new(public_schema))
}
pub fn deserialize_attribute(
attribute_schema: &AttributeList,
attribute: AttributeValue,
is_admin: bool,
) -> FieldResult<DomainAttribute> {
let attribute_name = AttributeName::from(attribute.name.as_str());
let attribute_schema = attribute_schema
.get_attribute_schema(&attribute_name)
.ok_or_else(|| anyhow!("Attribute {} is not defined in the schema", attribute.name))?;
if attribute_schema.is_readonly {
return Err(anyhow!(
"Permission denied: Attribute {} is read-only",
attribute.name
)
.into());
}
if !is_admin && !attribute_schema.is_editable {
return Err(anyhow!(
"Permission denied: Attribute {} is not editable by regular users",
attribute.name
)
.into());
}
let deserialized_values = deserialize_attribute_value(
&attribute.value,
attribute_schema.attribute_type,
attribute_schema.is_list,
)
.context(format!("While deserializing attribute {}", attribute.name))?;
Ok(DomainAttribute {
name: attribute_name,
value: deserialized_values,
})
}

View File

@@ -1,99 +0,0 @@
use juniper::{GraphQLInputObject, GraphQLObject};
#[derive(Clone, PartialEq, Eq, Debug, GraphQLInputObject)]
// This conflicts with the attribute values returned by the user/group queries.
#[graphql(name = "AttributeValueInput")]
pub struct AttributeValue {
/// The name of the attribute. It must be present in the schema, and the type informs how
/// to interpret the values.
pub 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.
pub value: Vec<String>,
}
#[derive(PartialEq, Eq, Debug, GraphQLInputObject)]
/// The details required to create a user.
pub struct CreateUserInput {
pub id: String,
// The email can be specified as an attribute, but one of the two is required.
pub email: Option<String>,
pub display_name: Option<String>,
/// First name of user. Deprecated: use attribute instead.
/// If both field and corresponding attribute is supplied, the attribute will take precedence.
pub first_name: Option<String>,
/// Last name of user. Deprecated: use attribute instead.
/// If both field and corresponding attribute is supplied, the attribute will take precedence.
pub last_name: Option<String>,
/// Base64 encoded JpegPhoto. Deprecated: use attribute instead.
/// If both field and corresponding attribute is supplied, the attribute will take precedence.
pub avatar: Option<String>,
/// Attributes.
pub attributes: Option<Vec<AttributeValue>>,
}
#[derive(PartialEq, Eq, Debug, GraphQLInputObject)]
/// The details required to create a group.
pub struct CreateGroupInput {
pub display_name: String,
/// User-defined attributes.
pub attributes: Option<Vec<AttributeValue>>,
}
#[derive(PartialEq, Eq, Debug, GraphQLInputObject)]
/// The fields that can be updated for a user.
pub struct UpdateUserInput {
pub id: String,
pub email: Option<String>,
pub display_name: Option<String>,
/// First name of user. Deprecated: use attribute instead.
/// If both field and corresponding attribute is supplied, the attribute will take precedence.
pub first_name: Option<String>,
/// Last name of user. Deprecated: use attribute instead.
/// If both field and corresponding attribute is supplied, the attribute will take precedence.
pub last_name: Option<String>,
/// Base64 encoded JpegPhoto. Deprecated: use attribute instead.
/// If both field and corresponding attribute is supplied, the attribute will take precedence.
pub avatar: Option<String>,
/// Attribute names to remove.
/// They are processed before insertions.
pub remove_attributes: Option<Vec<String>>,
/// Inserts or updates the given attributes.
/// For lists, the entire list must be provided.
pub insert_attributes: Option<Vec<AttributeValue>>,
}
#[derive(PartialEq, Eq, Debug, GraphQLInputObject)]
/// The fields that can be updated for a group.
pub struct UpdateGroupInput {
/// The group ID.
pub id: i32,
/// The new display name.
pub display_name: Option<String>,
/// Attribute names to remove.
/// They are processed before insertions.
pub remove_attributes: Option<Vec<String>>,
/// Inserts or updates the given attributes.
/// For lists, the entire list must be provided.
pub insert_attributes: Option<Vec<AttributeValue>>,
}
#[derive(PartialEq, Eq, Debug, GraphQLObject)]
pub struct Success {
ok: bool,
}
impl Success {
pub fn new() -> Self {
Self { ok: true }
}
}
impl Default for Success {
fn default() -> Self {
Self::new()
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,267 +0,0 @@
use chrono::TimeZone;
use juniper::{FieldResult, graphql_object};
use lldap_domain::public_schema::PublicSchema;
use lldap_domain::schema::AttributeList as DomainAttributeList;
use lldap_domain::schema::AttributeSchema as DomainAttributeSchema;
use lldap_domain::types::{Attribute as DomainAttribute, AttributeValue as DomainAttributeValue};
use lldap_domain::types::{Cardinality, Group as DomainGroup, GroupDetails, User as DomainUser};
use lldap_domain_handlers::handler::BackendHandler;
use serde::{Deserialize, Serialize};
use crate::api::Context;
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)]
pub struct AttributeSchema<Handler: BackendHandler> {
schema: DomainAttributeSchema,
_phantom: std::marker::PhantomData<Box<Handler>>,
}
#[graphql_object(context = Context<Handler>)]
impl<Handler: BackendHandler> AttributeSchema<Handler> {
fn name(&self) -> String {
self.schema.name.to_string()
}
fn attribute_type(&self) -> lldap_domain::types::AttributeType {
self.schema.attribute_type
}
fn is_list(&self) -> bool {
self.schema.is_list
}
fn is_visible(&self) -> bool {
self.schema.is_visible
}
fn is_editable(&self) -> bool {
self.schema.is_editable
}
fn is_hardcoded(&self) -> bool {
self.schema.is_hardcoded
}
fn is_readonly(&self) -> bool {
self.schema.is_readonly
}
}
impl<Handler: BackendHandler> Clone for AttributeSchema<Handler> {
fn clone(&self) -> Self {
Self {
schema: self.schema.clone(),
_phantom: std::marker::PhantomData,
}
}
}
impl<Handler: BackendHandler> From<DomainAttributeSchema> for AttributeSchema<Handler> {
fn from(value: DomainAttributeSchema) -> Self {
Self {
schema: value,
_phantom: std::marker::PhantomData,
}
}
}
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)]
pub struct AttributeValue<Handler: BackendHandler> {
pub(super) attribute: DomainAttribute,
pub(super) schema: AttributeSchema<Handler>,
_phantom: std::marker::PhantomData<Box<Handler>>,
}
#[graphql_object(context = Context<Handler>)]
impl<Handler: BackendHandler> AttributeValue<Handler> {
fn name(&self) -> &str {
self.attribute.name.as_str()
}
fn value(&self) -> FieldResult<Vec<String>> {
Ok(serialize_attribute_to_graphql(&self.attribute.value))
}
fn schema(&self) -> &AttributeSchema<Handler> {
&self.schema
}
}
impl<Handler: BackendHandler> AttributeValue<Handler> {
fn from_value(attr: DomainAttribute, schema: DomainAttributeSchema) -> Self {
Self {
attribute: attr,
schema: AttributeSchema::<Handler> {
schema,
_phantom: std::marker::PhantomData,
},
_phantom: std::marker::PhantomData,
}
}
pub(super) fn name(&self) -> &str {
self.attribute.name.as_str()
}
}
impl<Handler: BackendHandler> Clone for AttributeValue<Handler> {
fn clone(&self) -> Self {
Self {
attribute: self.attribute.clone(),
schema: self.schema.clone(),
_phantom: std::marker::PhantomData,
}
}
}
pub fn serialize_attribute_to_graphql(attribute_value: &DomainAttributeValue) -> Vec<String> {
let convert_date = |&date| chrono::Utc.from_utc_datetime(&date).to_rfc3339();
match attribute_value {
DomainAttributeValue::String(Cardinality::Singleton(s)) => vec![s.clone()],
DomainAttributeValue::String(Cardinality::Unbounded(l)) => l.clone(),
DomainAttributeValue::Integer(Cardinality::Singleton(i)) => vec![i.to_string()],
DomainAttributeValue::Integer(Cardinality::Unbounded(l)) => {
l.iter().map(|i| i.to_string()).collect()
}
DomainAttributeValue::DateTime(Cardinality::Singleton(dt)) => vec![convert_date(dt)],
DomainAttributeValue::DateTime(Cardinality::Unbounded(l)) => {
l.iter().map(convert_date).collect()
}
DomainAttributeValue::JpegPhoto(Cardinality::Singleton(p)) => vec![String::from(p)],
DomainAttributeValue::JpegPhoto(Cardinality::Unbounded(l)) => {
l.iter().map(String::from).collect()
}
}
}
impl<Handler: BackendHandler> AttributeValue<Handler> {
fn from_schema(a: DomainAttribute, schema: &DomainAttributeList) -> Option<Self> {
schema
.get_attribute_schema(&a.name)
.map(|s| AttributeValue::<Handler>::from_value(a, s.clone()))
}
pub fn user_attributes_from_schema(
user: &mut DomainUser,
schema: &PublicSchema,
) -> Vec<AttributeValue<Handler>> {
let user_attributes = std::mem::take(&mut user.attributes);
let mut all_attributes = schema
.get_schema()
.user_attributes
.attributes
.iter()
.filter(|a| a.is_hardcoded)
.flat_map(|attribute_schema| {
let value: Option<DomainAttributeValue> = match attribute_schema.name.as_str() {
"user_id" => Some(user.user_id.clone().into_string().into()),
"creation_date" => Some(user.creation_date.into()),
"modified_date" => Some(user.modified_date.into()),
"password_modified_date" => Some(user.password_modified_date.into()),
"mail" => Some(user.email.clone().into_string().into()),
"uuid" => Some(user.uuid.clone().into_string().into()),
"display_name" => user.display_name.as_ref().map(|d| d.clone().into()),
"avatar" | "first_name" | "last_name" => None,
_ => panic!("Unexpected hardcoded attribute: {}", attribute_schema.name),
};
value.map(|v| (attribute_schema, v))
})
.map(|(attribute_schema, value)| {
AttributeValue::<Handler>::from_value(
DomainAttribute {
name: attribute_schema.name.clone(),
value,
},
attribute_schema.clone(),
)
})
.collect::<Vec<_>>();
user_attributes
.into_iter()
.flat_map(|a| {
AttributeValue::<Handler>::from_schema(a, &schema.get_schema().user_attributes)
})
.for_each(|value| all_attributes.push(value));
all_attributes
}
pub fn group_attributes_from_schema(
group: &mut DomainGroup,
schema: &PublicSchema,
) -> Vec<AttributeValue<Handler>> {
let group_attributes = std::mem::take(&mut group.attributes);
let mut all_attributes = schema
.get_schema()
.group_attributes
.attributes
.iter()
.filter(|a| a.is_hardcoded)
.map(|attribute_schema| {
(
attribute_schema,
match attribute_schema.name.as_str() {
"group_id" => (group.id.0 as i64).into(),
"creation_date" => group.creation_date.into(),
"modified_date" => group.modified_date.into(),
"uuid" => group.uuid.clone().into_string().into(),
"display_name" => group.display_name.clone().into_string().into(),
_ => panic!("Unexpected hardcoded attribute: {}", attribute_schema.name),
},
)
})
.map(|(attribute_schema, value)| {
AttributeValue::<Handler>::from_value(
DomainAttribute {
name: attribute_schema.name.clone(),
value,
},
attribute_schema.clone(),
)
})
.collect::<Vec<_>>();
group_attributes
.into_iter()
.flat_map(|a| {
AttributeValue::<Handler>::from_schema(a, &schema.get_schema().group_attributes)
})
.for_each(|value| all_attributes.push(value));
all_attributes
}
pub fn group_details_attributes_from_schema(
group: &mut GroupDetails,
schema: &PublicSchema,
) -> Vec<AttributeValue<Handler>> {
let group_attributes = std::mem::take(&mut group.attributes);
let mut all_attributes = schema
.get_schema()
.group_attributes
.attributes
.iter()
.filter(|a| a.is_hardcoded)
.map(|attribute_schema| {
(
attribute_schema,
match attribute_schema.name.as_str() {
"group_id" => (group.group_id.0 as i64).into(),
"creation_date" => group.creation_date.into(),
"modified_date" => group.modified_date.into(),
"uuid" => group.uuid.clone().into_string().into(),
"display_name" => group.display_name.clone().into_string().into(),
_ => panic!("Unexpected hardcoded attribute: {}", attribute_schema.name),
},
)
})
.map(|(attribute_schema, value)| {
AttributeValue::<Handler>::from_value(
DomainAttribute {
name: attribute_schema.name.clone(),
value,
},
attribute_schema.clone(),
)
})
.collect::<Vec<_>>();
group_attributes
.into_iter()
.flat_map(|a| {
AttributeValue::<Handler>::from_schema(a, &schema.get_schema().group_attributes)
})
.for_each(|value| all_attributes.push(value));
all_attributes
}
}

View File

@@ -1,89 +0,0 @@
use anyhow::Context as AnyhowContext;
use juniper::{FieldResult, GraphQLInputObject};
use lldap_domain::deserialize::deserialize_attribute_value;
use lldap_domain::public_schema::PublicSchema;
use lldap_domain::types::GroupId;
use lldap_domain::types::UserId;
use lldap_domain_handlers::handler::UserRequestFilter as DomainRequestFilter;
use lldap_domain_model::model::UserColumn;
use lldap_ldap::{UserFieldType, map_user_field};
#[derive(PartialEq, Eq, Debug, GraphQLInputObject)]
/// A filter for requests, specifying a boolean expression based on field constraints. Only one of
/// the fields can be set at a time.
pub struct RequestFilter {
any: Option<Vec<RequestFilter>>,
all: Option<Vec<RequestFilter>>,
not: Option<Box<RequestFilter>>,
eq: Option<EqualityConstraint>,
member_of: Option<String>,
member_of_id: Option<i32>,
}
impl RequestFilter {
pub fn try_into_domain_filter(self, schema: &PublicSchema) -> FieldResult<DomainRequestFilter> {
match (
self.eq,
self.any,
self.all,
self.not,
self.member_of,
self.member_of_id,
) {
(Some(eq), None, None, None, None, None) => {
match map_user_field(&eq.field.as_str().into(), schema) {
UserFieldType::NoMatch => {
Err(format!("Unknown request filter: {}", &eq.field).into())
}
UserFieldType::PrimaryField(UserColumn::UserId) => {
Ok(DomainRequestFilter::UserId(UserId::new(&eq.value)))
}
UserFieldType::PrimaryField(column) => {
Ok(DomainRequestFilter::Equality(column, eq.value))
}
UserFieldType::Attribute(name, typ, false) => {
let value = deserialize_attribute_value(&[eq.value], typ, false)
.context(format!("While deserializing attribute {}", &name))?;
Ok(DomainRequestFilter::AttributeEquality(name, value))
}
UserFieldType::Attribute(_, _, true) => {
Err("Equality not supported for list fields".into())
}
UserFieldType::MemberOf => Ok(DomainRequestFilter::MemberOf(eq.value.into())),
UserFieldType::ObjectClass | UserFieldType::Dn | UserFieldType::EntryDn => {
Err("Ldap fields not supported in request filter".into())
}
}
}
(None, Some(any), None, None, None, None) => Ok(DomainRequestFilter::Or(
any.into_iter()
.map(|f| f.try_into_domain_filter(schema))
.collect::<FieldResult<Vec<_>>>()?,
)),
(None, None, Some(all), None, None, None) => Ok(DomainRequestFilter::And(
all.into_iter()
.map(|f| f.try_into_domain_filter(schema))
.collect::<FieldResult<Vec<_>>>()?,
)),
(None, None, None, Some(not), None, None) => Ok(DomainRequestFilter::Not(Box::new(
(*not).try_into_domain_filter(schema)?,
))),
(None, None, None, None, Some(group), None) => {
Ok(DomainRequestFilter::MemberOf(group.into()))
}
(None, None, None, None, None, Some(group_id)) => {
Ok(DomainRequestFilter::MemberOfId(GroupId(group_id)))
}
(None, None, None, None, None, None) => {
Err("No field specified in request filter".into())
}
_ => Err("Multiple fields specified in request filter".into()),
}
}
}
#[derive(PartialEq, Eq, Debug, GraphQLInputObject)]
pub struct EqualityConstraint {
field: String,
value: String,
}

View File

@@ -1,123 +0,0 @@
use chrono::TimeZone;
use juniper::{FieldResult, graphql_object};
use lldap_access_control::ReadonlyBackendHandler;
use lldap_domain::public_schema::PublicSchema;
use lldap_domain::types::{Group as DomainGroup, GroupDetails, GroupId};
use lldap_domain_handlers::handler::{BackendHandler, UserRequestFilter as DomainRequestFilter};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tracing::{Instrument, debug, debug_span};
use super::attribute::AttributeValue;
use super::user::User;
use crate::api::{Context, field_error_callback};
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)]
/// Represents a single group.
pub struct Group<Handler: BackendHandler> {
pub group_id: i32,
pub display_name: String,
creation_date: chrono::NaiveDateTime,
uuid: String,
attributes: Vec<AttributeValue<Handler>>,
pub schema: Arc<PublicSchema>,
_phantom: std::marker::PhantomData<Box<Handler>>,
}
impl<Handler: BackendHandler> Group<Handler> {
pub fn from_group(
mut group: DomainGroup,
schema: Arc<PublicSchema>,
) -> FieldResult<Group<Handler>> {
let attributes =
AttributeValue::<Handler>::group_attributes_from_schema(&mut group, &schema);
Ok(Self {
group_id: group.id.0,
display_name: group.display_name.to_string(),
creation_date: group.creation_date,
uuid: group.uuid.into_string(),
attributes,
schema,
_phantom: std::marker::PhantomData,
})
}
pub fn from_group_details(
mut group_details: GroupDetails,
schema: Arc<PublicSchema>,
) -> FieldResult<Group<Handler>> {
let attributes = AttributeValue::<Handler>::group_details_attributes_from_schema(
&mut group_details,
&schema,
);
Ok(Self {
group_id: group_details.group_id.0,
display_name: group_details.display_name.to_string(),
creation_date: group_details.creation_date,
uuid: group_details.uuid.into_string(),
attributes,
schema,
_phantom: std::marker::PhantomData,
})
}
}
impl<Handler: BackendHandler> Clone for Group<Handler> {
fn clone(&self) -> Self {
Self {
group_id: self.group_id,
display_name: self.display_name.clone(),
creation_date: self.creation_date,
uuid: self.uuid.clone(),
attributes: self.attributes.clone(),
schema: self.schema.clone(),
_phantom: std::marker::PhantomData,
}
}
}
#[graphql_object(context = Context<Handler>)]
impl<Handler: BackendHandler> Group<Handler> {
fn id(&self) -> i32 {
self.group_id
}
fn display_name(&self) -> String {
self.display_name.clone()
}
fn creation_date(&self) -> chrono::DateTime<chrono::Utc> {
chrono::Utc.from_utc_datetime(&self.creation_date)
}
fn uuid(&self) -> String {
self.uuid.clone()
}
/// User-defined attributes.
fn attributes(&self) -> &[AttributeValue<Handler>] {
&self.attributes
}
/// The groups to which this user belongs.
async fn users(&self, context: &Context<Handler>) -> FieldResult<Vec<User<Handler>>> {
let span = debug_span!("[GraphQL query] group::users");
span.in_scope(|| {
debug!(name = %self.display_name);
});
let handler = context
.get_readonly_handler()
.ok_or_else(field_error_callback(
&span,
"Unauthorized access to group data",
))?;
let domain_users = handler
.list_users(
Some(DomainRequestFilter::MemberOfId(GroupId(self.group_id))),
false,
)
.instrument(span)
.await?;
domain_users
.into_iter()
.map(|u| User::<Handler>::from_user_and_groups(u, self.schema.clone()))
.collect()
}
}

View File

@@ -1,539 +0,0 @@
pub mod attribute;
pub mod filters;
pub mod group;
pub mod schema;
pub mod user;
// Re-export public types
pub use attribute::{AttributeSchema, AttributeValue, serialize_attribute_to_graphql};
pub use filters::{EqualityConstraint, RequestFilter};
pub use group::Group;
pub use schema::{AttributeList, ObjectClassInfo, Schema};
pub use user::User;
use juniper::{FieldResult, graphql_object};
use lldap_access_control::{ReadonlyBackendHandler, UserReadableBackendHandler};
use lldap_domain::public_schema::PublicSchema;
use lldap_domain::types::{GroupId, UserId};
use lldap_domain_handlers::handler::{BackendHandler, ReadSchemaBackendHandler};
use std::sync::Arc;
use tracing::{Instrument, Span, debug, debug_span};
use crate::api::{Context, field_error_callback};
#[derive(PartialEq, Eq, Debug)]
/// The top-level GraphQL query type.
pub struct Query<Handler: BackendHandler> {
_phantom: std::marker::PhantomData<Box<Handler>>,
}
impl<Handler: BackendHandler> Default for Query<Handler> {
fn default() -> Self {
Self::new()
}
}
impl<Handler: BackendHandler> Query<Handler> {
pub fn new() -> Self {
Self {
_phantom: std::marker::PhantomData,
}
}
}
#[graphql_object(context = Context<Handler>)]
impl<Handler: BackendHandler> Query<Handler> {
fn api_version() -> &'static str {
"1.0"
}
pub async fn user(context: &Context<Handler>, user_id: String) -> FieldResult<User<Handler>> {
use anyhow::Context;
let span = debug_span!("[GraphQL query] user");
span.in_scope(|| {
debug!(?user_id);
});
let user_id = urlencoding::decode(&user_id).context("Invalid user parameter")?;
let user_id = UserId::new(&user_id);
let handler = context
.get_readable_handler(&user_id)
.ok_or_else(field_error_callback(
&span,
"Unauthorized access to user data",
))?;
let schema = 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(
context: &Context<Handler>,
#[graphql(name = "where")] filters: Option<RequestFilter>,
) -> FieldResult<Vec<User<Handler>>> {
let span = debug_span!("[GraphQL query] users");
span.in_scope(|| {
debug!(?filters);
});
let handler = context
.get_readonly_handler()
.ok_or_else(field_error_callback(
&span,
"Unauthorized access to user list",
))?;
let schema = Arc::new(self.get_schema(context, span.clone()).await?);
let users = handler
.list_users(
filters
.map(|f| f.try_into_domain_filter(&schema))
.transpose()?,
false,
)
.instrument(span)
.await?;
users
.into_iter()
.map(|u| User::<Handler>::from_user_and_groups(u, schema.clone()))
.collect()
}
async fn groups(context: &Context<Handler>) -> FieldResult<Vec<Group<Handler>>> {
let span = debug_span!("[GraphQL query] groups");
let handler = context
.get_readonly_handler()
.ok_or_else(field_error_callback(
&span,
"Unauthorized access to group list",
))?;
let schema = Arc::new(self.get_schema(context, span.clone()).await?);
let domain_groups = handler.list_groups(None).instrument(span).await?;
domain_groups
.into_iter()
.map(|g| Group::<Handler>::from_group(g, schema.clone()))
.collect()
}
async fn group(context: &Context<Handler>, group_id: i32) -> FieldResult<Group<Handler>> {
let span = debug_span!("[GraphQL query] group");
span.in_scope(|| {
debug!(?group_id);
});
let handler = context
.get_readonly_handler()
.ok_or_else(field_error_callback(
&span,
"Unauthorized access to group data",
))?;
let schema = Arc::new(self.get_schema(context, span.clone()).await?);
let group_details = handler
.get_group_details(GroupId(group_id))
.instrument(span)
.await?;
Group::<Handler>::from_group_details(group_details, schema.clone())
}
async fn schema(context: &Context<Handler>) -> FieldResult<Schema<Handler>> {
let span = debug_span!("[GraphQL query] get_schema");
self.get_schema(context, span).await.map(Into::into)
}
}
impl<Handler: BackendHandler> Query<Handler> {
async fn get_schema(
&self,
context: &Context<Handler>,
span: Span,
) -> FieldResult<PublicSchema> {
let handler = context
.handler
.get_user_restricted_lister_handler(&context.validation_result);
Ok(handler
.get_schema()
.instrument(span)
.await
.map(Into::<PublicSchema>::into)?)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
use juniper::{
DefaultScalarValue, EmptyMutation, EmptySubscription, GraphQLType, RootNode, Variables,
execute, graphql_value,
};
use lldap_auth::access_control::{Permission, ValidationResults};
use lldap_domain::schema::AttributeSchema as DomainAttributeSchema;
use lldap_domain::types::{Attribute as DomainAttribute, GroupDetails, User as DomainUser};
use lldap_domain::{
schema::{AttributeList, Schema},
types::{AttributeName, AttributeType, LdapObjectClass},
};
use lldap_domain_model::model::UserColumn;
use lldap_test_utils::{MockTestBackendHandler, setup_default_schema};
use mockall::predicate::eq;
use pretty_assertions::assert_eq;
use std::collections::HashSet;
fn schema<'q, C, Q>(query_root: Q) -> RootNode<'q, Q, EmptyMutation<C>, EmptySubscription<C>>
where
Q: GraphQLType<DefaultScalarValue, Context = C, TypeInfo = ()> + 'q,
{
RootNode::new(
query_root,
EmptyMutation::<C>::new(),
EmptySubscription::<C>::new(),
)
}
#[tokio::test]
async fn get_user_by_id() {
const QUERY: &str = r#"{
user(userId: "bob") {
id
email
creationDate
firstName
lastName
uuid
attributes {
name
value
}
groups {
id
displayName
creationDate
uuid
attributes {
name
value
}
}
}
}"#;
let mut mock = MockTestBackendHandler::new();
mock.expect_get_schema().returning(|| {
Ok(Schema {
user_attributes: AttributeList {
attributes: vec![
DomainAttributeSchema {
name: "first_name".into(),
attribute_type: AttributeType::String,
is_list: false,
is_visible: true,
is_editable: true,
is_hardcoded: true,
is_readonly: false,
},
DomainAttributeSchema {
name: "last_name".into(),
attribute_type: AttributeType::String,
is_list: false,
is_visible: true,
is_editable: true,
is_hardcoded: true,
is_readonly: false,
},
],
},
group_attributes: AttributeList {
attributes: vec![DomainAttributeSchema {
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![
LdapObjectClass::from("customUserClass"),
LdapObjectClass::from("myUserClass"),
],
extra_group_object_classes: vec![LdapObjectClass::from("customGroupClass")],
})
});
mock.expect_get_user_details()
.with(eq(UserId::new("bob")))
.return_once(|_| {
Ok(DomainUser {
user_id: UserId::new("bob"),
email: "bob@bobbers.on".into(),
display_name: None,
creation_date: chrono::Utc.timestamp_millis_opt(42).unwrap().naive_utc(),
modified_date: chrono::Utc.timestamp_opt(0, 0).unwrap().naive_utc(),
password_modified_date: chrono::Utc.timestamp_opt(0, 0).unwrap().naive_utc(),
uuid: lldap_domain::types::Uuid::from_name_and_date(
"bob",
&chrono::Utc.timestamp_millis_opt(42).unwrap().naive_utc(),
),
attributes: vec![
DomainAttribute {
name: "first_name".into(),
value: "Bob".to_string().into(),
},
DomainAttribute {
name: "last_name".into(),
value: "Bobberson".to_string().into(),
},
],
})
});
let mut groups = HashSet::new();
groups.insert(GroupDetails {
group_id: GroupId(3),
display_name: "Bobbersons".into(),
creation_date: chrono::Utc.timestamp_nanos(42).naive_utc(),
uuid: lldap_domain::types::Uuid::from_name_and_date(
"Bobbersons",
&chrono::Utc.timestamp_nanos(42).naive_utc(),
),
attributes: vec![DomainAttribute {
name: "club_name".into(),
value: "Gang of Four".to_string().into(),
}],
modified_date: chrono::Utc.timestamp_nanos(42).naive_utc(),
});
groups.insert(GroupDetails {
group_id: GroupId(7),
display_name: "Jefferees".into(),
creation_date: chrono::Utc.timestamp_nanos(12).naive_utc(),
uuid: lldap_domain::types::Uuid::from_name_and_date(
"Jefferees",
&chrono::Utc.timestamp_nanos(12).naive_utc(),
),
attributes: Vec::new(),
modified_date: chrono::Utc.timestamp_nanos(12).naive_utc(),
});
mock.expect_get_user_groups()
.with(eq(UserId::new("bob")))
.return_once(|_| Ok(groups));
let context = Context::<MockTestBackendHandler>::new_for_tests(
mock,
ValidationResults {
user: UserId::new("admin"),
permission: Permission::Admin,
},
);
let schema = schema(Query::<MockTestBackendHandler>::new());
let result = execute(QUERY, None, &schema, &Variables::new(), &context).await;
assert!(result.is_ok(), "Query failed: {:?}", result);
}
#[tokio::test]
async fn list_users() {
const QUERY: &str = r#"{
users(filters: {
any: [
{eq: {
field: "id"
value: "bob"
}},
{eq: {
field: "email"
value: "robert@bobbers.on"
}},
{eq: {
field: "firstName"
value: "robert"
}}
]}) {
id
email
}
}"#;
let mut mock = MockTestBackendHandler::new();
setup_default_schema(&mut mock);
mock.expect_list_users()
.with(
eq(Some(lldap_domain_handlers::handler::UserRequestFilter::Or(
vec![
lldap_domain_handlers::handler::UserRequestFilter::UserId(UserId::new(
"bob",
)),
lldap_domain_handlers::handler::UserRequestFilter::Equality(
UserColumn::Email,
"robert@bobbers.on".to_owned(),
),
lldap_domain_handlers::handler::UserRequestFilter::AttributeEquality(
AttributeName::from("first_name"),
"robert".to_string().into(),
),
],
))),
eq(false),
)
.return_once(|_, _| {
Ok(vec![
lldap_domain::types::UserAndGroups {
user: DomainUser {
user_id: UserId::new("bob"),
email: "bob@bobbers.on".into(),
display_name: None,
creation_date: chrono::Utc.timestamp_opt(0, 0).unwrap().naive_utc(),
modified_date: chrono::Utc.timestamp_opt(0, 0).unwrap().naive_utc(),
password_modified_date: chrono::Utc
.timestamp_opt(0, 0)
.unwrap()
.naive_utc(),
uuid: lldap_domain::types::Uuid::from_name_and_date(
"bob",
&chrono::Utc.timestamp_opt(0, 0).unwrap().naive_utc(),
),
attributes: Vec::new(),
},
groups: None,
},
lldap_domain::types::UserAndGroups {
user: DomainUser {
user_id: UserId::new("robert"),
email: "robert@bobbers.on".into(),
display_name: None,
creation_date: chrono::Utc.timestamp_opt(0, 0).unwrap().naive_utc(),
modified_date: chrono::Utc.timestamp_opt(0, 0).unwrap().naive_utc(),
password_modified_date: chrono::Utc
.timestamp_opt(0, 0)
.unwrap()
.naive_utc(),
uuid: lldap_domain::types::Uuid::from_name_and_date(
"robert",
&chrono::Utc.timestamp_opt(0, 0).unwrap().naive_utc(),
),
attributes: Vec::new(),
},
groups: None,
},
])
});
let context = Context::<MockTestBackendHandler>::new_for_tests(
mock,
ValidationResults {
user: UserId::new("admin"),
permission: Permission::Admin,
},
);
let schema = schema(Query::<MockTestBackendHandler>::new());
assert_eq!(
execute(QUERY, None, &schema, &Variables::new(), &context).await,
Ok((
graphql_value!(
{
"users": [
{
"id": "bob",
"email": "bob@bobbers.on"
},
{
"id": "robert",
"email": "robert@bobbers.on"
},
]
}),
vec![]
))
);
}
#[tokio::test]
async fn get_schema() {
const QUERY: &str = r#"{
schema {
userSchema {
attributes {
name
attributeType
isList
isVisible
isEditable
isHardcoded
}
extraLdapObjectClasses
}
groupSchema {
attributes {
name
attributeType
isList
isVisible
isEditable
isHardcoded
}
extraLdapObjectClasses
}
}
}"#;
let mut mock = MockTestBackendHandler::new();
setup_default_schema(&mut mock);
let context = Context::<MockTestBackendHandler>::new_for_tests(
mock,
ValidationResults {
user: UserId::new("admin"),
permission: Permission::Admin,
},
);
let schema = schema(Query::<MockTestBackendHandler>::new());
let result = execute(QUERY, None, &schema, &Variables::new(), &context).await;
assert!(result.is_ok(), "Query failed: {:?}", result);
}
#[tokio::test]
async fn regular_user_doesnt_see_non_visible_attributes() {
const QUERY: &str = r#"{
schema {
userSchema {
attributes {
name
}
extraLdapObjectClasses
}
}
}"#;
let mut mock = MockTestBackendHandler::new();
mock.expect_get_schema().times(1).return_once(|| {
Ok(Schema {
user_attributes: AttributeList {
attributes: vec![DomainAttributeSchema {
name: "invisible".into(),
attribute_type: AttributeType::JpegPhoto,
is_list: false,
is_visible: false,
is_editable: true,
is_hardcoded: true,
is_readonly: false,
}],
},
group_attributes: AttributeList {
attributes: Vec::new(),
},
extra_user_object_classes: vec![LdapObjectClass::from("customUserClass")],
extra_group_object_classes: Vec::new(),
})
});
let context = Context::<MockTestBackendHandler>::new_for_tests(
mock,
ValidationResults {
user: UserId::new("bob"),
permission: Permission::Regular,
},
);
let schema = schema(Query::<MockTestBackendHandler>::new());
let result = execute(QUERY, None, &schema, &Variables::new(), &context).await;
assert!(result.is_ok(), "Query failed: {:?}", result);
}
}

View File

@@ -1,117 +0,0 @@
use juniper::graphql_object;
use lldap_domain::public_schema::PublicSchema;
use lldap_domain::schema::AttributeList as DomainAttributeList;
use lldap_domain::types::LdapObjectClass;
use lldap_domain_handlers::handler::BackendHandler;
use lldap_ldap::{get_default_group_object_classes, get_default_user_object_classes};
use serde::{Deserialize, Serialize};
use super::attribute::AttributeSchema;
use crate::api::Context;
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)]
pub struct AttributeList<Handler: BackendHandler> {
attributes: DomainAttributeList,
default_classes: Vec<LdapObjectClass>,
extra_classes: Vec<LdapObjectClass>,
_phantom: std::marker::PhantomData<Box<Handler>>,
}
#[derive(Clone)]
pub struct ObjectClassInfo {
object_class: String,
is_hardcoded: bool,
}
#[graphql_object]
impl ObjectClassInfo {
fn object_class(&self) -> &str {
&self.object_class
}
fn is_hardcoded(&self) -> bool {
self.is_hardcoded
}
}
#[graphql_object(context = Context<Handler>)]
impl<Handler: BackendHandler> AttributeList<Handler> {
fn attributes(&self) -> Vec<AttributeSchema<Handler>> {
self.attributes
.attributes
.clone()
.into_iter()
.map(Into::into)
.collect()
}
fn extra_ldap_object_classes(&self) -> Vec<String> {
self.extra_classes.iter().map(|c| c.to_string()).collect()
}
fn ldap_object_classes(&self) -> Vec<ObjectClassInfo> {
let mut all_object_classes: Vec<ObjectClassInfo> = self
.default_classes
.iter()
.map(|c| ObjectClassInfo {
object_class: c.to_string(),
is_hardcoded: true,
})
.collect();
all_object_classes.extend(self.extra_classes.iter().map(|c| ObjectClassInfo {
object_class: c.to_string(),
is_hardcoded: false,
}));
all_object_classes
}
}
impl<Handler: BackendHandler> AttributeList<Handler> {
pub fn new(
attributes: DomainAttributeList,
default_classes: Vec<LdapObjectClass>,
extra_classes: Vec<LdapObjectClass>,
) -> Self {
Self {
attributes,
default_classes,
extra_classes,
_phantom: std::marker::PhantomData,
}
}
}
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)]
pub struct Schema<Handler: BackendHandler> {
schema: PublicSchema,
_phantom: std::marker::PhantomData<Box<Handler>>,
}
#[graphql_object(context = Context<Handler>)]
impl<Handler: BackendHandler> Schema<Handler> {
fn user_schema(&self) -> AttributeList<Handler> {
AttributeList::<Handler>::new(
self.schema.get_schema().user_attributes.clone(),
get_default_user_object_classes(),
self.schema.get_schema().extra_user_object_classes.clone(),
)
}
fn group_schema(&self) -> AttributeList<Handler> {
AttributeList::<Handler>::new(
self.schema.get_schema().group_attributes.clone(),
get_default_group_object_classes(),
self.schema.get_schema().extra_group_object_classes.clone(),
)
}
}
impl<Handler: BackendHandler> From<PublicSchema> for Schema<Handler> {
fn from(value: PublicSchema) -> Self {
Self {
schema: value,
_phantom: std::marker::PhantomData,
}
}
}

View File

@@ -1,136 +0,0 @@
use chrono::TimeZone;
use juniper::{FieldResult, graphql_object};
use lldap_access_control::UserReadableBackendHandler;
use lldap_domain::public_schema::PublicSchema;
use lldap_domain::types::{User as DomainUser, UserAndGroups as DomainUserAndGroups};
use lldap_domain_handlers::handler::BackendHandler;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tracing::{Instrument, debug, debug_span};
use super::attribute::AttributeValue;
use super::group::Group;
use crate::api::Context;
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)]
/// Represents a single user.
pub struct User<Handler: BackendHandler> {
user: DomainUser,
attributes: Vec<AttributeValue<Handler>>,
schema: Arc<PublicSchema>,
groups: Option<Vec<Group<Handler>>>,
_phantom: std::marker::PhantomData<Box<Handler>>,
}
impl<Handler: BackendHandler> User<Handler> {
pub fn from_user(mut user: DomainUser, schema: Arc<PublicSchema>) -> FieldResult<Self> {
let attributes = AttributeValue::<Handler>::user_attributes_from_schema(&mut user, &schema);
Ok(Self {
user,
attributes,
schema,
groups: None,
_phantom: std::marker::PhantomData,
})
}
}
impl<Handler: BackendHandler> User<Handler> {
pub fn from_user_and_groups(
DomainUserAndGroups { user, groups }: DomainUserAndGroups,
schema: Arc<PublicSchema>,
) -> FieldResult<Self> {
let mut user = Self::from_user(user, schema.clone())?;
if let Some(groups) = groups {
user.groups = Some(
groups
.into_iter()
.map(|g| Group::<Handler>::from_group_details(g, schema.clone()))
.collect::<FieldResult<Vec<_>>>()?,
);
}
Ok(user)
}
}
#[graphql_object(context = Context<Handler>)]
impl<Handler: BackendHandler> User<Handler> {
fn id(&self) -> &str {
self.user.user_id.as_str()
}
fn email(&self) -> &str {
self.user.email.as_str()
}
fn display_name(&self) -> &str {
self.user.display_name.as_deref().unwrap_or("")
}
fn first_name(&self) -> &str {
self.attributes
.iter()
.find(|a| a.name() == "first_name")
.map(|a| a.attribute.value.as_str().unwrap_or_default())
.unwrap_or_default()
}
fn last_name(&self) -> &str {
self.attributes
.iter()
.find(|a| a.name() == "last_name")
.map(|a| a.attribute.value.as_str().unwrap_or_default())
.unwrap_or_default()
}
fn avatar(&self) -> Option<String> {
self.attributes
.iter()
.find(|a| a.name() == "avatar")
.map(|a| {
String::from(
a.attribute
.value
.as_jpeg_photo()
.expect("Invalid JPEG returned by the DB"),
)
})
}
fn creation_date(&self) -> chrono::DateTime<chrono::Utc> {
chrono::Utc.from_utc_datetime(&self.user.creation_date)
}
fn uuid(&self) -> &str {
self.user.uuid.as_str()
}
/// User-defined attributes.
fn attributes(&self) -> &[AttributeValue<Handler>] {
&self.attributes
}
/// The groups to which this user belongs.
async fn groups(&self, context: &Context<Handler>) -> FieldResult<Vec<Group<Handler>>> {
if let Some(groups) = &self.groups {
return Ok(groups.clone());
}
let span = debug_span!("[GraphQL query] user::groups");
span.in_scope(|| {
debug!(user_id = ?self.user.user_id);
});
let handler = context
.get_readable_handler(&self.user.user_id)
.expect("We shouldn't be able to get there without readable permission");
let domain_groups = handler
.get_user_groups(&self.user.user_id)
.instrument(span)
.await?;
let mut groups = domain_groups
.into_iter()
.map(|g| Group::<Handler>::from_group_details(g, self.schema.clone()))
.collect::<FieldResult<Vec<Group<Handler>>>>()?;
groups.sort_by(|g1, g2| g1.display_name.cmp(&g2.display_name));
Ok(groups)
}
}

View File

@@ -7,7 +7,6 @@ edition.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
[dependencies]
anyhow = "*"
@@ -64,4 +63,4 @@ version = "1.25"
[dev-dependencies.lldap_domain]
path = "../domain"
features = ["test"]
features = ["test"]

View File

@@ -124,7 +124,6 @@ mod tests {
users: vec![UserId::new("bob")],
uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"),
attributes: Vec::new(),
modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
}])
});
let ldap_handler = setup_bound_admin_handler(mock).await;
@@ -219,7 +218,6 @@ mod tests {
users: vec![UserId::new("bob")],
uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"),
attributes: Vec::new(),
modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
}])
});
let ldap_handler = setup_bound_admin_handler(mock).await;

View File

@@ -72,17 +72,11 @@ pub fn get_group_attribute(
.to_rfc3339()
.into_bytes(),
],
GroupFieldType::ModifiedDate => vec![
chrono::Utc
.from_utc_datetime(&group.modified_date)
.to_rfc3339()
.into_bytes(),
],
GroupFieldType::Member => group
.users
.iter()
.filter(|u| user_filter.as_ref().map(|f| *u == f).unwrap_or(true))
.map(|u| format!("uid={u},ou=people,{base_dn_str}").into_bytes())
.map(|u| format!("uid={},ou=people,{}", u, base_dn_str).into_bytes())
.collect(),
GroupFieldType::Uuid => vec![group.uuid.to_string().into_bytes()],
GroupFieldType::Attribute(attr, _, _) => get_custom_attribute(&group.attributes, &attr)?,
@@ -92,7 +86,8 @@ pub fn get_group_attribute(
"+" => return None,
"*" => {
panic!(
"Matched {attribute}, * should have been expanded into attribute list and * removed"
"Matched {}, * should have been expanded into attribute list and * removed",
attribute
)
}
_ => {
@@ -184,11 +179,11 @@ fn get_group_attribute_equality_filter(
]),
(Ok(_), Err(e)) => {
warn!("Invalid value for attribute {} (lowercased): {}", field, e);
GroupRequestFilter::False
GroupRequestFilter::from(false)
}
(Err(e), _) => {
warn!("Invalid value for attribute {}: {}", field, e);
GroupRequestFilter::False
GroupRequestFilter::from(false)
}
}
}
@@ -209,14 +204,14 @@ fn convert_group_filter(
.map(|id| GroupRequestFilter::GroupId(GroupId(id)))
.unwrap_or_else(|_| {
warn!("Given group id is not a valid integer: {}", value_lc);
GroupRequestFilter::False
GroupRequestFilter::from(false)
})),
GroupFieldType::DisplayName => Ok(GroupRequestFilter::DisplayName(value_lc.into())),
GroupFieldType::Uuid => Uuid::try_from(value_lc.as_str())
.map(GroupRequestFilter::Uuid)
.map_err(|e| LdapError {
code: LdapResultCode::Other,
message: format!("Invalid UUID: {e:#}"),
message: format!("Invalid UUID: {:#}", e),
}),
GroupFieldType::Member => Ok(get_user_id_from_distinguished_name_or_plain_name(
&value_lc,
@@ -226,7 +221,7 @@ fn convert_group_filter(
.map(GroupRequestFilter::Member)
.unwrap_or_else(|e| {
warn!("Invalid member filter on group: {}", e);
GroupRequestFilter::False
GroupRequestFilter::from(false)
})),
GroupFieldType::ObjectClass => Ok(GroupRequestFilter::from(
get_default_group_object_classes()
@@ -246,7 +241,7 @@ fn convert_group_filter(
.map(GroupRequestFilter::DisplayName)
.unwrap_or_else(|_| {
warn!("Invalid dn filter on group: {}", value_lc);
GroupRequestFilter::False
GroupRequestFilter::from(false)
}))
}
GroupFieldType::NoMatch => {
@@ -257,7 +252,7 @@ fn convert_group_filter(
field
);
}
Ok(GroupRequestFilter::False)
Ok(GroupRequestFilter::from(false))
}
GroupFieldType::Attribute(field, typ, is_list) => Ok(
get_group_attribute_equality_filter(&field, typ, is_list, value),
@@ -266,61 +261,23 @@ fn convert_group_filter(
code: LdapResultCode::UnwillingToPerform,
message: "Creation date filter for groups not supported".to_owned(),
}),
GroupFieldType::ModifiedDate => Err(LdapError {
code: LdapResultCode::UnwillingToPerform,
message: "Modified date filter for groups not supported".to_owned(),
}),
}
}
LdapFilter::And(filters) => {
let res = filters
.iter()
.map(rec)
.filter(|f| !matches!(f, Ok(GroupRequestFilter::True)))
.flat_map(|f| match f {
Ok(GroupRequestFilter::And(v)) => v.into_iter().map(Ok).collect(),
f => vec![f],
})
.collect::<LdapResult<Vec<_>>>()?;
if res.is_empty() {
Ok(GroupRequestFilter::True)
} else if res.len() == 1 {
Ok(res.into_iter().next().unwrap())
} else {
Ok(GroupRequestFilter::And(res))
}
}
LdapFilter::Or(filters) => {
let res = filters
.iter()
.map(rec)
.filter(|c| !matches!(c, Ok(GroupRequestFilter::False)))
.flat_map(|f| match f {
Ok(GroupRequestFilter::Or(v)) => v.into_iter().map(Ok).collect(),
f => vec![f],
})
.collect::<LdapResult<Vec<_>>>()?;
if res.is_empty() {
Ok(GroupRequestFilter::False)
} else if res.len() == 1 {
Ok(res.into_iter().next().unwrap())
} else {
Ok(GroupRequestFilter::Or(res))
}
}
LdapFilter::Not(filter) => Ok(match rec(filter)? {
GroupRequestFilter::True => GroupRequestFilter::False,
GroupRequestFilter::False => GroupRequestFilter::True,
f => GroupRequestFilter::Not(Box::new(f)),
}),
LdapFilter::And(filters) => Ok(GroupRequestFilter::And(
filters.iter().map(rec).collect::<LdapResult<_>>()?,
)),
LdapFilter::Or(filters) => Ok(GroupRequestFilter::Or(
filters.iter().map(rec).collect::<LdapResult<_>>()?,
)),
LdapFilter::Not(filter) => Ok(GroupRequestFilter::Not(Box::new(rec(filter)?))),
LdapFilter::Present(field) => {
let field = AttributeName::from(field.as_str());
Ok(match map_group_field(&field, schema) {
GroupFieldType::Attribute(name, _, _) => {
GroupRequestFilter::CustomAttributePresent(name)
}
GroupFieldType::NoMatch => GroupRequestFilter::False,
_ => GroupRequestFilter::True,
GroupFieldType::NoMatch => GroupRequestFilter::from(false),
_ => GroupRequestFilter::from(true),
})
}
LdapFilter::Substring(field, substring_filter) => {
@@ -329,18 +286,19 @@ fn convert_group_filter(
GroupFieldType::DisplayName => Ok(GroupRequestFilter::DisplayNameSubString(
substring_filter.clone().into(),
)),
GroupFieldType::NoMatch => Ok(GroupRequestFilter::False),
GroupFieldType::NoMatch => Ok(GroupRequestFilter::from(false)),
_ => Err(LdapError {
code: LdapResultCode::UnwillingToPerform,
message: format!(
"Unsupported group attribute for substring filter: \"{field}\""
"Unsupported group attribute for substring filter: \"{}\"",
field
),
}),
}
}
_ => Err(LdapError {
code: LdapResultCode::UnwillingToPerform,
message: format!("Unsupported group filter: {filter:?}"),
message: format!("Unsupported group filter: {:?}", filter),
}),
}
}
@@ -360,7 +318,7 @@ pub async fn get_groups_list<Backend: GroupListerBackendHandler>(
.await
.map_err(|e| LdapError {
code: LdapResultCode::Other,
message: format!(r#"Error while listing groups "{base}": {e:#}"#),
message: format!(r#"Error while listing groups "{}": {:#}"#, base, e),
})
}
@@ -388,305 +346,3 @@ pub fn convert_groups_to_ldap_op<'a>(
))
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
handler::tests::{make_group_search_request, setup_bound_admin_handler},
search::{make_search_request, make_search_success},
};
use ldap3_proto::proto::LdapSubstringFilter;
use lldap_domain::{
types::{GroupId, UserId},
uuid,
};
use lldap_domain_handlers::handler::*;
use lldap_test_utils::MockTestBackendHandler;
use mockall::predicate::eq;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn test_search_groups() {
let mut mock = MockTestBackendHandler::new();
mock.expect_list_groups()
.with(eq(Some(GroupRequestFilter::True)))
.times(1)
.return_once(|_| {
Ok(vec![
Group {
id: GroupId(1),
display_name: "group_1".into(),
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
users: vec![UserId::new("bob"), UserId::new("john")],
uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"),
attributes: Vec::new(),
modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
},
Group {
id: GroupId(3),
display_name: "BestGroup".into(),
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
users: vec![UserId::new("john")],
uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"),
attributes: Vec::new(),
modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
},
])
});
let ldap_handler = setup_bound_admin_handler(mock).await;
let request = make_group_search_request(
LdapFilter::And(vec![]),
vec![
"objectClass",
"dn",
"cn",
"uniqueMember",
"entryUuid",
"entryDN",
],
);
assert_eq!(
ldap_handler.do_search_or_dse(&request).await,
Ok(vec![
LdapOp::SearchResultEntry(LdapSearchResultEntry {
dn: "cn=group_1,ou=groups,dc=example,dc=com".to_string(),
attributes: vec![
LdapPartialAttribute {
atype: "cn".to_string(),
vals: vec![b"group_1".to_vec()]
},
LdapPartialAttribute {
atype: "entryDN".to_string(),
vals: vec![b"uid=group_1,ou=groups,dc=example,dc=com".to_vec()],
},
LdapPartialAttribute {
atype: "entryUuid".to_string(),
vals: vec![b"04ac75e0-2900-3e21-926c-2f732c26b3fc".to_vec()],
},
LdapPartialAttribute {
atype: "objectClass".to_string(),
vals: vec![b"groupOfUniqueNames".to_vec(), b"groupOfNames".to_vec()]
},
LdapPartialAttribute {
atype: "uniqueMember".to_string(),
vals: vec![
b"uid=bob,ou=people,dc=example,dc=com".to_vec(),
b"uid=john,ou=people,dc=example,dc=com".to_vec(),
],
},
],
}),
LdapOp::SearchResultEntry(LdapSearchResultEntry {
dn: "cn=BestGroup,ou=groups,dc=example,dc=com".to_string(),
attributes: vec![
LdapPartialAttribute {
atype: "cn".to_string(),
vals: vec![b"BestGroup".to_vec()]
},
LdapPartialAttribute {
atype: "entryDN".to_string(),
vals: vec![b"uid=BestGroup,ou=groups,dc=example,dc=com".to_vec()],
},
LdapPartialAttribute {
atype: "entryUuid".to_string(),
vals: vec![b"04ac75e0-2900-3e21-926c-2f732c26b3fc".to_vec()],
},
LdapPartialAttribute {
atype: "objectClass".to_string(),
vals: vec![b"groupOfUniqueNames".to_vec(), b"groupOfNames".to_vec()]
},
LdapPartialAttribute {
atype: "uniqueMember".to_string(),
vals: vec![b"uid=john,ou=people,dc=example,dc=com".to_vec()],
},
],
}),
make_search_success(),
])
);
}
#[tokio::test]
async fn test_search_groups_by_groupid() {
let mut mock = MockTestBackendHandler::new();
mock.expect_list_groups()
.with(eq(Some(GroupRequestFilter::GroupId(GroupId(1)))))
.times(1)
.return_once(|_| {
Ok(vec![Group {
display_name: "group_1".into(),
id: GroupId(1),
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
users: vec![],
uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"),
attributes: Vec::new(),
modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
}])
});
let ldap_handler = setup_bound_admin_handler(mock).await;
let request = make_group_search_request(
LdapFilter::Equality("groupid".to_string(), "1".to_string()),
vec!["dn"],
);
assert_eq!(
ldap_handler.do_search_or_dse(&request).await,
Ok(vec![
LdapOp::SearchResultEntry(LdapSearchResultEntry {
dn: "cn=group_1,ou=groups,dc=example,dc=com".to_string(),
attributes: vec![],
}),
make_search_success(),
])
);
}
#[tokio::test]
async fn test_search_groups_filter() {
let mut mock = MockTestBackendHandler::new();
mock.expect_list_groups()
.with(eq(Some(GroupRequestFilter::And(vec![
GroupRequestFilter::DisplayName("group_1".into()),
GroupRequestFilter::Member(UserId::new("bob")),
GroupRequestFilter::DisplayName("rockstars".into()),
false.into(),
GroupRequestFilter::Uuid(uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc")),
false.into(),
GroupRequestFilter::DisplayNameSubString(SubStringFilter {
initial: Some("iNIt".to_owned()),
any: vec!["1".to_owned(), "2aA".to_owned()],
final_: Some("finAl".to_owned()),
}),
]))))
.times(1)
.return_once(|_| {
Ok(vec![Group {
display_name: "group_1".into(),
id: GroupId(1),
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
users: vec![],
uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"),
attributes: Vec::new(),
modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
}])
});
let ldap_handler = setup_bound_admin_handler(mock).await;
let request = make_group_search_request(
LdapFilter::And(vec![
LdapFilter::Equality("cN".to_string(), "Group_1".to_string()),
LdapFilter::Equality(
"uniqueMember".to_string(),
"uid=bob,ou=peopLe,Dc=eXample,dc=com".to_string(),
),
LdapFilter::Equality(
"dn".to_string(),
"uid=rockstars,ou=groups,dc=example,dc=com".to_string(),
),
LdapFilter::Equality(
"dn".to_string(),
"uid=rockstars,ou=people,dc=example,dc=com".to_string(),
),
LdapFilter::Equality(
"uuid".to_string(),
"04ac75e0-2900-3e21-926c-2f732c26b3fc".to_string(),
),
LdapFilter::Equality("obJEctclass".to_string(), "groupofUniqueNames".to_string()),
LdapFilter::Equality("objectclass".to_string(), "groupOfNames".to_string()),
LdapFilter::Present("objectclass".to_string()),
LdapFilter::Present("dn".to_string()),
LdapFilter::Not(Box::new(LdapFilter::Present(
"random_attribUte".to_string(),
))),
LdapFilter::Equality("unknown_attribute".to_string(), "randomValue".to_string()),
LdapFilter::Substring(
"cn".to_owned(),
LdapSubstringFilter {
initial: Some("iNIt".to_owned()),
any: vec!["1".to_owned(), "2aA".to_owned()],
final_: Some("finAl".to_owned()),
},
),
]),
vec!["1.1"],
);
assert_eq!(
ldap_handler.do_search_or_dse(&request).await,
Ok(vec![
LdapOp::SearchResultEntry(LdapSearchResultEntry {
dn: "cn=group_1,ou=groups,dc=example,dc=com".to_string(),
attributes: vec![],
}),
make_search_success(),
])
);
}
#[tokio::test]
async fn test_search_groups_filter_2() {
let mut mock = MockTestBackendHandler::new();
mock.expect_list_groups()
.with(eq(Some(GroupRequestFilter::Or(vec![
GroupRequestFilter::DisplayName("group_1".into()),
GroupRequestFilter::Member(UserId::new("bob")),
]))))
.times(1)
.return_once(|_| Ok(vec![]));
let ldap_handler = setup_bound_admin_handler(mock).await;
let request = make_group_search_request(
LdapFilter::Or(vec![
LdapFilter::Equality("cn".to_string(), "group_1".to_string()),
LdapFilter::Equality(
"member".to_string(),
"uid=bob,ou=people,dc=example,dc=com".to_string(),
),
]),
vec!["cn"],
);
assert_eq!(
ldap_handler.do_search_or_dse(&request).await,
Ok(vec![make_search_success()])
);
}
#[tokio::test]
async fn test_search_groups_filter_3() {
let mut mock = MockTestBackendHandler::new();
mock.expect_list_groups()
.with(eq(Some(GroupRequestFilter::Not(Box::new(
GroupRequestFilter::DisplayName("group_1".into()),
)))))
.times(1)
.return_once(|_| Ok(vec![]));
let ldap_handler = setup_bound_admin_handler(mock).await;
let request = make_group_search_request(
LdapFilter::Not(Box::new(LdapFilter::Equality(
"cn".to_string(),
"group_1".to_string(),
))),
vec!["cn"],
);
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();
mock.expect_list_groups()
.with(eq(Some(GroupRequestFilter::DisplayName("group_1".into()))))
.times(1)
.return_once(|_| Ok(vec![]));
let ldap_handler = setup_bound_admin_handler(mock).await;
let request = make_search_request(
"cn=group_1,ou=groups,dc=example,dc=com",
LdapFilter::And(vec![]),
vec!["objectClass"],
);
assert_eq!(
ldap_handler.do_search_or_dse(&request).await,
Ok(vec![make_search_success()]),
);
}
}

View File

@@ -3,10 +3,10 @@ use crate::core::{
utils::{
ExpandedAttributes, LdapInfo, UserFieldType, expand_attribute_wildcards,
get_custom_attribute, get_group_id_from_distinguished_name_or_plain_name,
get_user_id_from_distinguished_name_or_plain_name, map_user_field, to_generalized_time,
get_user_id_from_distinguished_name_or_plain_name, map_user_field,
},
};
use chrono::TimeZone;
use ldap3_proto::{
LdapFilter, LdapPartialAttribute, LdapResultCode, LdapSearchResultEntry, proto::LdapOp,
};
@@ -87,15 +87,12 @@ pub fn get_user_attribute(
UserFieldType::PrimaryField(UserColumn::DisplayName) => {
vec![user.display_name.clone()?.into_bytes()]
}
UserFieldType::PrimaryField(UserColumn::CreationDate) => {
vec![to_generalized_time(&user.creation_date)]
}
UserFieldType::PrimaryField(UserColumn::ModifiedDate) => {
vec![to_generalized_time(&user.modified_date)]
}
UserFieldType::PrimaryField(UserColumn::PasswordModifiedDate) => {
vec![to_generalized_time(&user.password_modified_date)]
}
UserFieldType::PrimaryField(UserColumn::CreationDate) => vec![
chrono::Utc
.from_utc_datetime(&user.creation_date)
.to_rfc3339()
.into_bytes(),
],
UserFieldType::Attribute(attr, _, _) => get_custom_attribute(&user.attributes, &attr)?,
UserFieldType::NoMatch => match attribute.as_str() {
"1.1" => return None,
@@ -103,7 +100,8 @@ pub fn get_user_attribute(
"+" => return None,
"*" => {
panic!(
"Matched {attribute}, * should have been expanded into attribute list and * removed"
"Matched {}, * should have been expanded into attribute list and * removed",
attribute
)
}
_ => {
@@ -193,11 +191,11 @@ fn get_user_attribute_equality_filter(
]),
(Ok(_), Err(e)) => {
warn!("Invalid value for attribute {} (lowercased): {}", field, e);
UserRequestFilter::False
UserRequestFilter::from(false)
}
(Err(e), _) => {
warn!("Invalid value for attribute {}: {}", field, e);
UserRequestFilter::False
UserRequestFilter::from(false)
}
}
}
@@ -209,47 +207,13 @@ fn convert_user_filter(
) -> LdapResult<UserRequestFilter> {
let rec = |f| convert_user_filter(ldap_info, f, schema);
match filter {
LdapFilter::And(filters) => {
let res = filters
.iter()
.map(rec)
.filter(|c| !matches!(c, Ok(UserRequestFilter::True)))
.flat_map(|f| match f {
Ok(UserRequestFilter::And(v)) => v.into_iter().map(Ok).collect(),
f => vec![f],
})
.collect::<LdapResult<Vec<_>>>()?;
if res.is_empty() {
Ok(UserRequestFilter::True)
} else if res.len() == 1 {
Ok(res.into_iter().next().unwrap())
} else {
Ok(UserRequestFilter::And(res))
}
}
LdapFilter::Or(filters) => {
let res = filters
.iter()
.map(rec)
.filter(|c| !matches!(c, Ok(UserRequestFilter::False)))
.flat_map(|f| match f {
Ok(UserRequestFilter::Or(v)) => v.into_iter().map(Ok).collect(),
f => vec![f],
})
.collect::<LdapResult<Vec<_>>>()?;
if res.is_empty() {
Ok(UserRequestFilter::False)
} else if res.len() == 1 {
Ok(res.into_iter().next().unwrap())
} else {
Ok(UserRequestFilter::Or(res))
}
}
LdapFilter::Not(filter) => Ok(match rec(filter)? {
UserRequestFilter::True => UserRequestFilter::False,
UserRequestFilter::False => UserRequestFilter::True,
f => UserRequestFilter::Not(Box::new(f)),
}),
LdapFilter::And(filters) => Ok(UserRequestFilter::And(
filters.iter().map(rec).collect::<LdapResult<_>>()?,
)),
LdapFilter::Or(filters) => Ok(UserRequestFilter::Or(
filters.iter().map(rec).collect::<LdapResult<_>>()?,
)),
LdapFilter::Not(filter) => Ok(UserRequestFilter::Not(Box::new(rec(filter)?))),
LdapFilter::Equality(field, value) => {
let field = AttributeName::from(field.as_str());
let value_lc = value.to_ascii_lowercase();
@@ -261,21 +225,6 @@ fn convert_user_filter(
UserColumn::LowercaseEmail,
value_lc,
)),
UserFieldType::PrimaryField(UserColumn::DisplayName) => {
// DisplayName (cn) should match case-insensitively, so we try both
// the original value and the lowercase value (if different)
if value.as_str() == value_lc {
Ok(UserRequestFilter::Equality(
UserColumn::DisplayName,
value_lc,
))
} else {
Ok(UserRequestFilter::Or(vec![
UserRequestFilter::Equality(UserColumn::DisplayName, value.to_string()),
UserRequestFilter::Equality(UserColumn::DisplayName, value_lc),
]))
}
}
UserFieldType::PrimaryField(field) => {
Ok(UserRequestFilter::Equality(field, value_lc))
}
@@ -290,7 +239,7 @@ fn convert_user_filter(
field
);
}
Ok(UserRequestFilter::False)
Ok(UserRequestFilter::from(false))
}
UserFieldType::ObjectClass => Ok(UserRequestFilter::from(
get_default_user_object_classes()
@@ -309,7 +258,7 @@ fn convert_user_filter(
.map(UserRequestFilter::MemberOf)
.unwrap_or_else(|e| {
warn!("Invalid memberOf filter: {}", e);
UserRequestFilter::False
UserRequestFilter::from(false)
})),
UserFieldType::EntryDn | UserFieldType::Dn => {
Ok(get_user_id_from_distinguished_name_or_plain_name(
@@ -320,7 +269,7 @@ fn convert_user_filter(
.map(UserRequestFilter::UserId)
.unwrap_or_else(|_| {
warn!("Invalid dn filter on user: {}", value_lc);
UserRequestFilter::False
UserRequestFilter::from(false)
}))
}
}
@@ -331,8 +280,8 @@ fn convert_user_filter(
UserFieldType::Attribute(name, _, _) => {
UserRequestFilter::CustomAttributePresent(name)
}
UserFieldType::NoMatch => UserRequestFilter::False,
_ => UserRequestFilter::True,
UserFieldType::NoMatch => UserRequestFilter::from(false),
_ => UserRequestFilter::from(true),
})
}
LdapFilter::Substring(field, substring_filter) => {
@@ -349,9 +298,12 @@ fn convert_user_filter(
| UserFieldType::PrimaryField(UserColumn::CreationDate)
| UserFieldType::PrimaryField(UserColumn::Uuid) => Err(LdapError {
code: LdapResultCode::UnwillingToPerform,
message: format!("Unsupported user attribute for substring filter: {field:?}"),
message: format!(
"Unsupported user attribute for substring filter: {:?}",
field
),
}),
UserFieldType::NoMatch => Ok(UserRequestFilter::False),
UserFieldType::NoMatch => Ok(UserRequestFilter::from(false)),
UserFieldType::PrimaryField(UserColumn::Email) => Ok(UserRequestFilter::SubString(
UserColumn::LowercaseEmail,
substring_filter.clone().into(),
@@ -364,7 +316,7 @@ fn convert_user_filter(
}
_ => Err(LdapError {
code: LdapResultCode::UnwillingToPerform,
message: format!("Unsupported user filter: {filter:?}"),
message: format!("Unsupported user filter: {:?}", filter),
}),
}
}
@@ -389,7 +341,7 @@ pub async fn get_user_list<Backend: UserListerBackendHandler>(
.await
.map_err(|e| LdapError {
code: LdapResultCode::Other,
message: format!(r#"Error while searching user "{base}": {e:#}"#),
message: format!(r#"Error while searching user "{}": {:#}"#, base, e),
})
}
@@ -415,417 +367,3 @@ pub fn convert_users_to_ldap_op<'a>(
))
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
handler::tests::{
make_user_search_request, setup_bound_admin_handler, setup_bound_handler_with_group,
setup_bound_readonly_handler,
},
search::{make_search_request, make_search_success},
};
use chrono::{DateTime, Duration, NaiveDateTime, TimeZone, Utc};
use lldap_domain::types::{Attribute, GroupDetails, JpegPhoto};
use lldap_test_utils::MockTestBackendHandler;
use mockall::predicate::eq;
use pretty_assertions::assert_eq;
fn assert_timestamp_within_margin(
timestamp_bytes: &[u8],
base_timestamp_dt: DateTime<Utc>,
time_margin: Duration,
) {
let timestamp_str =
std::str::from_utf8(timestamp_bytes).expect("Invalid conversion from UTF-8 to string");
let timestamp_naive = NaiveDateTime::parse_from_str(timestamp_str, "%Y%m%d%H%M%SZ")
.expect("Invalid timestamp format");
let timestamp_dt: DateTime<Utc> = Utc.from_utc_datetime(&timestamp_naive);
let within_range = (base_timestamp_dt - timestamp_dt).abs() <= time_margin;
assert!(
within_range,
"Timestamp not within range: expected within [{} - {}], got [{}]",
base_timestamp_dt - time_margin,
base_timestamp_dt + time_margin,
timestamp_dt
);
}
#[tokio::test]
async fn test_search_regular_user() {
let mut mock = MockTestBackendHandler::new();
mock.expect_list_users()
.with(
eq(Some(UserRequestFilter::And(vec![
UserRequestFilter::True,
UserRequestFilter::UserId(UserId::new("test")),
]))),
eq(false),
)
.times(1)
.return_once(|_, _| {
Ok(vec![UserAndGroups {
user: User {
user_id: UserId::new("test"),
..Default::default()
},
groups: None,
}])
});
let ldap_handler = setup_bound_handler_with_group(mock, "regular").await;
let request =
make_user_search_request::<String>(LdapFilter::And(vec![]), vec!["1.1".to_string()]);
assert_eq!(
ldap_handler.do_search_or_dse(&request).await,
Ok(vec![
LdapOp::SearchResultEntry(LdapSearchResultEntry {
dn: "uid=test,ou=people,dc=example,dc=com".to_string(),
attributes: vec![],
}),
make_search_success()
]),
);
}
#[tokio::test]
async fn test_search_readonly_user() {
let mut mock = MockTestBackendHandler::new();
mock.expect_list_users()
.with(eq(Some(UserRequestFilter::True)), eq(false))
.times(1)
.return_once(|_, _| Ok(vec![]));
let ldap_handler = setup_bound_readonly_handler(mock).await;
let request = make_user_search_request(LdapFilter::And(vec![]), vec!["1.1"]);
assert_eq!(
ldap_handler.do_search_or_dse(&request).await,
Ok(vec![make_search_success()]),
);
}
#[tokio::test]
async fn test_search_member_of() {
let mut mock = MockTestBackendHandler::new();
mock.expect_list_users()
.with(eq(Some(UserRequestFilter::True)), eq(true))
.times(1)
.return_once(|_, _| {
Ok(vec![UserAndGroups {
user: User {
user_id: UserId::new("bob"),
..Default::default()
},
groups: Some(vec![GroupDetails {
group_id: lldap_domain::types::GroupId(42),
display_name: "rockstars".into(),
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
uuid: lldap_domain::uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
attributes: Vec::new(),
modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
}]),
}])
});
let ldap_handler = setup_bound_readonly_handler(mock).await;
let request = make_user_search_request::<String>(
LdapFilter::And(vec![]),
vec!["memberOf".to_string()],
);
assert_eq!(
ldap_handler.do_search_or_dse(&request).await,
Ok(vec![
LdapOp::SearchResultEntry(LdapSearchResultEntry {
dn: "uid=bob,ou=people,dc=example,dc=com".to_string(),
attributes: vec![LdapPartialAttribute {
atype: "memberOf".to_string(),
vals: vec![b"cn=rockstars,ou=groups,dc=example,dc=com".to_vec()]
}],
}),
make_search_success(),
]),
);
}
#[tokio::test]
async fn test_search_user_as_scope() {
let mut mock = MockTestBackendHandler::new();
mock.expect_list_users()
.with(
eq(Some(UserRequestFilter::UserId(UserId::new("bob")))),
eq(false),
)
.times(1)
.return_once(|_, _| Ok(vec![]));
let ldap_handler = setup_bound_admin_handler(mock).await;
let request = make_search_request(
"uid=bob,ou=people,dc=example,dc=com",
LdapFilter::And(vec![]),
vec!["objectClass"],
);
assert_eq!(
ldap_handler.do_search_or_dse(&request).await,
Ok(vec![make_search_success()]),
);
}
#[tokio::test]
async fn test_search_users() {
use chrono::prelude::*;
use lldap_domain::uuid;
let mut mock = MockTestBackendHandler::new();
mock.expect_list_users().times(1).return_once(|_, _| {
Ok(vec![
UserAndGroups {
user: User {
user_id: UserId::new("bob_1"),
email: "bob@bobmail.bob".into(),
display_name: Some("Bôb Böbberson".to_string()),
uuid: uuid!("698e1d5f-7a40-3151-8745-b9b8a37839da"),
attributes: vec![
Attribute {
name: "first_name".into(),
value: "Bôb".to_string().into(),
},
Attribute {
name: "last_name".into(),
value: "Böbberson".to_string().into(),
},
],
..Default::default()
},
groups: None,
},
UserAndGroups {
user: User {
user_id: UserId::new("jim"),
email: "jim@cricket.jim".into(),
display_name: Some("Jimminy Cricket".to_string()),
attributes: vec![
Attribute {
name: "avatar".into(),
value: JpegPhoto::for_tests().into(),
},
Attribute {
name: "first_name".into(),
value: "Jim".to_string().into(),
},
Attribute {
name: "last_name".into(),
value: "Cricket".to_string().into(),
},
],
uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"),
creation_date: Utc
.with_ymd_and_hms(2014, 7, 8, 9, 10, 11)
.unwrap()
.naive_utc(),
modified_date: Utc
.with_ymd_and_hms(2014, 7, 8, 9, 10, 11)
.unwrap()
.naive_utc(),
password_modified_date: Utc
.with_ymd_and_hms(2014, 7, 8, 9, 10, 11)
.unwrap()
.naive_utc(),
},
groups: None,
},
])
});
let ldap_handler = setup_bound_admin_handler(mock).await;
let request = make_user_search_request(
LdapFilter::And(vec![]),
vec![
"objectClass",
"dn",
"uid",
"mail",
"givenName",
"sn",
"cn",
"createTimestamp",
"entryUuid",
"jpegPhoto",
],
);
assert_eq!(
ldap_handler.do_search_or_dse(&request).await,
Ok(vec![
LdapOp::SearchResultEntry(LdapSearchResultEntry {
dn: "uid=bob_1,ou=people,dc=example,dc=com".to_string(),
attributes: vec![
LdapPartialAttribute {
atype: "cn".to_string(),
vals: vec!["Bôb Böbberson".to_string().into_bytes()]
},
LdapPartialAttribute {
atype: "createTimestamp".to_string(),
vals: vec![b"19700101000000Z".to_vec()]
},
LdapPartialAttribute {
atype: "entryUuid".to_string(),
vals: vec![b"698e1d5f-7a40-3151-8745-b9b8a37839da".to_vec()]
},
LdapPartialAttribute {
atype: "givenName".to_string(),
vals: vec!["Bôb".to_string().into_bytes()]
},
LdapPartialAttribute {
atype: "mail".to_string(),
vals: vec![b"bob@bobmail.bob".to_vec()]
},
LdapPartialAttribute {
atype: "objectClass".to_string(),
vals: vec![
b"inetOrgPerson".to_vec(),
b"posixAccount".to_vec(),
b"mailAccount".to_vec(),
b"person".to_vec(),
b"customUserClass".to_vec(),
]
},
LdapPartialAttribute {
atype: "sn".to_string(),
vals: vec!["Böbberson".to_string().into_bytes()]
},
LdapPartialAttribute {
atype: "uid".to_string(),
vals: vec![b"bob_1".to_vec()]
},
],
}),
LdapOp::SearchResultEntry(LdapSearchResultEntry {
dn: "uid=jim,ou=people,dc=example,dc=com".to_string(),
attributes: vec![
LdapPartialAttribute {
atype: "cn".to_string(),
vals: vec![b"Jimminy Cricket".to_vec()]
},
LdapPartialAttribute {
atype: "createTimestamp".to_string(),
vals: vec![b"20140708091011Z".to_vec()]
},
LdapPartialAttribute {
atype: "entryUuid".to_string(),
vals: vec![b"04ac75e0-2900-3e21-926c-2f732c26b3fc".to_vec()]
},
LdapPartialAttribute {
atype: "givenName".to_string(),
vals: vec![b"Jim".to_vec()]
},
LdapPartialAttribute {
atype: "jpegPhoto".to_string(),
vals: vec![JpegPhoto::for_tests().into_bytes()]
},
LdapPartialAttribute {
atype: "mail".to_string(),
vals: vec![b"jim@cricket.jim".to_vec()]
},
LdapPartialAttribute {
atype: "objectClass".to_string(),
vals: vec![
b"inetOrgPerson".to_vec(),
b"posixAccount".to_vec(),
b"mailAccount".to_vec(),
b"person".to_vec(),
b"customUserClass".to_vec(),
]
},
LdapPartialAttribute {
atype: "sn".to_string(),
vals: vec![b"Cricket".to_vec()]
},
LdapPartialAttribute {
atype: "uid".to_string(),
vals: vec![b"jim".to_vec()]
},
],
}),
make_search_success(),
])
);
}
#[tokio::test]
async fn test_pwd_changed_time_format() {
use lldap_domain::uuid;
let mut mock = MockTestBackendHandler::new();
mock.expect_list_users().times(1).return_once(|_, _| {
Ok(vec![UserAndGroups {
user: User {
user_id: UserId::new("bob_1"),
email: "bob@bobmail.bob".into(),
uuid: uuid!("698e1d5f-7a40-3151-8745-b9b8a37839da"),
attributes: vec![],
password_modified_date: Utc
.with_ymd_and_hms(2014, 7, 8, 9, 10, 11)
.unwrap()
.naive_utc(),
..Default::default()
},
groups: None,
}])
});
let ldap_handler = setup_bound_admin_handler(mock).await;
let request = make_user_search_request(LdapFilter::And(vec![]), vec!["pwdChangedTime"]);
if let LdapOp::SearchResultEntry(entry) =
&ldap_handler.do_search_or_dse(&request).await.unwrap()[0]
{
assert_eq!(entry.attributes.len(), 1);
assert_eq!(entry.attributes[0].atype, "pwdChangedTime");
assert_eq!(entry.attributes[0].vals.len(), 1);
assert_timestamp_within_margin(
&entry.attributes[0].vals[0],
Utc.with_ymd_and_hms(2014, 7, 8, 9, 10, 11).unwrap(),
Duration::seconds(1),
);
} else {
panic!("Expected SearchResultEntry");
}
}
#[tokio::test]
async fn test_search_cn_case_insensitive() {
use lldap_domain::uuid;
let mut mock = MockTestBackendHandler::new();
mock.expect_list_users()
.with(
eq(Some(UserRequestFilter::Or(vec![
UserRequestFilter::Equality(UserColumn::DisplayName, "TestAll".to_string()),
UserRequestFilter::Equality(UserColumn::DisplayName, "testall".to_string()),
]))),
eq(false),
)
.times(1)
.return_once(|_, _| {
Ok(vec![UserAndGroups {
user: User {
user_id: UserId::new("testall"),
email: "test@example.com".into(),
display_name: Some("TestAll".to_string()),
uuid: uuid!("698e1d5f-7a40-3151-8745-b9b8a37839da"),
attributes: vec![],
..Default::default()
},
groups: None,
}])
});
let ldap_handler = setup_bound_admin_handler(mock).await;
let request = make_user_search_request(
LdapFilter::Equality("cn".to_string(), "TestAll".to_string()),
vec!["cn", "uid"],
);
let results = ldap_handler.do_search_or_dse(&request).await.unwrap();
assert_eq!(results.len(), 2);
if let LdapOp::SearchResultEntry(entry) = &results[0] {
assert_eq!(entry.dn, "uid=testall,ou=people,dc=example,dc=com");
assert_eq!(entry.attributes.len(), 2);
assert_eq!(entry.attributes[0].atype, "cn");
assert_eq!(entry.attributes[0].vals[0], b"TestAll");
} else {
panic!("Expected SearchResultEntry");
}
}
}

View File

@@ -3,7 +3,7 @@ use crate::core::{
group::{REQUIRED_GROUP_ATTRIBUTES, get_default_group_object_classes},
user::{REQUIRED_USER_ATTRIBUTES, get_default_user_object_classes},
};
use chrono::{NaiveDateTime, TimeZone};
use chrono::TimeZone;
use itertools::join;
use ldap3_proto::LdapResultCode;
use lldap_domain::{
@@ -18,16 +18,6 @@ use lldap_domain_model::model::UserColumn;
use std::collections::BTreeMap;
use tracing::{debug, instrument, warn};
/// Convert a NaiveDateTime to LDAP GeneralizedTime format (YYYYMMDDHHMMSSZ)
/// This is the standard format required by LDAP for timestamp attributes like pwdChangedTime
pub fn to_generalized_time(dt: &NaiveDateTime) -> Vec<u8> {
chrono::Utc
.from_utc_datetime(dt)
.format("%Y%m%d%H%M%SZ")
.to_string()
.into_bytes()
}
fn make_dn_pair<I>(mut iter: I) -> LdapResult<(String, String)>
where
I: Iterator<Item = String>,
@@ -76,9 +66,10 @@ impl UserOrGroupName {
UserOrGroupName::InvalidSyntax(err) => return err,
UserOrGroupName::UnexpectedFormat
| UserOrGroupName::User(_)
| UserOrGroupName::Group(_) => {
format!(r#"Unexpected DN format. Got "{input}", expected: {expected_format}"#)
}
| UserOrGroupName::Group(_) => format!(
r#"Unexpected DN format. Got "{}", expected: {}"#,
input, expected_format
),
},
}
}
@@ -114,7 +105,7 @@ pub fn get_user_id_from_distinguished_name(
) -> LdapResult<UserId> {
match get_user_or_group_id_from_distinguished_name(dn, base_tree) {
UserOrGroupName::User(user_id) => Ok(user_id),
err => Err(err.into_ldap_error(dn, format!(r#""uid=id,ou=people,{base_dn_str}""#))),
err => Err(err.into_ldap_error(dn, format!(r#""uid=id,ou=people,{}""#, base_dn_str))),
}
}
@@ -125,7 +116,7 @@ pub fn get_group_id_from_distinguished_name(
) -> LdapResult<GroupName> {
match get_user_or_group_id_from_distinguished_name(dn, base_tree) {
UserOrGroupName::Group(group_name) => Ok(group_name),
err => Err(err.into_ldap_error(dn, format!(r#""uid=id,ou=groups,{base_dn_str}""#))),
err => Err(err.into_ldap_error(dn, format!(r#""uid=id,ou=groups,{}""#, base_dn_str))),
}
}
@@ -249,15 +240,9 @@ pub fn map_user_field(field: &AttributeName, schema: &PublicSchema) -> UserField
AttributeType::JpegPhoto,
false,
),
"creationdate" | "createtimestamp" | "creation_date" => {
"creationdate" | "createtimestamp" | "modifytimestamp" | "creation_date" => {
UserFieldType::PrimaryField(UserColumn::CreationDate)
}
"modifytimestamp" | "modifydate" | "modified_date" => {
UserFieldType::PrimaryField(UserColumn::ModifiedDate)
}
"pwdchangedtime" | "passwordmodifydate" | "password_modified_date" => {
UserFieldType::PrimaryField(UserColumn::PasswordModifiedDate)
}
"entryuuid" | "uuid" => UserFieldType::PrimaryField(UserColumn::Uuid),
_ => schema
.get_schema()
@@ -273,7 +258,6 @@ pub enum GroupFieldType {
GroupId,
DisplayName,
CreationDate,
ModifiedDate,
ObjectClass,
Dn,
// Like Dn, but returned as part of the attributes.
@@ -289,8 +273,9 @@ pub fn map_group_field(field: &AttributeName, schema: &PublicSchema) -> GroupFie
"entrydn" => GroupFieldType::EntryDn,
"objectclass" => GroupFieldType::ObjectClass,
"cn" | "displayname" | "uid" | "display_name" | "id" => GroupFieldType::DisplayName,
"creationdate" | "createtimestamp" | "creation_date" => GroupFieldType::CreationDate,
"modifytimestamp" | "modifydate" | "modified_date" => GroupFieldType::ModifiedDate,
"creationdate" | "createtimestamp" | "modifytimestamp" | "creation_date" => {
GroupFieldType::CreationDate
}
"member" | "uniquemember" => GroupFieldType::Member,
"entryuuid" | "uuid" => GroupFieldType::Uuid,
"group_id" | "groupid" => GroupFieldType::GroupId,
@@ -310,27 +295,16 @@ pub struct LdapInfo {
pub ignored_group_attributes: Vec<AttributeName>,
}
impl LdapInfo {
pub fn new(
base_dn: &str,
ignored_user_attributes: Vec<AttributeName>,
ignored_group_attributes: Vec<AttributeName>,
) -> LdapResult<Self> {
let base_dn = parse_distinguished_name(&base_dn.to_ascii_lowercase())?;
let base_dn_str = join(base_dn.iter().map(|(k, v)| format!("{k}={v}")), ",");
Ok(Self {
base_dn,
base_dn_str,
ignored_user_attributes,
ignored_group_attributes,
})
}
}
pub fn get_custom_attribute(
attributes: &[Attribute],
attribute_name: &AttributeName,
) -> Option<Vec<Vec<u8>>> {
let convert_date = |date| {
chrono::Utc
.from_utc_datetime(date)
.to_rfc3339()
.into_bytes()
};
attributes
.iter()
.find(|a| &a.name == attribute_name)
@@ -356,9 +330,9 @@ pub fn get_custom_attribute(
AttributeValue::JpegPhoto(Cardinality::Unbounded(l)) => {
l.iter().map(|p| p.clone().into_bytes()).collect()
}
AttributeValue::DateTime(Cardinality::Singleton(dt)) => vec![to_generalized_time(dt)],
AttributeValue::DateTime(Cardinality::Singleton(dt)) => vec![convert_date(dt)],
AttributeValue::DateTime(Cardinality::Unbounded(l)) => {
l.iter().map(to_generalized_time).collect()
l.iter().map(convert_date).collect()
}
})
}
@@ -369,7 +343,7 @@ pub struct ObjectClassList(Vec<LdapObjectClass>);
// See RFC4512 section 4.2.1 "objectClasses"
impl ObjectClassList {
pub fn format_for_ldap_schema_description(&self) -> String {
join(self.0.iter().map(|c| format!("'{c}'")), " ")
join(self.0.iter().map(|c| format!("'{}'", c)), " ")
}
}
@@ -463,23 +437,13 @@ impl LdapSchemaDescription {
// See RFC4512 section 4.2.2 "attributeTypes"
// Parameter 'index_offset' is an offset for the enumeration of this list of attributes,
// it has been preceeded by the list of hardcoded attributes.
pub fn formatted_attribute_list(
&self,
index_offset: usize,
exclude_attributes: Vec<&str>,
) -> Vec<Vec<u8>> {
pub fn formatted_attribute_list(&self, index_offset: usize) -> Vec<Vec<u8>> {
let mut formatted_list: Vec<Vec<u8>> = Vec::new();
for (index, attribute) in self
.all_attributes()
.attributes
.into_iter()
.filter(|attr| !exclude_attributes.contains(&attr.name.as_str()))
.enumerate()
{
for (index, attribute) in self.all_attributes().attributes.into_iter().enumerate() {
formatted_list.push(
format!(
"( 10.{} NAME '{}' DESC 'LLDAP: {}' SUP {:?} )",
"( 2.{} NAME '{}' DESC 'LLDAP: {}' SUP {:?} )",
(index + index_offset),
attribute.name,
if attribute.is_hardcoded {
@@ -542,14 +506,4 @@ mod tests {
parsed_dn
);
}
#[test]
fn test_whitespace_in_ldap_info() {
assert_eq!(
LdapInfo::new(" ou=people, dc =example, dc=com \n", vec![], vec![])
.unwrap()
.base_dn_str,
"ou=people,dc=example,dc=com"
);
}
}

View File

@@ -33,7 +33,10 @@ pub(crate) async fn create_user_or_group(
}
err => Err(err.into_ldap_error(
&request.dn,
format!(r#""uid=id,ou=people,{base_dn_str}" or "uid=id,ou=groups,{base_dn_str}""#),
format!(
r#""uid=id,ou=people,{}" or "uid=id,ou=groups,{}""#,
base_dn_str, base_dn_str
),
)),
}
}
@@ -70,7 +73,10 @@ async fn create_user(
std::str::from_utf8(val)
.map_err(|e| LdapError {
code: LdapResultCode::ConstraintViolation,
message: format!("Attribute value is invalid UTF-8: {e:#?} (value {val:?})"),
message: format!(
"Attribute value is invalid UTF-8: {:#?} (value {:?})",
e, val
),
})
.map(str::to_owned)
}
@@ -86,7 +92,7 @@ async fn create_user(
value: deserialize::deserialize_attribute_value(&[value], typ, false).map_err(|e| {
LdapError {
code: LdapResultCode::ConstraintViolation,
message: format!("Invalid attribute value: {e}"),
message: format!("Invalid attribute value: {}", e),
}
})?,
})
@@ -128,7 +134,7 @@ async fn create_user(
.await
.map_err(|e| LdapError {
code: LdapResultCode::OperationsError,
message: format!("Could not create user: {e:#?}"),
message: format!("Could not create user: {:#?}", e),
})?;
Ok(vec![make_add_response(
LdapResultCode::Success,
@@ -150,7 +156,7 @@ async fn create_group(
.await
.map_err(|e| LdapError {
code: LdapResultCode::OperationsError,
message: format!("Could not create group: {e:#?}"),
message: format!("Could not create group: {:#?}", e),
})?;
Ok(vec![make_add_response(
LdapResultCode::Success,

View File

@@ -30,7 +30,10 @@ pub(crate) async fn delete_user_or_group(
UserOrGroupName::Group(group_name) => delete_group(backend_handler, group_name).await,
err => Err(err.into_ldap_error(
&request,
format!(r#""uid=id,ou=people,{base_dn_str}" or "uid=id,ou=groups,{base_dn_str}""#),
format!(
r#""uid=id,ou=people,{}" or "uid=id,ou=groups,{}""#,
base_dn_str, base_dn_str
),
)),
}
}
@@ -50,7 +53,7 @@ async fn delete_user(
},
e => LdapError {
code: LdapResultCode::OperationsError,
message: format!("Error while finding user: {e:?}"),
message: format!("Error while finding user: {:?}", e),
},
})?;
backend_handler
@@ -58,7 +61,7 @@ async fn delete_user(
.await
.map_err(|e| LdapError {
code: LdapResultCode::OperationsError,
message: format!("Error while deleting user: {e:?}"),
message: format!("Error while deleting user: {:?}", e),
})?;
Ok(vec![make_del_response(
LdapResultCode::Success,
@@ -76,7 +79,7 @@ async fn delete_group(
.await
.map_err(|e| LdapError {
code: LdapResultCode::OperationsError,
message: format!("Error while finding group: {e:?}"),
message: format!("Error while finding group: {:?}", e),
})?;
let group_id = groups
.iter()
@@ -91,7 +94,7 @@ async fn delete_group(
.await
.map_err(|e| LdapError {
code: LdapResultCode::OperationsError,
message: format!("Error while deleting group: {e:?}"),
message: format!("Error while deleting group: {:?}", e),
})?;
Ok(vec![make_del_response(
LdapResultCode::Success,
@@ -154,7 +157,6 @@ mod tests {
uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
users: Vec::new(),
attributes: Vec::new(),
modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
}])
});
mock.expect_delete_group()
@@ -285,7 +287,6 @@ mod tests {
uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
users: Vec::new(),
attributes: Vec::new(),
modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
}])
});
mock.expect_delete_group()

View File

@@ -2,7 +2,7 @@ use crate::{
compare,
core::{
error::{LdapError, LdapResult},
utils::LdapInfo,
utils::{LdapInfo, parse_distinguished_name},
},
create, delete, modify,
password::{self, do_password_modification},
@@ -18,7 +18,7 @@ use ldap3_proto::proto::{
};
use lldap_access_control::AccessControlledBackendHandler;
use lldap_auth::access_control::ValidationResults;
use lldap_domain::public_schema::PublicSchema;
use lldap_domain::{public_schema::PublicSchema, types::AttributeName};
use lldap_domain_handlers::handler::{BackendHandler, LoginHandler, ReadSchemaBackendHandler};
use lldap_opaque_handler::OpaqueHandler;
use tracing::{debug, instrument};
@@ -59,7 +59,7 @@ pub(crate) fn make_modify_response(code: LdapResultCode, message: String) -> Lda
pub struct LdapHandler<Backend> {
user_info: Option<ValidationResults>,
backend_handler: AccessControlledBackendHandler<Backend>,
ldap_info: &'static LdapInfo,
ldap_info: LdapInfo,
session_uuid: uuid::Uuid,
}
@@ -89,13 +89,26 @@ enum Credentials<'s> {
impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend> {
pub fn new(
backend_handler: AccessControlledBackendHandler<Backend>,
ldap_info: &'static LdapInfo,
mut ldap_base_dn: String,
ignored_user_attributes: Vec<AttributeName>,
ignored_group_attributes: Vec<AttributeName>,
session_uuid: uuid::Uuid,
) -> Self {
ldap_base_dn.make_ascii_lowercase();
Self {
user_info: None,
backend_handler,
ldap_info,
ldap_info: LdapInfo {
base_dn: parse_distinguished_name(&ldap_base_dn).unwrap_or_else(|_| {
panic!(
"Invalid value for ldap_base_dn in configuration: {}",
ldap_base_dn
)
}),
base_dn_str: ldap_base_dn,
ignored_user_attributes,
ignored_group_attributes,
},
session_uuid,
}
}
@@ -104,9 +117,9 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
pub fn new_for_tests(backend_handler: Backend, ldap_base_dn: &str) -> Self {
Self::new(
AccessControlledBackendHandler::new(backend_handler),
Box::leak(Box::new(
LdapInfo::new(ldap_base_dn, Vec::new(), Vec::new()).unwrap(),
)),
ldap_base_dn.to_string(),
vec![],
vec![],
uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
)
}
@@ -142,7 +155,7 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
let schema = backend_handler.get_schema().await.map_err(|e| LdapError {
code: LdapResultCode::OperationsError,
message: format!("Unable to get schema: {e:#}"),
message: format!("Unable to get schema: {:#}", e),
})?;
return Ok(vec![
make_ldap_subschema_entry(PublicSchema::from(schema)),
@@ -161,13 +174,13 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
let backend_handler = self
.backend_handler
.get_user_restricted_lister_handler(user_info);
search::do_search(&backend_handler, self.ldap_info, request).await
search::do_search(&backend_handler, &self.ldap_info, request).await
}
#[instrument(skip_all, level = "debug", fields(dn = %request.dn))]
pub async fn do_bind(&mut self, request: &LdapBindRequest) -> Vec<LdapOp> {
let (code, message) =
match password::do_bind(self.ldap_info, request, self.get_login_handler()).await {
match password::do_bind(&self.ldap_info, request, self.get_login_handler()).await {
Ok(user_id) => {
self.user_info = self
.backend_handler
@@ -201,7 +214,7 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
};
do_password_modification(
credentials,
self.ldap_info,
&self.ldap_info,
&self.backend_handler,
self.get_opaque_handler(),
&password_request,
@@ -211,7 +224,7 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
}
Err(e) => vec![make_extended_response(
LdapResultCode::ProtocolError,
format!("Error while parsing password modify request: {e:#?}"),
format!("Error while parsing password modify request: {:#?}", e),
)],
},
OID_WHOAMI => {
@@ -247,7 +260,7 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
self.backend_handler
.get_readable_handler(credentials, &user_id)
},
self.ldap_info,
&self.ldap_info,
credentials,
request,
)
@@ -265,7 +278,7 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
code: LdapResultCode::InsufficentAccessRights,
message: "Unauthorized write".to_string(),
})?;
create::create_user_or_group(backend_handler, self.ldap_info, request).await
create::create_user_or_group(backend_handler, &self.ldap_info, request).await
}
#[instrument(skip_all, level = "debug")]
@@ -278,7 +291,7 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
code: LdapResultCode::InsufficentAccessRights,
message: "Unauthorized write".to_string(),
})?;
delete::delete_user_or_group(backend_handler, self.ldap_info, request).await
delete::delete_user_or_group(backend_handler, &self.ldap_info, request).await
}
#[instrument(skip_all, level = "debug")]
@@ -330,7 +343,7 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
.unwrap_or_else(|e: LdapError| vec![make_search_error(e.code, e.message)]),
op => vec![make_extended_response(
LdapResultCode::UnwillingToPerform,
format!("Unsupported operation: {op:#?}"),
format!("Unsupported operation: {:#?}", op),
)],
})
}
@@ -388,7 +401,6 @@ pub mod tests {
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
attributes: Vec::new(),
modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
});
Ok(set)
});

View File

@@ -7,7 +7,7 @@ pub(crate) mod modify;
pub(crate) mod password;
pub(crate) mod search;
pub use core::utils::{LdapInfo, UserFieldType, map_group_field, map_user_field};
pub use core::utils::{UserFieldType, map_group_field, map_user_field};
pub use handler::LdapHandler;
pub use core::group::get_default_group_object_classes;

View File

@@ -47,7 +47,7 @@ async fn handle_modify_change(
.await
.map_err(|e| LdapError {
code: LdapResultCode::Other,
message: format!("Error while changing the password: {e:#?}"),
message: format!("Error while changing the password: {:#?}", e),
})?;
} else {
return Err(LdapError {
@@ -94,7 +94,7 @@ where
.await
.map_err(|e| LdapError {
code: LdapResultCode::OperationsError,
message: format!("Internal error while requesting user's groups: {e:#?}"),
message: format!("Internal error while requesting user's groups: {:#?}", e),
})?
.iter()
.any(|g| g.display_name == "lldap_admin".into());
@@ -115,7 +115,7 @@ where
}
Err(e) => Err(LdapError {
code: LdapResultCode::InvalidDNSyntax,
message: format!("Invalid username: {e}"),
message: format!("Invalid username: {}", e),
}),
}
}
@@ -158,7 +158,6 @@ mod tests {
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
attributes: Vec::new(),
modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
});
}
Ok(g)
@@ -167,7 +166,7 @@ mod tests {
fn make_password_modify_request(target_user: &str) -> LdapModifyRequest {
LdapModifyRequest {
dn: format!("uid={target_user},ou=people,dc=example,dc=com"),
dn: format!("uid={},ou=people,dc=example,dc=com", target_user),
changes: vec![LdapModify {
operation: LdapModifyType::Replace,
modification: ldap3_proto::LdapPartialAttribute {
@@ -285,7 +284,7 @@ mod tests {
let request = {
let target_user = "bob";
LdapModifyRequest {
dn: format!("uid={target_user},ou=people,dc=example,dc=com"),
dn: format!("uid={},ou=people,dc=example,dc=com", target_user),
changes: vec![LdapModify {
operation: LdapModifyType::Replace,
modification: ldap3_proto::LdapPartialAttribute {

View File

@@ -112,7 +112,8 @@ pub(crate) async fn do_password_modification<Handler: BackendHandler>(
.map_err(|e| LdapError {
code: LdapResultCode::OperationsError,
message: format!(
"Internal error while requesting user's groups: {e:#?}"
"Internal error while requesting user's groups: {:#?}",
e
),
})?
.iter()
@@ -130,7 +131,7 @@ pub(crate) async fn do_password_modification<Handler: BackendHandler>(
{
Err(LdapError {
code: LdapResultCode::Other,
message: format!("Error while changing the password: {e:#?}"),
message: format!("Error while changing the password: {:#?}", e),
})
} else {
Ok(vec![make_extended_response(
@@ -141,7 +142,7 @@ pub(crate) async fn do_password_modification<Handler: BackendHandler>(
}
Err(e) => Err(LdapError {
code: LdapResultCode::InvalidDNSyntax,
message: format!("Invalid username: {e}"),
message: format!("Invalid username: {}", e),
}),
}
}
@@ -263,7 +264,6 @@ pub mod tests {
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
attributes: Vec::new(),
modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
});
Ok(set)
});
@@ -521,7 +521,6 @@ pub mod tests {
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
attributes: Vec::new(),
modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
});
mock.expect_get_user_groups()
.with(eq(UserId::new("bob")))

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,6 @@ edition.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
[features]
test = []

View File

@@ -7,7 +7,6 @@ edition.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
[features]
test = []

View File

@@ -91,7 +91,7 @@ pub mod tests {
handler
.create_user(CreateUserRequest {
user_id: UserId::new(name),
email: format!("{name}@bob.bob").into(),
email: format!("{}@bob.bob", name).into(),
display_name: Some("display ".to_string() + name),
attributes: vec![
DomainAttribute {

View File

@@ -164,7 +164,7 @@ impl GroupBackendHandler for SqlBackendHandler {
.one(&self.sql_pool)
.await?
.map(Into::<GroupDetails>::into)
.ok_or_else(|| DomainError::EntityNotFound(format!("{group_id:?}")))?;
.ok_or_else(|| DomainError::EntityNotFound(format!("{:?}", group_id)))?;
let attributes = model::GroupAttributes::find()
.filter(model::GroupAttributesColumn::GroupId.eq(group_details.group_id))
.order_by_asc(model::GroupAttributesColumn::AttributeName)
@@ -206,7 +206,6 @@ impl GroupBackendHandler for SqlBackendHandler {
lowercase_display_name: Set(lower_display_name),
creation_date: Set(now),
uuid: Set(uuid),
modified_date: Set(now),
..Default::default()
};
Ok(self
@@ -253,7 +252,8 @@ impl GroupBackendHandler for SqlBackendHandler {
.await?;
if res.rows_affected == 0 {
return Err(DomainError::EntityNotFound(format!(
"No such group: '{group_id:?}'"
"No such group: '{:?}'",
group_id
)));
}
Ok(())
@@ -269,12 +269,10 @@ impl SqlBackendHandler {
.display_name
.as_ref()
.map(|s| s.as_str().to_lowercase());
let now = chrono::Utc::now().naive_utc();
let update_group = model::groups::ActiveModel {
group_id: Set(request.group_id),
display_name: request.display_name.map(Set).unwrap_or_default(),
lowercase_display_name: lower_display_name.map(Set).unwrap_or_default(),
modified_date: Set(now),
..Default::default()
};
update_group.update(transaction).await?;
@@ -308,7 +306,8 @@ impl SqlBackendHandler {
remove_group_attributes.push(attribute);
} else {
return Err(DomainError::InternalError(format!(
"Group attribute name {attribute} doesn't exist in the schema, yet was attempted to be removed from the database"
"Group attribute name {} doesn't exist in the schema, yet was attempted to be removed from the database",
attribute
)));
}
}

View File

@@ -27,8 +27,6 @@ pub enum Users {
TotpSecret,
MfaType,
Uuid,
ModifiedDate,
PasswordModifiedDate,
}
#[derive(DeriveIden, PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Copy)]
@@ -39,7 +37,6 @@ pub(crate) enum Groups {
LowercaseDisplayName,
CreationDate,
Uuid,
ModifiedDate,
}
#[derive(DeriveIden, Clone, Copy)]
@@ -1115,53 +1112,6 @@ async fn migrate_to_v10(transaction: DatabaseTransaction) -> Result<DatabaseTran
Ok(transaction)
}
async fn migrate_to_v11(transaction: DatabaseTransaction) -> Result<DatabaseTransaction, DbErr> {
let builder = transaction.get_database_backend();
// Add modified_date to users table
transaction
.execute(
builder.build(
Table::alter().table(Users::Table).add_column(
ColumnDef::new(Users::ModifiedDate)
.date_time()
.not_null()
.default(chrono::Utc::now().naive_utc()),
),
),
)
.await?;
// Add password_modified_date to users table
transaction
.execute(
builder.build(
Table::alter().table(Users::Table).add_column(
ColumnDef::new(Users::PasswordModifiedDate)
.date_time()
.not_null()
.default(chrono::Utc::now().naive_utc()),
),
),
)
.await?;
// Add modified_date to groups table
transaction
.execute(
builder.build(
Table::alter().table(Groups::Table).add_column(
ColumnDef::new(Groups::ModifiedDate)
.date_time()
.not_null()
.default(chrono::Utc::now().naive_utc()),
),
),
)
.await?;
Ok(transaction)
}
// This is needed to make an array of async functions.
macro_rules! to_sync {
($l:ident) => {
@@ -1192,7 +1142,6 @@ pub(crate) async fn migrate_from_version(
to_sync!(migrate_to_v8),
to_sync!(migrate_to_v9),
to_sync!(migrate_to_v10),
to_sync!(migrate_to_v11),
];
assert_eq!(migrations.len(), (LAST_SCHEMA_VERSION.0 - 1) as usize);
for migration in 2..=last_version.0 {

View File

@@ -197,12 +197,9 @@ impl OpaqueHandler for SqlOpaqueHandler {
let password_file =
opaque::server::registration::get_password_file(request.registration_upload);
// Set the user password to the new password.
let now = chrono::Utc::now().naive_utc();
let user_update = model::users::ActiveModel {
user_id: ActiveValue::Set(username.clone()),
password_hash: ActiveValue::Set(Some(password_file.serialize())),
password_modified_date: ActiveValue::Set(now),
modified_date: ActiveValue::Set(now),
..Default::default()
};
user_update.update(&self.sql_pool).await?;

View File

@@ -9,7 +9,7 @@ pub type DbConnection = sea_orm::DatabaseConnection;
#[derive(Copy, PartialEq, Eq, Debug, Clone, PartialOrd, Ord, DeriveValueType)]
pub struct SchemaVersion(pub i16);
pub const LAST_SCHEMA_VERSION: SchemaVersion = SchemaVersion(11);
pub const LAST_SCHEMA_VERSION: SchemaVersion = SchemaVersion(10);
#[derive(Copy, PartialEq, Eq, Debug, Clone, PartialOrd, Ord)]
pub struct PrivateKeyHash(pub [u8; 32]);

View File

@@ -2,11 +2,7 @@ use crate::sql_backend_handler::SqlBackendHandler;
use async_trait::async_trait;
use lldap_domain::{
requests::{CreateUserRequest, UpdateUserRequest},
schema::Schema,
types::{
Attribute, AttributeName, GroupDetails, GroupId, Serialized, User, UserAndGroups, UserId,
Uuid,
},
types::{AttributeName, GroupDetails, GroupId, Serialized, User, UserAndGroups, UserId, Uuid},
};
use lldap_domain_handlers::handler::{
ReadSchemaBackendHandler, UserBackendHandler, UserListerBackendHandler, UserRequestFilter,
@@ -189,12 +185,18 @@ impl UserListerBackendHandler for SqlBackendHandler {
}
impl SqlBackendHandler {
fn compute_user_attribute_changes(
user_id: &UserId,
insert_attributes: Vec<Attribute>,
delete_attributes: Vec<AttributeName>,
schema: &Schema,
) -> Result<(Vec<model::user_attributes::ActiveModel>, Vec<AttributeName>)> {
async fn update_user_with_transaction(
transaction: &DatabaseTransaction,
request: UpdateUserRequest,
) -> Result<()> {
let lower_email = request.email.as_ref().map(|s| s.as_str().to_lowercase());
let update_user = model::users::ActiveModel {
user_id: ActiveValue::Set(request.user_id.clone()),
email: request.email.map(ActiveValue::Set).unwrap_or_default(),
lowercase_email: lower_email.map(ActiveValue::Set).unwrap_or_default(),
display_name: to_value(&request.display_name),
..Default::default()
};
let mut update_user_attributes = Vec::new();
let mut remove_user_attributes = Vec::new();
let mut process_serialized =
@@ -204,20 +206,24 @@ impl SqlBackendHandler {
}
ActiveValue::Set(_) => {
update_user_attributes.push(model::user_attributes::ActiveModel {
user_id: Set(user_id.clone()),
user_id: Set(request.user_id.clone()),
attribute_name: Set(attribute_name),
value,
})
}
_ => unreachable!(),
};
for attribute in insert_attributes {
let schema = Self::get_schema_with_transaction(transaction).await?;
for attribute in request.insert_attributes {
if schema
.user_attributes
.get_attribute_type(&attribute.name)
.is_some()
{
process_serialized(ActiveValue::Set(attribute.value.into()), attribute.name);
process_serialized(
ActiveValue::Set(attribute.value.into()),
attribute.name.clone(),
);
} else {
return Err(DomainError::InternalError(format!(
"User attribute name {} doesn't exist in the schema, yet was attempted to be inserted in the database",
@@ -225,7 +231,7 @@ impl SqlBackendHandler {
)));
}
}
for attribute in delete_attributes {
for attribute in request.delete_attributes {
if schema
.user_attributes
.get_attribute_type(&attribute)
@@ -234,35 +240,11 @@ impl SqlBackendHandler {
remove_user_attributes.push(attribute);
} else {
return Err(DomainError::InternalError(format!(
"User attribute name {attribute} doesn't exist in the schema, yet was attempted to be removed from the database"
"User attribute name {} doesn't exist in the schema, yet was attempted to be removed from the database",
attribute
)));
}
}
Ok((update_user_attributes, remove_user_attributes))
}
async fn update_user_with_transaction(
transaction: &DatabaseTransaction,
request: UpdateUserRequest,
) -> Result<()> {
let schema = Self::get_schema_with_transaction(transaction).await?;
let (update_user_attributes, remove_user_attributes) =
Self::compute_user_attribute_changes(
&request.user_id,
request.insert_attributes,
request.delete_attributes,
&schema,
)?;
let lower_email = request.email.as_ref().map(|s| s.as_str().to_lowercase());
let now = chrono::Utc::now().naive_utc();
let update_user = model::users::ActiveModel {
user_id: ActiveValue::Set(request.user_id.clone()),
email: request.email.map(ActiveValue::Set).unwrap_or_default(),
lowercase_email: lower_email.map(ActiveValue::Set).unwrap_or_default(),
display_name: to_value(&request.display_name),
modified_date: ActiveValue::Set(now),
..Default::default()
};
update_user.update(transaction).await?;
if !remove_user_attributes.is_empty() {
model::UserAttributes::delete_many()
@@ -344,8 +326,6 @@ impl UserBackendHandler for SqlBackendHandler {
display_name: to_value(&request.display_name),
creation_date: ActiveValue::Set(now),
uuid: ActiveValue::Set(uuid),
modified_date: ActiveValue::Set(now),
password_modified_date: ActiveValue::Set(now),
..Default::default()
};
let mut new_user_attributes = Vec::new();
@@ -404,7 +384,8 @@ impl UserBackendHandler for SqlBackendHandler {
.await?;
if res.rows_affected == 0 {
return Err(DomainError::EntityNotFound(format!(
"No such user: '{user_id}'"
"No such user: '{}'",
user_id
)));
}
Ok(())
@@ -412,70 +393,25 @@ impl UserBackendHandler for SqlBackendHandler {
#[instrument(skip_all, level = "debug", err, fields(user_id = ?user_id.as_str(), group_id))]
async fn add_user_to_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()> {
let user_id = user_id.clone();
self.sql_pool
.transaction::<_, _, sea_orm::DbErr>(|transaction| {
Box::pin(async move {
let new_membership = model::memberships::ActiveModel {
user_id: ActiveValue::Set(user_id),
group_id: ActiveValue::Set(group_id),
};
new_membership.insert(transaction).await?;
// Update group modification time
let now = chrono::Utc::now().naive_utc();
let update_group = model::groups::ActiveModel {
group_id: Set(group_id),
modified_date: Set(now),
..Default::default()
};
update_group.update(transaction).await?;
Ok(())
})
})
.await?;
let new_membership = model::memberships::ActiveModel {
user_id: ActiveValue::Set(user_id.clone()),
group_id: ActiveValue::Set(group_id),
};
new_membership.insert(&self.sql_pool).await?;
Ok(())
}
#[instrument(skip_all, level = "debug", err, fields(user_id = ?user_id.as_str(), group_id))]
async fn remove_user_from_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()> {
let user_id = user_id.clone();
self.sql_pool
.transaction::<_, _, sea_orm::DbErr>(|transaction| {
Box::pin(async move {
let res = model::Membership::delete_by_id((user_id.clone(), group_id))
.exec(transaction)
.await?;
if res.rows_affected == 0 {
return Err(sea_orm::DbErr::Custom(format!(
"No such membership: '{user_id}' -> {group_id:?}"
)));
}
// Update group modification time
let now = chrono::Utc::now().naive_utc();
let update_group = model::groups::ActiveModel {
group_id: Set(group_id),
modified_date: Set(now),
..Default::default()
};
update_group.update(transaction).await?;
Ok(())
})
})
.await
.map_err(|e| match e {
sea_orm::TransactionError::Connection(sea_orm::DbErr::Custom(msg)) => {
DomainError::EntityNotFound(msg)
}
sea_orm::TransactionError::Transaction(sea_orm::DbErr::Custom(msg)) => {
DomainError::EntityNotFound(msg)
}
sea_orm::TransactionError::Connection(e) => DomainError::DatabaseError(e),
sea_orm::TransactionError::Transaction(e) => DomainError::DatabaseError(e),
})?;
let res = model::Membership::delete_by_id((user_id.clone(), group_id))
.exec(&self.sql_pool)
.await?;
if res.rows_affected == 0 {
return Err(DomainError::EntityNotFound(format!(
"No such membership: '{}' -> {:?}",
user_id, group_id
)));
}
Ok(())
}
}

View File

@@ -6,7 +6,6 @@ edition.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
[dependencies]
async-trait = "0.1"

View File

@@ -7,4 +7,3 @@ edition.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true

View File

@@ -3,7 +3,6 @@
- [With Docker](#with-docker)
- [With Podman](#with-podman)
- [With Kubernetes](#with-kubernetes)
- [TrueNAS SCALE](#truenas-scale)
- [From a package repository](#from-a-package-repository)
- [With FreeBSD](#with-freebsd)
- [From source](#from-source)
@@ -69,7 +68,7 @@ services:
- LLDAP_JWT_SECRET=REPLACE_WITH_RANDOM
- LLDAP_KEY_SEED=REPLACE_WITH_RANDOM
- LLDAP_LDAP_BASE_DN=dc=example,dc=com
- LLDAP_LDAP_USER_PASS=CHANGE_ME # If the password contains '$', escape it (e.g. Pas$$word sets Pas$word)
- LLDAP_LDAP_USER_PASS=adminPas$word
# If using LDAPS, set enabled true and configure cert and key path
# - LLDAP_LDAPS_OPTIONS__ENABLED=true
# - LLDAP_LDAPS_OPTIONS__CERT_FILE=/path/to/certfile.crt
@@ -94,7 +93,7 @@ front-end.
### With Podman
LLDAP works well with rootless Podman either through command line deployment
or using [quadlets](../example_configs/podman-quadlets/). The example quadlets
or using [quadlets](example_configs/podman-quadlets/). The example quadlets
include configuration with postgresql and file based secrets, but have comments
for several other deployment strategies.
@@ -103,30 +102,9 @@ for several other deployment strategies.
See https://github.com/Evantage-WS/lldap-kubernetes for a LLDAP deployment for Kubernetes
You can bootstrap your lldap instance (users, groups)
using [bootstrap.sh](../example_configs/bootstrap/bootstrap.md#kubernetes-job).
using [bootstrap.sh](example_configs/bootstrap/bootstrap.md#kubernetes-job).
It can be run by Argo CD for managing users in git-opt way, or as a one-shot job.
### TrueNAS SCALE
LLDAP can be installed on **TrueNAS SCALE** using the built-in Apps catalog, allowing users to deploy and manage LLDAP directly from the TrueNAS web interface without manually maintaining containers.
To install:
1. Open the TrueNAS web interface.
2. Navigate to **Apps → Discover Apps**.
3. Search for **LLDAP** and click **Install**.
4. Provide the required configuration values such as:
- Base DN
- Admin credentials
- LDAP / LDAPS ports
- Persistent storage dataset
TrueNAS supports selecting certificates for LDAPS and configuring a public web URL. When LDAPS is enabled, it is recommended to disable the unencrypted LDAP port to ensure secure communication.
A full, step-by-step TrueNAS-specific guide (including recommended ports, certificate configuration, and common integrations) is available here:
👉 [example_configs/truenas-install.md](https://github.com/lldap/lldap/blob/main/example_configs/truenas-install.md)
### From a package repository
**Do not open issues in this repository for problems with third-party
@@ -136,7 +114,7 @@ Depending on the distribution you use, it might be possible to install LLDAP
from a package repository, officially supported by the distribution or
community contributed.
Each package offers a [systemd service](https://wiki.archlinux.org/title/systemd#Using_units) `lldap.service` or [rc.d_lldap](../example_configs/freebsd/rc.d_lldap) `rc.d/lldap` to (auto-)start and stop lldap.<br>
Each package offers a [systemd service](https://wiki.archlinux.org/title/systemd#Using_units) `lldap.service` or [rc.d_lldap](example_configs/freebsd/rc.d_lldap) `rc.d/lldap` to (auto-)start and stop lldap.<br>
When using the distributed packages, the default login is `admin/password`. You can change that from the web UI after starting the service.
<details>
@@ -407,7 +385,7 @@ arguments to `cargo run`. Have a look at the docker template:
`lldap_config.docker_template.toml`.
You can also install it as a systemd service, see
[lldap.service](../example_configs/lldap.service).
[lldap.service](example_configs/lldap.service).
### Cross-compilation

View File

@@ -1,71 +0,0 @@
# Nix Development Environment
LLDAP provides a Nix flake that sets up a complete development environment with all necessary tools and dependencies.
## Requirements
- [Nix](https://nixos.org/download.html) with flakes enabled
- (Optional) [direnv](https://direnv.net/) for automatic environment activation
## Usage
```bash
# Clone the repository
git clone https://github.com/lldap/lldap.git
cd lldap
# Enter the development environment
nix develop
# Build the workspace
cargo build --workspace
# Run tests
cargo test --workspace
# Check formatting and linting
cargo fmt --check --all
cargo clippy --tests --workspace -- -D warnings
# Build frontend
./app/build.sh
# Export GraphQL schema (if needed)
./export_schema.sh
# Start development server
cargo run -- run --config-file lldap_config.docker_template.toml
```
## Building with Nix
You can also build LLDAP directly using Nix:
```bash
# Build the default package (server)
nix build
# Build and run
nix run
```
## Development Shells
The flake provides two development shells:
- `default` - Full development environment
- `ci` - Minimal environment similar to CI
```bash
# Use the CI-like environment
nix develop .#ci
```
## Automatic Environment Activation (Optional)
For automatic environment activation when entering the project directory:
1. Install direnv: `nix profile install nixpkgs#direnv`
2. Set up direnv shell hook in your shell configuration
3. Navigate to the project directory and allow direnv: `direnv allow`
4. The environment will automatically activate when entering the directory

View File

@@ -55,8 +55,8 @@ Then you'll receive a JSON response with:
```
{
"token": "Yh6RJV...",
"refreshToken": "dww5jwU...",
"token": "eYbat...",
"refreshToken": "3bCka...",
}
```

View File

@@ -4,15 +4,13 @@ Some specific clients have been tested to work and come with sample
configuration files:
- [Airsonic Advanced](airsonic-advanced.md)
- [Apache HTTP Server](apache.md)
- [Apache Guacamole](apacheguacamole.md)
- [Apereo CAS Server](apereo_cas_server.md)
- [Authelia](authelia.md)
- [Authelia](authelia_config.yml)
- [Authentik](authentik.md)
- [Bookstack](bookstack.env.example)
- [Calibre-Web](calibre_web.md)
- [Carpal](carpal.md)
- [Continuwuity](continuwuity.md)
- [Dell iDRAC](dell_idrac.md)
- [Dex](dex_config.yml)
- [Dokuwiki](dokuwiki.md)
@@ -21,7 +19,6 @@ configuration files:
- [Ejabberd](ejabberd.md)
- [Emby](emby.md)
- [Ergo IRCd](ergo.md)
- [Gerrit](gerrit.md)
- [Gitea](gitea.md)
- [GitLab](gitlab.md)
- [Grafana](grafana_ldap_config.toml)
@@ -54,7 +51,6 @@ configuration files:
- [Peertube](peertube.md)
- [Penpot](penpot.md)
- [pgAdmin](pgadmin.md)
- [Pocket-ID](pocket-id.md)
- [Portainer](portainer.md)
- [PowerDNS Admin](powerdns_admin.md)
- [Prosody](prosody.md)
@@ -63,7 +59,6 @@ configuration files:
- [Radicale](radicale.md)
- [Rancher](rancher.md)
- [Seafile](seafile.md)
- [Semaphore](semaphore.md)
- [Shaarli](shaarli.md)
- [Snipe-IT](snipe-it.md)
- [SonarQube](sonarqube.md)

View File

@@ -1,65 +0,0 @@
# Configuration for Apache
This example snippet provides space under `/webdav/<username>/` if they log in as the user in question.
## Apache LDAP Configuration
```
# The User/Group specified in httpd.conf needs to have write permissions
# on the directory where the DavLockDB is placed and on any directory where
# "Dav On" is specified.
DavLockDB "/var/local/apache2/DavLock"
Alias /webdav "/var/local/apache2/data"
<Directory "/var/local/apache2/data">
AllowOverride None
Require all denied
DirectoryIndex disabled
</Directory>
<DirectoryMatch "^/var/local/apache2/data/(?<user>[^/]+)">
AuthType Basic
AuthName "LDAP Credentials"
AuthBasicProvider ldap
AuthLDAPURL ldap://lldap:3890/ou=people,dc=example,dc=com?uid?sub?(objectClass=person)
AuthLDAPBindDN uid=integration,ou=people,dc=example,dc=com
AuthLDAPBindPassword [redacted]
<RequireAll>
Require ldap-user "%{env:MATCH_USER}"
Require ldap-group cn=WebDAV,ou=groups,dc=example,dc=com
</RequireAll>
Dav On
Options +Indexes
</DirectoryMatch>
```
### Notes
* Make sure you create the `data` directory, and the subdirectories for your users.
* `integration` was an LDAP user I added with strict readonly.
* The `WebDAV` group was something I added and put relevant users into, more as a test of functionality than out of any need.
* I left the comment from the Apache DAV config in because it's not kidding around and it won't be obvious what's going wrong from the Apache logs if you miss that.
## Apache Orchestration
The stock Apache server with that stanza added to the bottom of the stock config and shared into the container.
```
webdav:
image: httpd:2.4.66-trixie
restart: always
volumes:
- /opt/webdav:/var/local/apache2
- ./httpd.conf:/usr/local/apache2/conf/httpd.conf
labels:
- "traefik.enable=true"
- "traefik.http.routers.webdav.entrypoints=websecure"
- "traefik.http.routers.webdav.rule=Host(`redacted`) && PathPrefix(`/webdav`)"
- "traefik.http.routers.webdav.tls.certresolver=myresolver"
- "traefik.http.routers.webdav.service=webdav-service"
- "traefik.http.services.webdav-service.loadbalancer.server.port=80"
```

View File

@@ -1,39 +0,0 @@
# Configuration for Authelia
## Authelia LDAP configuration
For all configuration options see the [Authelia LDAP Documentation](https://www.authelia.com/configuration/first-factor/ldap/).
The following example configuration uses the LLDAP implementation template, the default values are documented in the
[Authelia LLDAP Integration Guide](https://www.authelia.com/integration/ldap/lldap/).
Users will be able to sign in using their username or email address.
```yaml
authentication_backend:
# How often authelia should check if there is a user update in LDAP
refresh_interval: '1m'
ldap:
implementation: 'lldap'
# Format is [<scheme>://]<hostname>[:<port>]
# ldap port for LLDAP is 3890 and ldaps 6360
address: 'ldap://lldap:3890'
# Set base dn that you configured in LLDAP
base_dn: 'DC=example,DC=com'
# The username and password of the bind user.
# "bind_user" should be the username you created for authentication with the "lldap_strict_readonly" permission. It is not recommended to use an actual admin account here.
# If you are configuring Authelia to change user passwords, then the account used here needs the "lldap_password_manager" permission instead.
user: 'UID=bind_user,OU=people,DC=example,DC=com'
# Password can also be set using a secret: https://www.authelia.com/configuration/methods/secrets/.
password: 'REPLACE_ME'
# Optional: Setup TLS if you've enabled LDAPS
# tls:
# skip_verify: false
# minimum_version: TLS1.2
# Disable the authelia password change and reset functionality if the "bind_user" does not have the "lldap_password_manager" permission.
password_reset:
disable: false
password_change:
disable: false
```

View File

@@ -0,0 +1,35 @@
###############################################################
# Authelia configuration #
###############################################################
# This is just the LDAP part of the Authelia configuration!
# See Authelia docs at https://www.authelia.com/configuration/first-factor/ldap/ for more info
authentication_backend:
# Password reset through authelia works normally.
password_reset:
disable: false
# How often authelia should check if there is a user update in LDAP
refresh_interval: 1m
ldap:
implementation: lldap
# Pattern is ldap://HOSTNAME-OR-IP:PORT
# Normal ldap port is 389, standard in LLDAP is 3890
address: ldap://lldap:3890
# Set base dn that you configured in LLDAP
base_dn: dc=example,dc=com
# The username and password of the bind user.
# "bind_user" should be the username you created for authentication with the "lldap_strict_readonly" permission. It is not recommended to use an actual admin account here.
# If you are configuring Authelia to change user passwords, then the account used here needs the "lldap_password_manager" permission instead.
user: uid=bind_user,ou=people,dc=example,dc=com
additional_users_dn: ou=people
# Password can also be set using a secret: https://www.authelia.com/configuration/methods/secrets/
password: "REPLACE_ME"
# Optional: Setup TLS if you've enabled LDAPS
# tls:
# skip_verify: false
# minimum_version: TLS1.2
# Optional: To allow sign in with BOTH username and email, you can change the users_filter to this
# users_filter: "(&(|({username_attribute}={input})({mail_attribute}={input}))(objectClass=person))"

View File

@@ -64,7 +64,7 @@ dc=example,dc=com
# Additional settings
## Parent Group
## Group
```
---------
```
@@ -99,16 +99,6 @@ ou=groups
member
```
## User membership attribute
```
distinguishedName
```
## Looking using user attribute
```
false
```
## Object uniqueness field
```
uid

View File

@@ -36,9 +36,6 @@ The script can:
- `GROUP_SCHEMAS_DIR` (default value: `/bootstrap/group-schemas`) - directory where the group schema JSON configs could be found
- `LLDAP_SET_PASSWORD_PATH` - path to the `lldap_set_password` utility (default value: `/app/lldap_set_password`)
- `DO_CLEANUP` (default value: `false`) - delete groups and users not specified in config files, also remove users from groups that they do not belong to
- `DO_CLEANUP_USERS` (default value: `false`) - same as `DO_CLEANUP` but only for users.
- `DO_CLEANUP_GROUP_MEMBERSHIP` (default value: `false`) - same as `DO_CLEANUP` but only for group membership.
- `DO_CLEANUP_GROUPS` (default value: `false`) - same as `DO_CLEANUP` but only for groups.
## Config files
@@ -72,7 +69,6 @@ Fields description:
* `id`: it's just username (**MANDATORY**)
* `email`: self-explanatory (**MANDATORY**)
* `password`: would be used to set the password using `lldap_set_password` utility
* `password_file`: path to a file containing the password otherwise same as above
* `displayName`: self-explanatory
* `firstName`: self-explanatory
* `lastName`: self-explanatory
@@ -131,7 +127,7 @@ Fields description:
"isVisible": true
},
{
"name": "mail-alias",
"name": "mail_alias",
"attributeType": "STRING",
"isEditable": false,
"isList": true,
@@ -247,14 +243,14 @@ spec:
restartPolicy: OnFailure
containers:
- name: lldap-bootstrap
image: lldap/lldap:latest
image: lldap/lldap:v0.5.0
command:
- /app/bootstrap.sh
- /bootstrap/bootstrap.sh
env:
- name: LLDAP_URL
value: "http://lldap:17170"
value: "http://lldap:8080"
- name: LLDAP_ADMIN_USERNAME
valueFrom: { secretKeyRef: { name: lldap-admin-user, key: username } }
@@ -266,6 +262,11 @@ spec:
value: "true"
volumeMounts:
- name: bootstrap
mountPath: /bootstrap/bootstrap.sh
readOnly: true
subPath: bootstrap.sh
- name: user-configs
mountPath: /bootstrap/user-configs
readOnly: true
@@ -275,9 +276,27 @@ spec:
readOnly: true
volumes:
- name: bootstrap
configMap:
name: bootstrap
defaultMode: 0555
items:
- key: bootstrap.sh
path: bootstrap.sh
- name: user-configs
projected:
sources:
- secret:
name: lldap-admin-user
items:
- key: user-config.json
path: admin-config.json
- secret:
name: lldap-password-manager-user
items:
- key: user-config.json
path: password-manager-config.json
- secret:
name: lldap-bootstrap-configs
items:

View File

@@ -1,15 +0,0 @@
# Configuration for Continuwuity
This example is with environment vars from my docker-compose.yml, this also works just as well with a [config file](https://continuwuity.org/reference/config). `uid=query,ou=people,dc=example,dc=com` is a read-only user and you need to put their password into `/etc/bind_password_file`. Users need to be in the group `matrix` to log in and users in the group `matrix-admin` will be an admin.
```
CONTINUWUITY_LDAP__ENABLE: 'true'
CONTINUWUITY_LDAP__LDAP_ONLY: 'true'
CONTINUWUITY_LDAP__URI: 'ldap://lldap.example.com:3890'
CONTINUWUITY_LDAP__BASE_DN: 'ou=people,dc=example,dc=com'
CONTINUWUITY_LDAP__BIND_DN: 'uid=query,ou=people,dc=example,dc=com'
CONTINUWUITY_LDAP__BIND_PASSWORD_FILE: '/etc/bind_password_file'
CONTINUWUITY_LDAP__FILTER: '(memberOf=matrix)'
CONTINUWUITY_LDAP__UID_ATTRIBUTE: 'uid'
CONTINUWUITY_LDAP__ADMIN_FILTER: '(memberOf=matrix-admin)'
```

View File

@@ -1,18 +0,0 @@
# Configuration for Gerrit
Edit `gerrit.config`:
```ini
[auth]
type = ldap
[ldap]
server = ldap://lldap:3890
supportAnonymous = false
username = uid=gerritadmin,ou=people,dc=example.com,dc=com
accountBase = ou=people,dc=example.com,dc=com
accountPattern = (uid=${username})
accountFullName = cn
accountEmailAddress = mail
```
The `supportAnonymous = false` must be set.

View File

@@ -1,46 +0,0 @@
# Gogs LDAP configuration
Gogs can make use of LDAP and therefore lldap.
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`:
```yaml
$ cat /srv/git/gogs/conf/auth.d/ldap_bind_dn.conf
id = 101
type = ldap_bind_dn
name = LDAP BindDN
is_activated = true
is_default = true
[config]
host = ldap.example.com
port = 6360
# 0 - Unencrypted, 1 - LDAPS, 2 - StartTLS
security_protocol = 1
# You either need to install the LDAPS certificate into your trust store -
# Or skip verification altogether - for a restricted container deployment a sane default.
skip_verify = true
bind_dn = uid=<binduser>,ou=people,dc=example,dc=com
bind_password = `yourPasswordInBackticks`
user_base = dc=example,dc=com
attribute_username = uid
attribute_name = givenName
attribute_surname = sn
attribute_mail = mail
attributes_in_bind = false
# restricts on the `user_base`.
filter = (&(objectClass=person)(uid=%s))
# The initial administrator has to enable admin privileges.
# This is only possible for users who were logged in once.
# This renders the following filter obsolete; Though its response is accepted by Gogs.
admin_filter = (memberOf=cn=<yourAdminGroup>,ou=groups,dc=example,dc=com)
```
The `binduser` shall be a member of `lldap_strict_readonly`.
The group `yourAdminGroup` should be adapted to your requirement - Otherwise the entire line can be omitted.
The diamond brackets are for readability and are not required.
## Tested on Gogs
v0.14+dev via podman 4.3.1

View File

@@ -41,14 +41,7 @@ name = "displayName"
surname = "sn"
username = "uid"
# If you want to map your ldap groups to grafana's groups, configure the group query:
# https://grafana.com/docs/grafana/latest/setup-grafana/configure-access/configure-authentication/ldap/#posix-schema
# group_search_filter = "(&(objectClass=groupOfUniqueNames)(uniqueMember=%s))"
# group_search_base_dns = ["ou=groups,dc=example,dc=com"]
# group_search_filter_user_attribute = "uid"
#
# Then configure the groups:
# https://grafana.com/docs/grafana/latest/setup-grafana/configure-access/configure-authentication/ldap/#group-mappings
# If you want to map your ldap groups to grafana's groups, see: https://grafana.com/docs/grafana/latest/auth/ldap/#group-mappings
# As a quick example, here is how you would map lldap's admin group to grafana's admin
# [[servers.group_mappings]]
# group_dn = "cn=lldap_admin,ou=groups,dc=example,dc=org"

View File

@@ -64,7 +64,7 @@ if [[ ! -z "$2" ]] && ! jq -e '.groups|map(.displayName)|index("'"$2"'")' <<< $U
exit 1
fi
DISPLAY_NAME=$(jq -r '.displayName // .id' <<< $USER_JSON)
DISPLAY_NAME=$(jq -r .displayName <<< $USER_JSON)
IS_ADMIN=false
if [[ ! -z "$3" ]] && jq -e '.groups|map(.displayName)|index("'"$3"'")' <<< "$USER_JSON" > /dev/null 2>&1; then
@@ -88,4 +88,4 @@ if [[ "$IS_LOCAL" = true ]]; then
echo "local_only = true"
else
echo "local_only = false"
fi
fi

View File

@@ -58,9 +58,9 @@ services:
- LDAP_SEARCH_BASE=ou=people,dc=example,dc=com
- LDAP_BIND_DN=uid=admin,ou=people,dc=example,dc=com
- LDAP_BIND_PW=adminpassword
- LDAP_QUERY_FILTER_USER=(&(objectClass=inetOrgPerson)(mail=%s))
- LDAP_QUERY_FILTER_USER=(&(objectClass=inetOrgPerson)(|(uid=%u)(mail=%u)))
- LDAP_QUERY_FILTER_GROUP=(&(objectClass=groupOfUniqueNames)(uid=%s))
- LDAP_QUERY_FILTER_ALIAS=(&(objectClass=inetOrgPerson)(mail=%s))
- LDAP_QUERY_FILTER_ALIAS=(&(objectClass=inetOrgPerson)(|(uid=%u)(mail=%u)))
- LDAP_QUERY_FILTER_DOMAIN=(mail=*@%s)
# <<< Postfix LDAP Integration
# >>> Dovecot LDAP Integration
@@ -78,8 +78,7 @@ services:
container_name: roundcubemail
restart: always
volumes:
- roundcube_config:/var/roundcube/config
- roundcube_plugins:/var/www/html/plugins
- roundcube_data:/var/www/html
ports:
- "9002:80"
environment:
@@ -87,15 +86,12 @@ services:
- ROUNDCUBEMAIL_SKIN=elastic
- ROUNDCUBEMAIL_DEFAULT_HOST=mailserver # IMAP
- ROUNDCUBEMAIL_SMTP_SERVER=mailserver # SMTP
- ROUNDCUBEMAIL_COMPOSER_PLUGINS=roundcube/carddav
- ROUNDCUBEMAIL_PLUGINS=carddav
volumes:
mailserver-data:
mailserver-config:
mailserver-state:
lldap_data:
roundcube_config:
roundcube_plugins:
roundcube_data:
```

View File

@@ -1,31 +0,0 @@
# Open-WebUI LDAP configuration
For the GUI settings (recommended) go to:
`Admin Panel > General`.
There you find the LDAP config.
For the initial activation, restart OpenWebUI to load the LDAP module.
The following configurations have to be provided.
The user `binduser` has to be member of `lldap_strict_readonly`.
| environment variable | GUI variable | example value | elaboration |
|----------------------|--------------|---------------|-------------|
| `ENABLE_LDAP` | LDAP | `true` | Toggle |
| `LDAP_SERVER_LABEL` | Label | `any` (lldap) | name |
| `LDAP_SERVER_HOST` | Host | `ldap.example.org` | IP/domain without scheme or port |
| `LDAP_SERVER_PORT` | Port | `6360` | When starting Open-WebUI sometimes it only accepts the default LDAP or LDAPS port (only ENV configuration) |
| `LDAP_ATTRIBUTE_FOR_MAIL` | Attribute for Mail | `mail` | default |
| `LDAP_ATTRIBUTE_FOR_USERNAME` | Attribute for Username | `uid` | default |
| `LDAP_APP_DN` | Application DN | `uid=binduser,ou=people,dc=example,dc=org` | Hovering shows: Bind user-dn |
| `LDAP_APP_PASSWORD` | Application DN Password | `<binduser-pw>` | - |
| `LDAP_SEARCH_BASE` | Search Base | `ou=people,dc=example,dc=org` | Who should get access from your instance. |
| `LDAP_SEARCH_FILTER` | Search Filter | `(objectClass=person)` or `(\|(objectClass=person)(memberOf=cn=webui-members,ou=groups,dc=example,dc=org))` | Query for Open WebUI account names. |
| `LDAP_USE_TLS` | TLS | `true` | Should be `true` for LDAPS, `false` for plain LDAP |
| `LDAP_CA_CERT_FILE` | Certificate Path | `/ca-chain.pem` | required when TLS activated |
| `LDAP_VALIDATE_CERT` | Validate Certificate | `true` | Set to `false` for self-signed certificates |
| `LDAP_CIPHERS` | Ciphers | ALL | default |
## Tested on Open WebUI
v0.6.26 via podman 5.4.2

View File

@@ -92,9 +92,6 @@ Enable the following options on the OPNsense configuration page for your LLDAP s
- Synchronize groups: `Checked`
- Automatic user creation: `Checked`
### Constraint Groups
This limits the groups to prevent injection attacks. If you want to enable this feature, you need to add ou=groups,dc=example,dc=com to the Authentication Containers field. Be sure to separate with a semicolon. Otherwise disable this option.
### Create OPNsense Group
Go to `System > Access > Groups` and create a new group with the **same** name as the LLDAP group used to authenticate users for OPNsense.

Some files were not shown because too many files have changed in this diff Show More