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
134 changed files with 4120 additions and 6682 deletions
-46
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
+1 -1
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.
+2 -20
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
View File
@@ -1 +0,0 @@
use flake
+1
View File
@@ -0,0 +1 @@
* @nitnelave
+8 -5
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"
-159
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.**
-26
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
+2 -13
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; \
+5 -19
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; \
+1 -1
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"
+21 -31
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:
@@ -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
+21 -27
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
View File
@@ -29,8 +29,3 @@ recipe.json
lldap_config.toml
cert.pem
key.pem
# Nix
result
result-*
.direnv
-55
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.
+1 -3
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
Generated
+838 -1601
View File
File diff suppressed because it is too large Load Diff
+3 -1
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
+3 -3
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)
+1 -11
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"
+16 -18
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>
}
}
+6 -4
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())}
/>
}
}
+7 -6
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>
+6 -8
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())}
/>
}
}
+7 -6
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>
+8 -16
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
+14 -12
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;
+5 -3
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 {
+3 -2
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()}
/>
}
+5 -3
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>
+1 -1
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,
}
+5 -3
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 {
+3 -14
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>
}
}
+5 -3
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>
+6 -25
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 {
+55 -31
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);
}
}
-1
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;
-1
View File
@@ -7,7 +7,6 @@ edition.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
[dependencies]
tracing = "*"
-2
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"]
-4
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>,
}
-1
View File
@@ -6,7 +6,6 @@ authors.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
[features]
test = []
-1
View File
@@ -6,7 +6,6 @@ authors.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
[features]
test = []
-3
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,
}
}
}
-8
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,
}
}
}
-1
View File
@@ -9,7 +9,6 @@ edition.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
[features]
test = []
+2 -2
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> {
-27
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,
+9 -9
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)]
-1
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
-1
View File
@@ -7,7 +7,6 @@ authors.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
[dependencies]
anyhow = "*"
+1 -1
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::*;
@@ -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::*;
@@ -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,
})
}
@@ -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
@@ -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
}
}
@@ -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,
}
-123
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()
}
}
-539
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);
}
}
-117
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,
}
}
}
-136
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)
}
}
+1 -2
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"]
-2
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;
+24 -368
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()]),
);
}
}
+31 -493
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");
}
}
}
+23 -69
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"
);
}
}
+11 -5
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,
+8 -7
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()
+30 -18
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)
});
+1 -1
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;
+5 -6
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 {
+4 -5
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")))
+775 -256
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -7,7 +7,6 @@ edition.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
[features]
test = []
-1
View File
@@ -7,7 +7,6 @@ edition.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
[features]
test = []
@@ -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 {
@@ -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
)));
}
}
@@ -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 {
@@ -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?;
+1 -1
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]);
@@ -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(())
}
}
-1
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"
-1
View File
@@ -7,4 +7,3 @@ edition.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
+5 -27
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
-71
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
+2 -2
View File
@@ -55,8 +55,8 @@ Then you'll receive a JSON response with:
```
{
"token": "Yh6RJV...",
"refreshToken": "dww5jwU...",
"token": "eYbat...",
"refreshToken": "3bCka...",
}
```
+1 -7
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)
@@ -50,12 +47,10 @@ configuration files:
- [Nexus](nexus.md)
- [OCIS (OwnCloud Infinite Scale)](ocis.md)
- [OneDev](onedev.md)
- [OpenCloud](opencloud.md)
- [Organizr](Organizr.md)
- [Peertube](peertube.md)
- [Penpot](penpot.md)
- [pgAdmin](pgadmin.md)
- [Pocket-ID](pocket-id.md)
- [Portainer](portainer.md)
- [PowerDNS Admin](powerdns_admin.md)
- [Prosody](prosody.md)
@@ -64,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)
-65
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"
```
-39
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
```
+35
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))"
+1 -11
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
+27 -8
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:
-15
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)'
```
-18
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.
-46
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
+1 -8
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"
+2 -2
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
+4 -8
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:
```
-31
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
-55
View File
@@ -1,55 +0,0 @@
# OpenCloud example config
## About OpenCloud
A light-weight file-hosting / webDAV service written in Go and forked from ownCloud Infinite Scale (oCIS).
More information:
* https://opencloud.eu
* https://github.com/opencloud-eu
## LLDAP Configuration
OpenCloud ships an OIDC provider and a built-in LDAP server. It officially supports using a third-party OIDC provider.
This is **not** what this config does. This config leaves the general auth/OIDC infrastructure in place, but replaces the LDAP server from underneath it with LLDAP.
Configuration happens via environment variables. On FreeBSD, these are provided via `/usr/local/etc/opencloud/config.env`; on Linux you can provide them via the Docker configuration.
```dotenv
# Replace with actual IP and Port
OC_LDAP_URI=ldap://<lldap_ip>:3890
# Remove the following if you use LDAPS and your cert is not self-signed
OC_LDAP_INSECURE="true"
# Replace with your bind-user; can be in
OC_LDAP_BIND_DN="cn=<bind_user>,ou=people,dc=example,dc=com"
OC_LDAP_BIND_PASSWORD="<secret>"
OC_LDAP_GROUP_BASE_DN="ou=groups,dc=example,dc=com"
OC_LDAP_GROUP_SCHEMA_ID=entryuuid
OC_LDAP_USER_BASE_DN="ou=people,dc=example,dc=com"
OC_LDAP_USER_SCHEMA_ID=entryuuid
# Only allow users from specific group to login; remove this if everyone's allowed
OC_LDAP_USER_FILTER='(&(objectClass=person)(memberOf=cn=<opencloud_users>,ou=groups,dc=example,dc=com))'
# Other options have not been tested
OC_LDAP_DISABLE_USER_MECHANISM="none"
# If you bind-user is in lldap_strict_readonly set to false (this hides "forgot password"-buttons)
OC_LDAP_SERVER_WRITE_ENABLED="false"
# If your bind-user can change passwords:
OC_LDAP_SERVER_WRITE_ENABLED="true" # Not tested, yet!
# Don't start built-in LDAP, because it's replaced by LLDAP
OC_EXCLUDE_RUN_SERVICES="idm"
```
There is currently no (documented) way to give an LDAP user (or group) admin rights in OpenCloud.
See also [the official LDAP documentation](https://github.com/opencloud-eu/opencloud/blob/main/devtools/deployments/opencloud_full/ldap.yml).

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