You've already forked lldap
mirror of
https://github.com/lldap/lldap.git
synced 2026-04-05 20:42:57 +01:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07d1948190 | |||
| 4c9036d4ee |
@@ -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,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.
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
* @nitnelave
|
||||
+8
-5
@@ -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"
|
||||
|
||||
@@ -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.**
|
||||
@@ -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
|
||||
@@ -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; \
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -29,8 +29,3 @@ recipe.json
|
||||
lldap_config.toml
|
||||
cert.pem
|
||||
key.pem
|
||||
|
||||
# Nix
|
||||
result
|
||||
result-*
|
||||
.direnv
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())}
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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())}
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -7,7 +7,6 @@ edition.workspace = true
|
||||
homepage.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tracing = "*"
|
||||
|
||||
@@ -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,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>,
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ authors.workspace = true
|
||||
homepage.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[features]
|
||||
test = []
|
||||
|
||||
@@ -6,7 +6,6 @@ authors.workspace = true
|
||||
homepage.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[features]
|
||||
test = []
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ edition.workspace = true
|
||||
homepage.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[features]
|
||||
test = []
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -7,7 +7,6 @@ edition.workspace = true
|
||||
homepage.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies.serde]
|
||||
workspace = true
|
||||
|
||||
@@ -7,7 +7,6 @@ authors.workspace = true
|
||||
homepage.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = "*"
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
+254
-20
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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
@@ -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(×tamp_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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,6 @@ edition.workspace = true
|
||||
homepage.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[features]
|
||||
test = []
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -7,4 +7,3 @@ edition.workspace = true
|
||||
homepage.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
+5
-27
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -55,8 +55,8 @@ Then you'll receive a JSON response with:
|
||||
|
||||
```
|
||||
{
|
||||
"token": "Yh6RJV...",
|
||||
"refreshToken": "dww5jwU...",
|
||||
"token": "eYbat...",
|
||||
"refreshToken": "3bCka...",
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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))"
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)'
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
```
|
||||
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user