mirror of
https://github.com/lldap/lldap.git
synced 2026-07-25 17:58:44 +00:00
Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 68447b12a6 | |||
| 9fb252759a | |||
| 3a26d2ec4c | |||
| 86d9ea10d6 | |||
| 2ad634deda | |||
| 155bda6bbf | |||
| 7d1593e266 | |||
| 8c8df11250 | |||
| aa1384939b | |||
| 6f94134fdc | |||
| d1904a2759 | |||
| 02d92c3261 | |||
| 48058540ec | |||
| 618e3f3062 | |||
| cafd3732f0 | |||
| 8588d4b851 | |||
| 2f70e2e31f | |||
| a9d04b6bdf | |||
| c03f3b5498 | |||
| ac55dfedc4 | |||
| 62ae1d73fa | |||
| 469f35c12c | |||
| ee9fec71a5 | |||
| 9cbb0c99e2 | |||
| 81e985df48 | |||
| a136a68bf4 | |||
| 8f0022a9f1 | |||
| fc7b33e4b3 | |||
| a9b5147a30 | |||
| 4de069452f | |||
| e5c28a61d9 | |||
| c5e0441cae | |||
| a959a50e07 | |||
| ab4389fc5f | |||
| ddcbe383ab | |||
| eee42502f3 | |||
| 660301eb5f | |||
| 73f071ce89 | |||
| 28ef6e0c56 | |||
| a32c8baa25 | |||
| bf5b76269f | |||
| c09e5c451c | |||
| 1382c67de9 | |||
| 0f8f9e1244 | |||
| 9a83e68667 | |||
| 3f9880ec11 | |||
| 94007aee58 | |||
| 9e9d8e2ab5 | |||
| 18edd4eb7d | |||
| 3cdf2241ea | |||
| 9021066507 | |||
| fe063272bf | |||
| 59dee0115d | |||
| 622274cb1a | |||
| 4bad3a9e69 | |||
| 84fb9b0fd2 | |||
| 8a803bfb11 | |||
| f7fe0c6ea0 | |||
| 8f04843466 | |||
| 400beafb29 | |||
| 963e58bf1a | |||
| 176c49c78d | |||
| 3d5542996f | |||
| 4590463cdf | |||
| 85ce481e32 | |||
| f64f8625f1 | |||
| c68f9e7cab | |||
| 775c5c716d | |||
| 89cb59919b | |||
| 267f08f479 | |||
| b370360130 | |||
| 7438fe92cf | |||
| cd2694d7dc | |||
| 5e83ed8eb0 | |||
| c69957690e | |||
| 7ef2af8beb | |||
| 5c9897b156 | |||
| 0b720aa082 | |||
| 3e7277e77d | |||
| 5241626a3a | |||
| 363ef106e2 | |||
| 3c7e4c3dec | |||
| fa196a9fd9 | |||
| f02b365478 | |||
| 0b0e6ae2cd | |||
| da525fc99b | |||
| 78337bce72 | |||
| 87e9311a44 | |||
| 53e62ecf5a | |||
| 10d33a7537 | |||
| ada438398e |
@@ -0,0 +1,46 @@
|
|||||||
|
# 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.74
|
FROM rust:1.89
|
||||||
|
|
||||||
ARG USERNAME=lldapdev
|
ARG USERNAME=lldapdev
|
||||||
# We need to keep the user as 1001 to match the GitHub runner's UID.
|
# We need to keep the user as 1001 to match the GitHub runner's UID.
|
||||||
|
|||||||
@@ -1,8 +1,26 @@
|
|||||||
{
|
{
|
||||||
"name": "LLDAP dev",
|
"name": "LLDAP dev",
|
||||||
"build": { "dockerfile": "Dockerfile" },
|
"build": {
|
||||||
|
"dockerfile": "Dockerfile"
|
||||||
|
},
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"extensions": [
|
||||||
|
"rust-lang.rust-analyzer"
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"rust-analyzer.linkedProjects": [
|
||||||
|
"./Cargo.toml"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"ghcr.io/devcontainers/features/rust:1": {}
|
||||||
|
},
|
||||||
"forwardPorts": [
|
"forwardPorts": [
|
||||||
3890,
|
3890,
|
||||||
17170
|
17170
|
||||||
]
|
],
|
||||||
|
"remoteUser": "lldapdev"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
* @nitnelave
|
|
||||||
+5
-8
@@ -1,19 +1,16 @@
|
|||||||
codecov:
|
codecov:
|
||||||
require_ci_to_pass: yes
|
require_ci_to_pass: yes
|
||||||
comment:
|
comment:
|
||||||
layout: "header,diff,files"
|
layout: "condensed_header, diff, condensed_files"
|
||||||
require_changes: true
|
hide_project_coverage: true
|
||||||
require_base: true
|
require_changes: "coverage_drop"
|
||||||
require_head: true
|
|
||||||
coverage:
|
coverage:
|
||||||
|
range: "70...100"
|
||||||
status:
|
status:
|
||||||
project:
|
project:
|
||||||
default:
|
default:
|
||||||
target: "75%"
|
target: "75%"
|
||||||
threshold: "0.1%"
|
threshold: 5
|
||||||
removed_code_behavior: adjust_base
|
|
||||||
github_checks:
|
|
||||||
annotations: true
|
|
||||||
ignore:
|
ignore:
|
||||||
- "app"
|
- "app"
|
||||||
- "docs"
|
- "docs"
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# 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.**
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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
|
FROM localhost:5000/lldap/lldap:alpine-base
|
||||||
# Taken directly from https://github.com/tianon/gosu/blob/master/INSTALL.md
|
# 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; \
|
RUN set -eux; \
|
||||||
\
|
\
|
||||||
apk add --no-cache --virtual .gosu-deps \
|
apk add --no-cache --virtual .gosu-deps \
|
||||||
@@ -15,7 +15,18 @@ RUN set -eux; \
|
|||||||
\
|
\
|
||||||
# verify the signature
|
# verify the signature
|
||||||
export GNUPGHOME="$(mktemp -d)"; \
|
export GNUPGHOME="$(mktemp -d)"; \
|
||||||
gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \
|
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 --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
|
gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
|
||||||
gpgconf --kill all; \
|
gpgconf --kill all; \
|
||||||
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
|
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
FROM localhost:5000/lldap/lldap:debian-base
|
FROM localhost:5000/lldap/lldap:debian-base
|
||||||
# Taken directly from https://github.com/tianon/gosu/blob/master/INSTALL.md
|
# 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; \
|
RUN set -eux; \
|
||||||
# save list of currently installed packages for later so we can clean up
|
# save list of currently installed packages for later so we can clean up
|
||||||
savedAptMark="$(apt-mark showmanual)"; \
|
savedAptMark="$(apt-mark showmanual)"; \
|
||||||
apt-get update; \
|
for i in 1 2 3; do \
|
||||||
apt-get install -y --no-install-recommends ca-certificates gnupg wget; \
|
apt-get update && \
|
||||||
rm -rf /var/lib/apt/lists/*; \
|
apt-get install -y --no-install-recommends wget ca-certificates gnupg && \
|
||||||
|
apt-get clean && \
|
||||||
|
rm -rf /var/lib/apt/lists/* && break || sleep 5; \
|
||||||
|
done; \
|
||||||
\
|
\
|
||||||
dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \
|
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"; \
|
wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \
|
||||||
@@ -14,7 +17,18 @@ RUN set -eux; \
|
|||||||
\
|
\
|
||||||
# verify the signature
|
# verify the signature
|
||||||
export GNUPGHOME="$(mktemp -d)"; \
|
export GNUPGHOME="$(mktemp -d)"; \
|
||||||
gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \
|
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 --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
|
gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
|
||||||
gpgconf --kill all; \
|
gpgconf --kill all; \
|
||||||
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
|
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Keep tracking base image
|
# Keep tracking base image
|
||||||
FROM rust:1.85-slim-bookworm
|
FROM rust:1.89-slim-bookworm
|
||||||
|
|
||||||
# Set needed env path
|
# Set needed env path
|
||||||
ENV PATH="/opt/armv7l-linux-musleabihf-cross/:/opt/armv7l-linux-musleabihf-cross/bin/:/opt/aarch64-linux-musl-cross/:/opt/aarch64-linux-musl-cross/bin/:/opt/x86_64-linux-musl-cross/:/opt/x86_64-linux-musl-cross/bin/:$PATH"
|
ENV PATH="/opt/armv7l-linux-musleabihf-cross/:/opt/armv7l-linux-musleabihf-cross/bin/:/opt/aarch64-linux-musl-cross/:/opt/aarch64-linux-musl-cross/bin/:/opt/x86_64-linux-musl-cross/:/opt/x86_64-linux-musl-cross/bin/:$PATH"
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
|
MSRV: "1.89.0"
|
||||||
|
|
||||||
### CI Docs
|
### CI Docs
|
||||||
|
|
||||||
@@ -87,8 +87,14 @@ jobs:
|
|||||||
image: lldap/rust-dev:latest
|
image: lldap/rust-dev:latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v6.0.2
|
||||||
- uses: actions/cache@v4
|
- name: Install Rust
|
||||||
|
id: toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@master
|
||||||
|
with:
|
||||||
|
toolchain: "${{ env.MSRV }}"
|
||||||
|
targets: "wasm32-unknown-unknown"
|
||||||
|
- uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
/usr/local/cargo/bin
|
/usr/local/cargo/bin
|
||||||
@@ -99,8 +105,6 @@ jobs:
|
|||||||
key: lldap-ui-${{ hashFiles('**/Cargo.lock') }}
|
key: lldap-ui-${{ hashFiles('**/Cargo.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
lldap-ui-
|
lldap-ui-
|
||||||
- name: Add wasm target (rust)
|
|
||||||
run: rustup target add wasm32-unknown-unknown
|
|
||||||
- name: Install wasm-pack with cargo
|
- name: Install wasm-pack with cargo
|
||||||
run: cargo install wasm-pack || true
|
run: cargo install wasm-pack || true
|
||||||
env:
|
env:
|
||||||
@@ -110,7 +114,7 @@ jobs:
|
|||||||
- name: Check build path
|
- name: Check build path
|
||||||
run: ls -al app/
|
run: ls -al app/
|
||||||
- name: Upload ui artifacts
|
- name: Upload ui artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: ui
|
name: ui
|
||||||
path: app/
|
path: app/
|
||||||
@@ -132,8 +136,14 @@ jobs:
|
|||||||
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
|
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v6.0.2
|
||||||
- uses: actions/cache@v4
|
- name: Install Rust
|
||||||
|
id: toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@master
|
||||||
|
with:
|
||||||
|
toolchain: "${{ env.MSRV }}"
|
||||||
|
targets: "${{ matrix.target }}"
|
||||||
|
- uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
.cargo/bin
|
.cargo/bin
|
||||||
@@ -149,17 +159,17 @@ jobs:
|
|||||||
- name: Check path
|
- name: Check path
|
||||||
run: ls -al target/release
|
run: ls -al target/release
|
||||||
- name: Upload ${{ matrix.target}} lldap artifacts
|
- name: Upload ${{ matrix.target}} lldap artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.target}}-lldap-bin
|
name: ${{ matrix.target}}-lldap-bin
|
||||||
path: target/${{ matrix.target }}/release/lldap
|
path: target/${{ matrix.target }}/release/lldap
|
||||||
- name: Upload ${{ matrix.target }} migration tool artifacts
|
- name: Upload ${{ matrix.target }} migration tool artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.target }}-lldap_migration_tool-bin
|
name: ${{ matrix.target }}-lldap_migration_tool-bin
|
||||||
path: target/${{ matrix.target }}/release/lldap_migration_tool
|
path: target/${{ matrix.target }}/release/lldap_migration_tool
|
||||||
- name: Upload ${{ matrix.target }} password tool artifacts
|
- name: Upload ${{ matrix.target }} password tool artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.target }}-lldap_set_password-bin
|
name: ${{ matrix.target }}-lldap_set_password-bin
|
||||||
path: target/${{ matrix.target }}/release/lldap_set_password
|
path: target/${{ matrix.target }}/release/lldap_set_password
|
||||||
@@ -199,7 +209,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Download artifacts
|
- name: Download artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: x86_64-unknown-linux-musl-lldap-bin
|
name: x86_64-unknown-linux-musl-lldap-bin
|
||||||
path: bin/
|
path: bin/
|
||||||
@@ -300,18 +310,18 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout scripts
|
- name: Checkout scripts
|
||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v6.0.2
|
||||||
with:
|
with:
|
||||||
sparse-checkout: 'scripts'
|
sparse-checkout: 'scripts'
|
||||||
|
|
||||||
- name: Download LLDAP artifacts
|
- name: Download LLDAP artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: x86_64-unknown-linux-musl-lldap-bin
|
name: x86_64-unknown-linux-musl-lldap-bin
|
||||||
path: bin/
|
path: bin/
|
||||||
|
|
||||||
- name: Download LLDAP set password
|
- name: Download LLDAP set password
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: x86_64-unknown-linux-musl-lldap_set_password-bin
|
name: x86_64-unknown-linux-musl-lldap_set_password-bin
|
||||||
path: bin/
|
path: bin/
|
||||||
@@ -496,21 +506,21 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v6.0.2
|
||||||
|
|
||||||
- name: Download all artifacts
|
- name: Download all artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
path: bin
|
path: bin
|
||||||
|
|
||||||
- name: Download llap ui artifacts
|
- name: Download llap ui artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: ui
|
name: ui
|
||||||
path: web
|
path: web
|
||||||
|
|
||||||
- name: Setup QEMU
|
- name: Setup QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v4
|
||||||
- name: Setup buildx
|
- name: Setup buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
with:
|
with:
|
||||||
@@ -681,7 +691,7 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Download all artifacts
|
- name: Download all artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
path: bin/
|
path: bin/
|
||||||
- name: Check file
|
- name: Check file
|
||||||
@@ -702,7 +712,7 @@ jobs:
|
|||||||
chmod +x bin/*-lldap_set_password
|
chmod +x bin/*-lldap_set_password
|
||||||
|
|
||||||
- name: Download llap ui artifacts
|
- name: Download llap ui artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: ui
|
name: ui
|
||||||
path: web
|
path: web
|
||||||
|
|||||||
+28
-22
@@ -8,6 +8,7 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
|
MSRV: "1.89.0"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
pre_job:
|
pre_job:
|
||||||
@@ -33,14 +34,19 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout sources
|
- name: Checkout sources
|
||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v6.0.2
|
||||||
|
- name: Install Rust
|
||||||
|
id: toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@master
|
||||||
|
with:
|
||||||
|
toolchain: "${{ env.MSRV }}"
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
- name: Build
|
- name: Build
|
||||||
run: cargo build --verbose --workspace
|
run: cargo +${{steps.toolchain.outputs.name}} build --verbose --workspace
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: cargo test --verbose --workspace
|
run: cargo +${{steps.toolchain.outputs.name}} test --verbose --workspace
|
||||||
- name: Generate GraphQL schema
|
- name: Generate GraphQL schema
|
||||||
run: cargo run -- export_graphql_schema -o generated_schema.graphql
|
run: cargo +${{steps.toolchain.outputs.name}} run -- export_graphql_schema -o generated_schema.graphql
|
||||||
- name: Check schema
|
- name: Check schema
|
||||||
run: diff schema.graphql generated_schema.graphql || (echo "The schema file is out of date. Please run `./export_schema.sh`" && false)
|
run: diff schema.graphql generated_schema.graphql || (echo "The schema file is out of date. Please run `./export_schema.sh`" && false)
|
||||||
|
|
||||||
@@ -52,15 +58,15 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout sources
|
- name: Checkout sources
|
||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v6.0.2
|
||||||
|
- name: Install Rust
|
||||||
- uses: Swatinem/rust-cache@v2
|
id: toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@master
|
||||||
- name: Run cargo clippy
|
|
||||||
uses: actions-rs/cargo@v1
|
|
||||||
with:
|
with:
|
||||||
command: clippy
|
toolchain: "${{ env.MSRV }}"
|
||||||
args: --tests --all -- -D warnings
|
components: clippy
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- run: cargo +${{steps.toolchain.outputs.name}} clippy --tests --workspace -- -D warnings
|
||||||
|
|
||||||
format:
|
format:
|
||||||
name: cargo fmt
|
name: cargo fmt
|
||||||
@@ -69,15 +75,15 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout sources
|
- name: Checkout sources
|
||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v6.0.2
|
||||||
|
- name: Install Rust
|
||||||
- uses: Swatinem/rust-cache@v2
|
id: toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@master
|
||||||
- name: Run cargo fmt
|
|
||||||
uses: actions-rs/cargo@v1
|
|
||||||
with:
|
with:
|
||||||
command: fmt
|
toolchain: "${{ env.MSRV }}"
|
||||||
args: --all -- --check
|
components: rustfmt
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- run: cargo +${{steps.toolchain.outputs.name}} fmt --check --all
|
||||||
|
|
||||||
coverage:
|
coverage:
|
||||||
name: Code coverage
|
name: Code coverage
|
||||||
@@ -88,7 +94,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout sources
|
- name: Checkout sources
|
||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v6.0.2
|
||||||
|
|
||||||
- name: Install Rust
|
- 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
|
run: rustup toolchain install nightly --component llvm-tools-preview && rustup component add llvm-tools-preview --toolchain stable-x86_64-unknown-linux-gnu
|
||||||
@@ -102,7 +108,7 @@ jobs:
|
|||||||
- name: Aggregate reports
|
- name: Aggregate reports
|
||||||
run: cargo llvm-cov --no-run --lcov --output-path lcov.info
|
run: cargo llvm-cov --no-run --lcov --output-path lcov.info
|
||||||
- name: Upload coverage to Codecov (main)
|
- name: Upload coverage to Codecov (main)
|
||||||
uses: codecov/codecov-action@v4
|
uses: codecov/codecov-action@v6
|
||||||
with:
|
with:
|
||||||
files: lcov.info
|
files: lcov.info
|
||||||
fail_ci_if_error: true
|
fail_ci_if_error: true
|
||||||
|
|||||||
@@ -29,3 +29,8 @@ recipe.json
|
|||||||
lldap_config.toml
|
lldap_config.toml
|
||||||
cert.pem
|
cert.pem
|
||||||
key.pem
|
key.pem
|
||||||
|
|
||||||
|
# Nix
|
||||||
|
result
|
||||||
|
result-*
|
||||||
|
.direnv
|
||||||
|
|||||||
@@ -5,6 +5,61 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.6.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
|
## [0.6.1] 2024-11-22
|
||||||
|
|
||||||
Small release, mainly to fix a migration issue with Sqlite and Postgresql.
|
Small release, mainly to fix a migration issue with Sqlite and Postgresql.
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ advanced guides (scripting, migrations, ...) you can contribute to.
|
|||||||
If you don't know what to start with, check out the
|
If you don't know what to start with, check out the
|
||||||
[good first issues](https://github.com/lldap/lldap/labels/good%20first%20issue).
|
[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).
|
||||||
|
|
||||||
Otherwise, if you want to fix a specific bug or implement a feature, make sure
|
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
|
to start by creating an issue for it (if it doesn't already exist). There, we
|
||||||
can discuss whether it would be likely to be accepted and consider design
|
can discuss whether it would be likely to be accepted and consider design
|
||||||
|
|||||||
Generated
+1615
-852
File diff suppressed because it is too large
Load Diff
+1
-3
@@ -16,6 +16,7 @@ edition = "2024"
|
|||||||
homepage = "https://github.com/lldap/lldap"
|
homepage = "https://github.com/lldap/lldap"
|
||||||
license = "GPL-3.0-only"
|
license = "GPL-3.0-only"
|
||||||
repository = "https://github.com/lldap/lldap"
|
repository = "https://github.com/lldap/lldap"
|
||||||
|
rust-version = "1.89.0"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = true
|
lto = true
|
||||||
@@ -23,9 +24,6 @@ lto = true
|
|||||||
[profile.release.package.lldap_app]
|
[profile.release.package.lldap_app]
|
||||||
opt-level = 's'
|
opt-level = 's'
|
||||||
|
|
||||||
[patch.crates-io.lber]
|
|
||||||
git = 'https://github.com/inejge/ldap3/'
|
|
||||||
|
|
||||||
[workspace.dependencies.sea-orm]
|
[workspace.dependencies.sea-orm]
|
||||||
version = "1.1.8"
|
version = "1.1.8"
|
||||||
default-features = false
|
default-features = false
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ MySQL/MariaDB or PostgreSQL.
|
|||||||
|
|
||||||
## Installation
|
## 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), 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), [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).
|
||||||
|
|
||||||
Building [from source](docs/install.md#from-source) and [cross-compiling](docs/install.md#cross-compilation) to a different hardware architecture is also supported.
|
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).
|
the config).
|
||||||
|
|
||||||
Some specific clients have been tested to work and come with sample
|
Some specific clients have been tested to work and come with sample
|
||||||
configuration files, or guides. See the [`example_configs`](example_configs)
|
configuration files, or guides. See the [`example_configs`](example_configs/README.md)
|
||||||
folder for example configs for integration with specific services.
|
folder for example configs for integration with specific services.
|
||||||
|
|
||||||
Integration with Linux accounts is possible, through PAM and nslcd. See [PAM
|
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)
|
- [I can't login](docs/faq.md#i-cant-log-in)
|
||||||
- [Discord Integration](docs/faq.md#discord-integration)
|
- [Discord Integration](docs/faq.md#discord-integration)
|
||||||
- [Migrating from SQLite](docs/faq.md#migrating-from-sqlite)
|
- [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 support vhosts?](docs/faq.md#does-lldap-support-vhosts)
|
||||||
- [Does lldap provide commercial support contracts?](docs/faq.md#does-lldap-provide-commercial-support-contracts)
|
- [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)
|
- [Can I make a donation to fund development?](docs/faq.md#can-i-make-a-donation-to-fund-development)
|
||||||
|
|||||||
+11
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lldap_app"
|
name = "lldap_app"
|
||||||
version = "0.6.2-alpha"
|
version = "0.6.2"
|
||||||
description = "Frontend for LLDAP"
|
description = "Frontend for LLDAP"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
include = ["src/**/*", "queries/**/*", "Cargo.toml", "../schema.graphql"]
|
include = ["src/**/*", "queries/**/*", "Cargo.toml", "../schema.graphql"]
|
||||||
@@ -8,6 +8,7 @@ authors.workspace = true
|
|||||||
homepage.workspace = true
|
homepage.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
@@ -55,6 +56,11 @@ features = [
|
|||||||
"wasmbind"
|
"wasmbind"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[dependencies.derive_more]
|
||||||
|
features = ["debug", "display", "from", "from_str"]
|
||||||
|
default-features = false
|
||||||
|
version = "1"
|
||||||
|
|
||||||
[dependencies.lldap_auth]
|
[dependencies.lldap_auth]
|
||||||
path = "../crates/auth"
|
path = "../crates/auth"
|
||||||
features = [ "opaque_client" ]
|
features = [ "opaque_client" ]
|
||||||
@@ -73,6 +79,10 @@ version = "0.24"
|
|||||||
[dependencies.serde]
|
[dependencies.serde]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
|
[dependencies.strum]
|
||||||
|
features = ["derive"]
|
||||||
|
version = "0.25"
|
||||||
|
|
||||||
[dependencies.yew_form]
|
[dependencies.yew_form]
|
||||||
git = "https://github.com/jfbilodeau/yew_form"
|
git = "https://github.com/jfbilodeau/yew_form"
|
||||||
rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9"
|
rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9"
|
||||||
|
|||||||
+12
-10
@@ -197,17 +197,19 @@ impl App {
|
|||||||
<CreateUserForm/>
|
<CreateUserForm/>
|
||||||
},
|
},
|
||||||
AppRoute::Index | AppRoute::ListUsers => {
|
AppRoute::Index | AppRoute::ListUsers => {
|
||||||
let user_button = html! {
|
let user_button = |key| {
|
||||||
<Link classes="btn btn-primary" to={AppRoute::CreateUser}>
|
html! {
|
||||||
|
<Link classes="btn btn-primary" key={key} to={AppRoute::CreateUser}>
|
||||||
<i class="bi-person-plus me-2"></i>
|
<i class="bi-person-plus me-2"></i>
|
||||||
{"Create a user"}
|
{"Create a user"}
|
||||||
</Link>
|
</Link>
|
||||||
|
}
|
||||||
};
|
};
|
||||||
html! {
|
html! {
|
||||||
<div>
|
<div>
|
||||||
{ user_button.clone() }
|
{ user_button("top-create-user") }
|
||||||
<UserTable />
|
<UserTable />
|
||||||
{ user_button }
|
{ user_button("bottom-create-user") }
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -221,19 +223,19 @@ impl App {
|
|||||||
<CreateGroupAttributeForm/>
|
<CreateGroupAttributeForm/>
|
||||||
},
|
},
|
||||||
AppRoute::ListGroups => {
|
AppRoute::ListGroups => {
|
||||||
let group_button = html! {
|
let group_button = |key| {
|
||||||
<Link classes="btn btn-primary" to={AppRoute::CreateGroup}>
|
html! {
|
||||||
|
<Link classes="btn btn-primary" key={key} to={AppRoute::CreateGroup}>
|
||||||
<i class="bi-plus-circle me-2"></i>
|
<i class="bi-plus-circle me-2"></i>
|
||||||
{"Create a group"}
|
{"Create a group"}
|
||||||
</Link>
|
</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! {
|
html! {
|
||||||
<div>
|
<div>
|
||||||
{ group_button.clone() }
|
{ group_button("top-create-group") }
|
||||||
<GroupTable />
|
<GroupTable />
|
||||||
{ group_button }
|
{ group_button("bottom-create-group") }
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ use crate::{
|
|||||||
},
|
},
|
||||||
router::AppRoute,
|
router::AppRoute,
|
||||||
},
|
},
|
||||||
convert_attribute_type,
|
|
||||||
infra::{
|
infra::{
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
form_utils::{
|
form_utils::{
|
||||||
@@ -30,7 +29,8 @@ use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
|||||||
schema_path = "../schema.graphql",
|
schema_path = "../schema.graphql",
|
||||||
query_path = "queries/get_group_attributes_schema.graphql",
|
query_path = "queries/get_group_attributes_schema.graphql",
|
||||||
response_derives = "Debug,Clone,PartialEq,Eq",
|
response_derives = "Debug,Clone,PartialEq,Eq",
|
||||||
custom_scalars_module = "crate::infra::graphql"
|
custom_scalars_module = "crate::infra::graphql",
|
||||||
|
extern_enums("AttributeType")
|
||||||
)]
|
)]
|
||||||
pub struct GetGroupAttributesSchema;
|
pub struct GetGroupAttributesSchema;
|
||||||
|
|
||||||
@@ -39,8 +39,6 @@ use get_group_attributes_schema::ResponseData;
|
|||||||
pub type Attribute =
|
pub type Attribute =
|
||||||
get_group_attributes_schema::GetGroupAttributesSchemaSchemaGroupSchemaAttributes;
|
get_group_attributes_schema::GetGroupAttributesSchemaSchemaGroupSchemaAttributes;
|
||||||
|
|
||||||
convert_attribute_type!(get_group_attributes_schema::AttributeType);
|
|
||||||
|
|
||||||
impl From<&Attribute> for GraphQlAttributeSchema {
|
impl From<&Attribute> for GraphQlAttributeSchema {
|
||||||
fn from(attr: &Attribute) -> Self {
|
fn from(attr: &Attribute) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -218,14 +216,14 @@ fn get_custom_attribute_input(attribute_schema: &Attribute) -> Html {
|
|||||||
html! {
|
html! {
|
||||||
<ListAttributeInput
|
<ListAttributeInput
|
||||||
name={attribute_schema.name.clone()}
|
name={attribute_schema.name.clone()}
|
||||||
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
attribute_type={attribute_schema.attribute_type}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
html! {
|
html! {
|
||||||
<SingleAttributeInput
|
<SingleAttributeInput
|
||||||
name={attribute_schema.name.clone()}
|
name={attribute_schema.name.clone()}
|
||||||
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
attribute_type={attribute_schema.attribute_type}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ use crate::{
|
|||||||
form::{checkbox::CheckBox, field::Field, select::Select, submit::Submit},
|
form::{checkbox::CheckBox, field::Field, select::Select, submit::Submit},
|
||||||
router::AppRoute,
|
router::AppRoute,
|
||||||
},
|
},
|
||||||
convert_attribute_type,
|
|
||||||
infra::{
|
infra::{
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
schema::{AttributeType, validate_attribute_type},
|
schema::{AttributeType, validate_attribute_type},
|
||||||
@@ -23,12 +22,11 @@ use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
|||||||
schema_path = "../schema.graphql",
|
schema_path = "../schema.graphql",
|
||||||
query_path = "queries/create_group_attribute.graphql",
|
query_path = "queries/create_group_attribute.graphql",
|
||||||
response_derives = "Debug",
|
response_derives = "Debug",
|
||||||
custom_scalars_module = "crate::infra::graphql"
|
custom_scalars_module = "crate::infra::graphql",
|
||||||
|
extern_enums("AttributeType")
|
||||||
)]
|
)]
|
||||||
pub struct CreateGroupAttribute;
|
pub struct CreateGroupAttribute;
|
||||||
|
|
||||||
convert_attribute_type!(create_group_attribute::AttributeType);
|
|
||||||
|
|
||||||
pub struct CreateGroupAttributeForm {
|
pub struct CreateGroupAttributeForm {
|
||||||
common: CommonComponentParts<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
form: yew_form::Form<CreateGroupAttributeModel>,
|
form: yew_form::Form<CreateGroupAttributeModel>,
|
||||||
@@ -70,10 +68,11 @@ impl CommonComponent<CreateGroupAttributeForm> for CreateGroupAttributeForm {
|
|||||||
invalid
|
invalid
|
||||||
);
|
);
|
||||||
})?;
|
})?;
|
||||||
let attribute_type = model.attribute_type.parse::<AttributeType>().unwrap();
|
let attribute_type =
|
||||||
|
AttributeType::try_from(model.attribute_type.as_str()).unwrap();
|
||||||
let req = create_group_attribute::Variables {
|
let req = create_group_attribute::Variables {
|
||||||
name: model.attribute_name,
|
name: model.attribute_name,
|
||||||
attribute_type: create_group_attribute::AttributeType::from(attribute_type),
|
attribute_type,
|
||||||
is_list: model.is_list,
|
is_list: model.is_list,
|
||||||
is_visible: model.is_visible,
|
is_visible: model.is_visible,
|
||||||
};
|
};
|
||||||
@@ -145,7 +144,7 @@ impl Component for CreateGroupAttributeForm {
|
|||||||
oninput={link.callback(|_| Msg::Update)}>
|
oninput={link.callback(|_| Msg::Update)}>
|
||||||
<option selected=true value="String">{"String"}</option>
|
<option selected=true value="String">{"String"}</option>
|
||||||
<option value="Integer">{"Integer"}</option>
|
<option value="Integer">{"Integer"}</option>
|
||||||
<option value="Jpeg">{"Jpeg"}</option>
|
<option value="JpegPhoto">{"Jpeg"}</option>
|
||||||
<option value="DateTime">{"DateTime"}</option>
|
<option value="DateTime">{"DateTime"}</option>
|
||||||
</Select<CreateGroupAttributeModel>>
|
</Select<CreateGroupAttributeModel>>
|
||||||
<CheckBox<CreateGroupAttributeModel>
|
<CheckBox<CreateGroupAttributeModel>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ use crate::{
|
|||||||
},
|
},
|
||||||
router::AppRoute,
|
router::AppRoute,
|
||||||
},
|
},
|
||||||
convert_attribute_type,
|
|
||||||
infra::{
|
infra::{
|
||||||
api::HostService,
|
api::HostService,
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
@@ -32,7 +31,8 @@ use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
|||||||
schema_path = "../schema.graphql",
|
schema_path = "../schema.graphql",
|
||||||
query_path = "queries/get_user_attributes_schema.graphql",
|
query_path = "queries/get_user_attributes_schema.graphql",
|
||||||
response_derives = "Debug,Clone,PartialEq,Eq",
|
response_derives = "Debug,Clone,PartialEq,Eq",
|
||||||
custom_scalars_module = "crate::infra::graphql"
|
custom_scalars_module = "crate::infra::graphql",
|
||||||
|
extern_enums("AttributeType")
|
||||||
)]
|
)]
|
||||||
pub struct GetUserAttributesSchema;
|
pub struct GetUserAttributesSchema;
|
||||||
|
|
||||||
@@ -40,8 +40,6 @@ use get_user_attributes_schema::ResponseData;
|
|||||||
|
|
||||||
pub type Attribute = get_user_attributes_schema::GetUserAttributesSchemaSchemaUserSchemaAttributes;
|
pub type Attribute = get_user_attributes_schema::GetUserAttributesSchemaSchemaUserSchemaAttributes;
|
||||||
|
|
||||||
convert_attribute_type!(get_user_attributes_schema::AttributeType);
|
|
||||||
|
|
||||||
impl From<&Attribute> for GraphQlAttributeSchema {
|
impl From<&Attribute> for GraphQlAttributeSchema {
|
||||||
fn from(attr: &Attribute) -> Self {
|
fn from(attr: &Attribute) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -306,18 +304,22 @@ impl Component for CreateUserForm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn get_custom_attribute_input(attribute_schema: &Attribute) -> Html {
|
fn get_custom_attribute_input(attribute_schema: &Attribute) -> Html {
|
||||||
|
let mail_is_required = attribute_schema.name.as_str() == "mail";
|
||||||
|
|
||||||
if attribute_schema.is_list {
|
if attribute_schema.is_list {
|
||||||
html! {
|
html! {
|
||||||
<ListAttributeInput
|
<ListAttributeInput
|
||||||
name={attribute_schema.name.clone()}
|
name={attribute_schema.name.clone()}
|
||||||
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
attribute_type={attribute_schema.attribute_type}
|
||||||
|
required={mail_is_required}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
html! {
|
html! {
|
||||||
<SingleAttributeInput
|
<SingleAttributeInput
|
||||||
name={attribute_schema.name.clone()}
|
name={attribute_schema.name.clone()}
|
||||||
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
attribute_type={attribute_schema.attribute_type}
|
||||||
|
required={mail_is_required}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ use crate::{
|
|||||||
form::{checkbox::CheckBox, field::Field, select::Select, submit::Submit},
|
form::{checkbox::CheckBox, field::Field, select::Select, submit::Submit},
|
||||||
router::AppRoute,
|
router::AppRoute,
|
||||||
},
|
},
|
||||||
convert_attribute_type,
|
|
||||||
infra::{
|
infra::{
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
schema::{AttributeType, validate_attribute_type},
|
schema::{AttributeType, validate_attribute_type},
|
||||||
@@ -23,12 +22,11 @@ use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
|||||||
schema_path = "../schema.graphql",
|
schema_path = "../schema.graphql",
|
||||||
query_path = "queries/create_user_attribute.graphql",
|
query_path = "queries/create_user_attribute.graphql",
|
||||||
response_derives = "Debug",
|
response_derives = "Debug",
|
||||||
custom_scalars_module = "crate::infra::graphql"
|
custom_scalars_module = "crate::infra::graphql",
|
||||||
|
extern_enums("AttributeType")
|
||||||
)]
|
)]
|
||||||
pub struct CreateUserAttribute;
|
pub struct CreateUserAttribute;
|
||||||
|
|
||||||
convert_attribute_type!(create_user_attribute::AttributeType);
|
|
||||||
|
|
||||||
pub struct CreateUserAttributeForm {
|
pub struct CreateUserAttributeForm {
|
||||||
common: CommonComponentParts<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
form: yew_form::Form<CreateUserAttributeModel>,
|
form: yew_form::Form<CreateUserAttributeModel>,
|
||||||
@@ -74,10 +72,11 @@ impl CommonComponent<CreateUserAttributeForm> for CreateUserAttributeForm {
|
|||||||
invalid
|
invalid
|
||||||
);
|
);
|
||||||
})?;
|
})?;
|
||||||
let attribute_type = model.attribute_type.parse::<AttributeType>().unwrap();
|
let attribute_type =
|
||||||
|
AttributeType::try_from(model.attribute_type.as_str()).unwrap();
|
||||||
let req = create_user_attribute::Variables {
|
let req = create_user_attribute::Variables {
|
||||||
name: model.attribute_name,
|
name: model.attribute_name,
|
||||||
attribute_type: create_user_attribute::AttributeType::from(attribute_type),
|
attribute_type,
|
||||||
is_editable: model.is_editable,
|
is_editable: model.is_editable,
|
||||||
is_list: model.is_list,
|
is_list: model.is_list,
|
||||||
is_visible: model.is_visible,
|
is_visible: model.is_visible,
|
||||||
@@ -147,7 +146,7 @@ impl Component for CreateUserAttributeForm {
|
|||||||
oninput={link.callback(|_| Msg::Update)}>
|
oninput={link.callback(|_| Msg::Update)}>
|
||||||
<option selected=true value="String">{"String"}</option>
|
<option selected=true value="String">{"String"}</option>
|
||||||
<option value="Integer">{"Integer"}</option>
|
<option value="Integer">{"Integer"}</option>
|
||||||
<option value="Jpeg">{"Jpeg"}</option>
|
<option value="JpegPhoto">{"Jpeg"}</option>
|
||||||
<option value="DateTime">{"DateTime"}</option>
|
<option value="DateTime">{"DateTime"}</option>
|
||||||
</Select<CreateUserAttributeModel>>
|
</Select<CreateUserAttributeModel>>
|
||||||
<CheckBox<CreateUserAttributeModel>
|
<CheckBox<CreateUserAttributeModel>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ fn attribute_input(props: &AttributeInputProps) -> Html {
|
|||||||
<DateTimeInput name={props.name.clone()} value={props.value.clone()} />
|
<DateTimeInput name={props.name.clone()} value={props.value.clone()} />
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
AttributeType::Jpeg => {
|
AttributeType::JpegPhoto => {
|
||||||
return html! {
|
return html! {
|
||||||
<JpegFileInput name={props.name.clone()} value={props.value.clone()} />
|
<JpegFileInput name={props.name.clone()} value={props.value.clone()} />
|
||||||
};
|
};
|
||||||
@@ -45,6 +45,8 @@ fn attribute_input(props: &AttributeInputProps) -> Html {
|
|||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
struct AttributeLabelProps {
|
struct AttributeLabelProps {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
#[prop_or(false)]
|
||||||
|
pub required: bool,
|
||||||
}
|
}
|
||||||
#[function_component(AttributeLabel)]
|
#[function_component(AttributeLabel)]
|
||||||
fn attribute_label(props: &AttributeLabelProps) -> Html {
|
fn attribute_label(props: &AttributeLabelProps) -> Html {
|
||||||
@@ -66,7 +68,9 @@ fn attribute_label(props: &AttributeLabelProps) -> Html {
|
|||||||
<label for={props.name.clone()}
|
<label for={props.name.clone()}
|
||||||
class="form-label col-4 col-form-label"
|
class="form-label col-4 col-form-label"
|
||||||
>
|
>
|
||||||
{props.name[0..1].to_uppercase() + &props.name[1..].replace('_', " ")}{":"}
|
{props.name[0..1].to_uppercase() + &props.name[1..].replace('_', " ")}
|
||||||
|
{if props.required { html!{<span class="text-danger">{"*"}</span>} } else { html!{} }}
|
||||||
|
{":"}
|
||||||
<button
|
<button
|
||||||
class="btn btn-sm btn-link"
|
class="btn btn-sm btn-link"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -82,19 +86,21 @@ fn attribute_label(props: &AttributeLabelProps) -> Html {
|
|||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct SingleAttributeInputProps {
|
pub struct SingleAttributeInputProps {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub attribute_type: AttributeType,
|
pub(crate) attribute_type: AttributeType,
|
||||||
#[prop_or(None)]
|
#[prop_or(None)]
|
||||||
pub value: Option<String>,
|
pub value: Option<String>,
|
||||||
|
#[prop_or(false)]
|
||||||
|
pub required: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[function_component(SingleAttributeInput)]
|
#[function_component(SingleAttributeInput)]
|
||||||
pub fn single_attribute_input(props: &SingleAttributeInputProps) -> Html {
|
pub fn single_attribute_input(props: &SingleAttributeInputProps) -> Html {
|
||||||
html! {
|
html! {
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<AttributeLabel name={props.name.clone()} />
|
<AttributeLabel name={props.name.clone()} required={props.required} />
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<AttributeInput
|
<AttributeInput
|
||||||
attribute_type={props.attribute_type.clone()}
|
attribute_type={props.attribute_type}
|
||||||
name={props.name.clone()}
|
name={props.name.clone()}
|
||||||
value={props.value.clone()} />
|
value={props.value.clone()} />
|
||||||
</div>
|
</div>
|
||||||
@@ -105,9 +111,11 @@ pub fn single_attribute_input(props: &SingleAttributeInputProps) -> Html {
|
|||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct ListAttributeInputProps {
|
pub struct ListAttributeInputProps {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub attribute_type: AttributeType,
|
pub(crate) attribute_type: AttributeType,
|
||||||
#[prop_or(vec!())]
|
#[prop_or(vec!())]
|
||||||
pub values: Vec<String>,
|
pub values: Vec<String>,
|
||||||
|
#[prop_or(false)]
|
||||||
|
pub required: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum ListAttributeInputMsg {
|
pub enum ListAttributeInputMsg {
|
||||||
@@ -160,12 +168,12 @@ impl Component for ListAttributeInput {
|
|||||||
let link = &ctx.link();
|
let link = &ctx.link();
|
||||||
html! {
|
html! {
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<AttributeLabel name={props.name.clone()} />
|
<AttributeLabel name={props.name.clone()} required={props.required} />
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
{self.indices.iter().map(|&i| html! {
|
{self.indices.iter().map(|&i| html! {
|
||||||
<div class="input-group mb-2" key={i}>
|
<div class="input-group mb-2" key={i}>
|
||||||
<AttributeInput
|
<AttributeInput
|
||||||
attribute_type={props.attribute_type.clone()}
|
attribute_type={props.attribute_type}
|
||||||
name={props.name.clone()}
|
name={props.name.clone()}
|
||||||
value={props.values.get(i).cloned().unwrap_or_default()} />
|
value={props.values.get(i).cloned().unwrap_or_default()} />
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -147,10 +147,11 @@ impl Component for JpegFileInput {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
Msg::FileLoaded(file_name, data) => {
|
Msg::FileLoaded(file_name, data) => {
|
||||||
if let Some(avatar) = &mut self.avatar {
|
if let Some(avatar) = &mut self.avatar
|
||||||
if let Some(file) = &avatar.file {
|
&& let Some(file) = &avatar.file
|
||||||
if file.name() == file_name {
|
&& file.name() == file_name
|
||||||
if let Result::Ok(data) = data {
|
&& let Result::Ok(data) = data
|
||||||
|
{
|
||||||
if !is_valid_jpeg(data.as_slice()) {
|
if !is_valid_jpeg(data.as_slice()) {
|
||||||
// Clear the selection.
|
// Clear the selection.
|
||||||
self.avatar = Some(JsFile::default());
|
self.avatar = Some(JsFile::default());
|
||||||
@@ -160,9 +161,6 @@ impl Component for JpegFileInput {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.reader = None;
|
self.reader = None;
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ use crate::{
|
|||||||
remove_user_from_group::RemoveUserFromGroupComponent,
|
remove_user_from_group::RemoveUserFromGroupComponent,
|
||||||
router::{AppRoute, Link},
|
router::{AppRoute, Link},
|
||||||
},
|
},
|
||||||
convert_attribute_type,
|
|
||||||
infra::{
|
infra::{
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
form_utils::GraphQlAttributeSchema,
|
form_utils::GraphQlAttributeSchema,
|
||||||
|
schema::AttributeType,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{Error, Result, bail};
|
use anyhow::{Error, Result, bail};
|
||||||
@@ -20,7 +20,8 @@ use yew::prelude::*;
|
|||||||
schema_path = "../schema.graphql",
|
schema_path = "../schema.graphql",
|
||||||
query_path = "queries/get_group_details.graphql",
|
query_path = "queries/get_group_details.graphql",
|
||||||
response_derives = "Debug, Hash, PartialEq, Eq, Clone",
|
response_derives = "Debug, Hash, PartialEq, Eq, Clone",
|
||||||
custom_scalars_module = "crate::infra::graphql"
|
custom_scalars_module = "crate::infra::graphql",
|
||||||
|
extern_enums("AttributeType")
|
||||||
)]
|
)]
|
||||||
pub struct GetGroupDetails;
|
pub struct GetGroupDetails;
|
||||||
|
|
||||||
@@ -29,9 +30,6 @@ pub type User = get_group_details::GetGroupDetailsGroupUsers;
|
|||||||
pub type AddGroupMemberUser = add_group_member::User;
|
pub type AddGroupMemberUser = add_group_member::User;
|
||||||
pub type Attribute = get_group_details::GetGroupDetailsGroupAttributes;
|
pub type Attribute = get_group_details::GetGroupDetailsGroupAttributes;
|
||||||
pub type AttributeSchema = get_group_details::GetGroupDetailsSchemaGroupSchemaAttributes;
|
pub type AttributeSchema = get_group_details::GetGroupDetailsSchemaGroupSchemaAttributes;
|
||||||
pub type AttributeType = get_group_details::AttributeType;
|
|
||||||
|
|
||||||
convert_attribute_type!(AttributeType);
|
|
||||||
|
|
||||||
impl From<&AttributeSchema> for GraphQlAttributeSchema {
|
impl From<&AttributeSchema> for GraphQlAttributeSchema {
|
||||||
fn from(attr: &AttributeSchema) -> Self {
|
fn from(attr: &AttributeSchema) -> Self {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ use crate::{
|
|||||||
infra::{
|
infra::{
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
form_utils::{AttributeValue, EmailIsRequired, IsAdmin, read_all_form_attributes},
|
form_utils::{AttributeValue, EmailIsRequired, IsAdmin, read_all_form_attributes},
|
||||||
schema::AttributeType,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{Ok, Result};
|
use anyhow::{Ok, Result};
|
||||||
@@ -174,7 +173,7 @@ fn get_custom_attribute_input(
|
|||||||
html! {
|
html! {
|
||||||
<ListAttributeInput
|
<ListAttributeInput
|
||||||
name={attribute_schema.name.clone()}
|
name={attribute_schema.name.clone()}
|
||||||
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
attribute_type={attribute_schema.attribute_type}
|
||||||
values={values}
|
values={values}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -182,7 +181,7 @@ fn get_custom_attribute_input(
|
|||||||
html! {
|
html! {
|
||||||
<SingleAttributeInput
|
<SingleAttributeInput
|
||||||
name={attribute_schema.name.clone()}
|
name={attribute_schema.name.clone()}
|
||||||
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
attribute_type={attribute_schema.attribute_type}
|
||||||
value={values.first().cloned().unwrap_or_default()}
|
value={values.first().cloned().unwrap_or_default()}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ use crate::{
|
|||||||
fragments::attribute_schema::render_attribute_name,
|
fragments::attribute_schema::render_attribute_name,
|
||||||
router::{AppRoute, Link},
|
router::{AppRoute, Link},
|
||||||
},
|
},
|
||||||
convert_attribute_type,
|
|
||||||
infra::{
|
infra::{
|
||||||
attributes::group,
|
attributes::group,
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
@@ -21,7 +20,8 @@ use yew::prelude::*;
|
|||||||
schema_path = "../schema.graphql",
|
schema_path = "../schema.graphql",
|
||||||
query_path = "queries/get_group_attributes_schema.graphql",
|
query_path = "queries/get_group_attributes_schema.graphql",
|
||||||
response_derives = "Debug,Clone,PartialEq,Eq",
|
response_derives = "Debug,Clone,PartialEq,Eq",
|
||||||
custom_scalars_module = "crate::infra::graphql"
|
custom_scalars_module = "crate::infra::graphql",
|
||||||
|
extern_enums("AttributeType")
|
||||||
)]
|
)]
|
||||||
pub struct GetGroupAttributesSchema;
|
pub struct GetGroupAttributesSchema;
|
||||||
|
|
||||||
@@ -30,8 +30,6 @@ use get_group_attributes_schema::ResponseData;
|
|||||||
pub type Attribute =
|
pub type Attribute =
|
||||||
get_group_attributes_schema::GetGroupAttributesSchemaSchemaGroupSchemaAttributes;
|
get_group_attributes_schema::GetGroupAttributesSchemaSchemaGroupSchemaAttributes;
|
||||||
|
|
||||||
convert_attribute_type!(get_group_attributes_schema::AttributeType);
|
|
||||||
|
|
||||||
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
||||||
pub struct Props {
|
pub struct Props {
|
||||||
pub hardcoded: bool,
|
pub hardcoded: bool,
|
||||||
@@ -147,7 +145,7 @@ impl GroupSchemaTable {
|
|||||||
|
|
||||||
fn view_attribute(&self, ctx: &Context<Self>, attribute: &Attribute) -> Html {
|
fn view_attribute(&self, ctx: &Context<Self>, attribute: &Attribute) -> Html {
|
||||||
let link = ctx.link();
|
let link = ctx.link();
|
||||||
let attribute_type = AttributeType::from(attribute.attribute_type.clone());
|
let attribute_type = attribute.attribute_type;
|
||||||
let checkmark = html! {
|
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">
|
<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>
|
<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 {
|
pub struct FormModel {
|
||||||
#[validate(length(min = 1, message = "Missing username"))]
|
#[validate(length(min = 1, message = "Missing username"))]
|
||||||
username: String,
|
username: String,
|
||||||
#[validate(length(min = 8, message = "Invalid password. Min length: 8"))]
|
#[validate(length(min = 1, message = "Missing password"))]
|
||||||
password: String,
|
password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ use crate::{
|
|||||||
router::{AppRoute, Link},
|
router::{AppRoute, Link},
|
||||||
user_details_form::UserDetailsForm,
|
user_details_form::UserDetailsForm,
|
||||||
},
|
},
|
||||||
convert_attribute_type,
|
|
||||||
infra::{
|
infra::{
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
form_utils::GraphQlAttributeSchema,
|
form_utils::GraphQlAttributeSchema,
|
||||||
|
schema::AttributeType,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{Error, Result, bail};
|
use anyhow::{Error, Result, bail};
|
||||||
@@ -20,7 +20,8 @@ use yew::prelude::*;
|
|||||||
schema_path = "../schema.graphql",
|
schema_path = "../schema.graphql",
|
||||||
query_path = "queries/get_user_details.graphql",
|
query_path = "queries/get_user_details.graphql",
|
||||||
response_derives = "Debug, Hash, PartialEq, Eq, Clone",
|
response_derives = "Debug, Hash, PartialEq, Eq, Clone",
|
||||||
custom_scalars_module = "crate::infra::graphql"
|
custom_scalars_module = "crate::infra::graphql",
|
||||||
|
extern_enums("AttributeType")
|
||||||
)]
|
)]
|
||||||
pub struct GetUserDetails;
|
pub struct GetUserDetails;
|
||||||
|
|
||||||
@@ -28,9 +29,6 @@ pub type User = get_user_details::GetUserDetailsUser;
|
|||||||
pub type Group = get_user_details::GetUserDetailsUserGroups;
|
pub type Group = get_user_details::GetUserDetailsUserGroups;
|
||||||
pub type Attribute = get_user_details::GetUserDetailsUserAttributes;
|
pub type Attribute = get_user_details::GetUserDetailsUserAttributes;
|
||||||
pub type AttributeSchema = get_user_details::GetUserDetailsSchemaUserSchemaAttributes;
|
pub type AttributeSchema = get_user_details::GetUserDetailsSchemaUserSchemaAttributes;
|
||||||
pub type AttributeType = get_user_details::AttributeType;
|
|
||||||
|
|
||||||
convert_attribute_type!(AttributeType);
|
|
||||||
|
|
||||||
impl From<&AttributeSchema> for GraphQlAttributeSchema {
|
impl From<&AttributeSchema> for GraphQlAttributeSchema {
|
||||||
fn from(attr: &AttributeSchema) -> Self {
|
fn from(attr: &AttributeSchema) -> Self {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use crate::{
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{Ok, Result};
|
use anyhow::{Ok, Result};
|
||||||
|
use gloo_console::console;
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
|
|
||||||
@@ -168,7 +169,7 @@ fn get_custom_attribute_input(
|
|||||||
html! {
|
html! {
|
||||||
<ListAttributeInput
|
<ListAttributeInput
|
||||||
name={attribute_schema.name.clone()}
|
name={attribute_schema.name.clone()}
|
||||||
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
attribute_type={attribute_schema.attribute_type}
|
||||||
values={values}
|
values={values}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -176,7 +177,7 @@ fn get_custom_attribute_input(
|
|||||||
html! {
|
html! {
|
||||||
<SingleAttributeInput
|
<SingleAttributeInput
|
||||||
name={attribute_schema.name.clone()}
|
name={attribute_schema.name.clone()}
|
||||||
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
attribute_type={attribute_schema.attribute_type}
|
||||||
value={values.first().cloned().unwrap_or_default()}
|
value={values.first().cloned().unwrap_or_default()}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -192,9 +193,19 @@ fn get_custom_attribute_static(
|
|||||||
.find(|a| a.name == attribute_schema.name)
|
.find(|a| a.name == attribute_schema.name)
|
||||||
.map(|attribute| attribute.value.clone())
|
.map(|attribute| attribute.value.clone())
|
||||||
.unwrap_or_default();
|
.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! {
|
html! {
|
||||||
<StaticValue label={attribute_schema.name.clone()} id={attribute_schema.name.clone()}>
|
<StaticValue label={attribute_schema.name.clone()} id={attribute_schema.name.clone()}>
|
||||||
{values.into_iter().map(|x| html!{<div>{x}</div>}).collect::<Vec<_>>()}
|
{values.into_iter().map(|x| html!{<div>{value_to_str(x)}</div>}).collect::<Vec<_>>()}
|
||||||
</StaticValue>
|
</StaticValue>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ use crate::{
|
|||||||
fragments::attribute_schema::render_attribute_name,
|
fragments::attribute_schema::render_attribute_name,
|
||||||
router::{AppRoute, Link},
|
router::{AppRoute, Link},
|
||||||
},
|
},
|
||||||
convert_attribute_type,
|
|
||||||
infra::{
|
infra::{
|
||||||
attributes::user,
|
attributes::user,
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
@@ -21,7 +20,8 @@ use yew::prelude::*;
|
|||||||
schema_path = "../schema.graphql",
|
schema_path = "../schema.graphql",
|
||||||
query_path = "queries/get_user_attributes_schema.graphql",
|
query_path = "queries/get_user_attributes_schema.graphql",
|
||||||
response_derives = "Debug,Clone,PartialEq,Eq",
|
response_derives = "Debug,Clone,PartialEq,Eq",
|
||||||
custom_scalars_module = "crate::infra::graphql"
|
custom_scalars_module = "crate::infra::graphql",
|
||||||
|
extern_enums("AttributeType")
|
||||||
)]
|
)]
|
||||||
pub struct GetUserAttributesSchema;
|
pub struct GetUserAttributesSchema;
|
||||||
|
|
||||||
@@ -29,8 +29,6 @@ use get_user_attributes_schema::ResponseData;
|
|||||||
|
|
||||||
pub type Attribute = get_user_attributes_schema::GetUserAttributesSchemaSchemaUserSchemaAttributes;
|
pub type Attribute = get_user_attributes_schema::GetUserAttributesSchemaSchemaUserSchemaAttributes;
|
||||||
|
|
||||||
convert_attribute_type!(get_user_attributes_schema::AttributeType);
|
|
||||||
|
|
||||||
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
||||||
pub struct Props {
|
pub struct Props {
|
||||||
pub hardcoded: bool,
|
pub hardcoded: bool,
|
||||||
@@ -146,7 +144,7 @@ impl UserSchemaTable {
|
|||||||
|
|
||||||
fn view_attribute(&self, ctx: &Context<Self>, attribute: &Attribute) -> Html {
|
fn view_attribute(&self, ctx: &Context<Self>, attribute: &Attribute) -> Html {
|
||||||
let link = ctx.link();
|
let link = ctx.link();
|
||||||
let attribute_type = AttributeType::from(attribute.attribute_type.clone());
|
let attribute_type = attribute.attribute_type;
|
||||||
let checkmark = html! {
|
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">
|
<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>
|
<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,12 +8,17 @@ pub mod group {
|
|||||||
|
|
||||||
use super::AttributeDescription;
|
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 {
|
match name {
|
||||||
"creation_date" => Some(AttributeDescription {
|
"creation_date" => Some(AttributeDescription {
|
||||||
attribute_identifier: name,
|
attribute_identifier: name,
|
||||||
attribute_name: "creationdate",
|
attribute_name: "creationdate",
|
||||||
aliases: vec![name, "createtimestamp", "modifytimestamp"],
|
aliases: vec![name, "createtimestamp"],
|
||||||
|
}),
|
||||||
|
"modified_date" => Some(AttributeDescription {
|
||||||
|
attribute_identifier: name,
|
||||||
|
attribute_name: "modifydate",
|
||||||
|
aliases: vec![name, "modifytimestamp"],
|
||||||
}),
|
}),
|
||||||
"display_name" => Some(AttributeDescription {
|
"display_name" => Some(AttributeDescription {
|
||||||
attribute_identifier: name,
|
attribute_identifier: name,
|
||||||
@@ -34,7 +39,9 @@ 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) {
|
match resolve_group_attribute_description(name) {
|
||||||
Some(d) => d,
|
Some(d) => d,
|
||||||
None => AttributeDescription {
|
None => AttributeDescription {
|
||||||
@@ -50,7 +57,7 @@ pub mod user {
|
|||||||
|
|
||||||
use super::AttributeDescription;
|
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 {
|
match name {
|
||||||
"avatar" => Some(AttributeDescription {
|
"avatar" => Some(AttributeDescription {
|
||||||
attribute_identifier: name,
|
attribute_identifier: name,
|
||||||
@@ -60,7 +67,17 @@ pub mod user {
|
|||||||
"creation_date" => Some(AttributeDescription {
|
"creation_date" => Some(AttributeDescription {
|
||||||
attribute_identifier: name,
|
attribute_identifier: name,
|
||||||
attribute_name: "creationdate",
|
attribute_name: "creationdate",
|
||||||
aliases: vec![name, "createtimestamp", "modifytimestamp"],
|
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"],
|
||||||
}),
|
}),
|
||||||
"display_name" => Some(AttributeDescription {
|
"display_name" => Some(AttributeDescription {
|
||||||
attribute_identifier: name,
|
attribute_identifier: name,
|
||||||
@@ -96,7 +113,9 @@ 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) {
|
match resolve_user_attribute_description(name) {
|
||||||
Some(d) => d,
|
Some(d) => d,
|
||||||
None => AttributeDescription {
|
None => AttributeDescription {
|
||||||
|
|||||||
+31
-55
@@ -1,66 +1,42 @@
|
|||||||
use anyhow::Result;
|
use derive_more::Display;
|
||||||
use std::{fmt::Display, str::FromStr};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use strum::EnumString;
|
||||||
use validator::ValidationError;
|
use validator::ValidationError;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq, Hash, EnumString, Display)]
|
||||||
pub enum AttributeType {
|
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||||
|
#[strum(ascii_case_insensitive)]
|
||||||
|
pub(crate) enum AttributeType {
|
||||||
String,
|
String,
|
||||||
Integer,
|
Integer,
|
||||||
|
#[strum(serialize = "DATE_TIME", serialize = "DATETIME")]
|
||||||
DateTime,
|
DateTime,
|
||||||
Jpeg,
|
#[strum(serialize = "JPEG_PHOTO", serialize = "JPEGPHOTO")]
|
||||||
}
|
JpegPhoto,
|
||||||
|
|
||||||
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> {
|
pub fn validate_attribute_type(attribute_type: &str) -> Result<(), ValidationError> {
|
||||||
AttributeType::from_str(attribute_type)
|
AttributeType::try_from(attribute_type)
|
||||||
.map_err(|_| ValidationError::new("Invalid attribute type"))?;
|
.map_err(|_| ValidationError::new("Invalid attribute type"))?;
|
||||||
Ok(())
|
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,6 +2,7 @@
|
|||||||
#![forbid(non_ascii_idents)]
|
#![forbid(non_ascii_idents)]
|
||||||
#![allow(clippy::uninlined_format_args)]
|
#![allow(clippy::uninlined_format_args)]
|
||||||
#![allow(clippy::let_unit_value)]
|
#![allow(clippy::let_unit_value)]
|
||||||
|
#![allow(clippy::unnecessary_operation)] // Doesn't work well with the html macro.
|
||||||
|
|
||||||
pub mod components;
|
pub mod components;
|
||||||
pub mod infra;
|
pub mod infra;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ edition.workspace = true
|
|||||||
homepage.workspace = true
|
homepage.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tracing = "*"
|
tracing = "*"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ authors.workspace = true
|
|||||||
homepage.workspace = true
|
homepage.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["opaque_server", "opaque_client"]
|
default = ["opaque_server", "opaque_client"]
|
||||||
@@ -24,6 +25,7 @@ generic-array = "0.14"
|
|||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
sha2 = "0.9"
|
sha2 = "0.9"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
|
uuid = { version = "1.18.1", features = ["serde"] }
|
||||||
|
|
||||||
[dependencies.derive_more]
|
[dependencies.derive_more]
|
||||||
features = ["debug", "display"]
|
features = ["debug", "display"]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use chrono::prelude::*;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub mod access_control;
|
pub mod access_control;
|
||||||
pub mod opaque;
|
pub mod opaque;
|
||||||
@@ -208,8 +209,11 @@ pub mod types {
|
|||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
pub struct JWTClaims {
|
pub struct JWTClaims {
|
||||||
|
#[serde(with = "chrono::serde::ts_seconds")]
|
||||||
pub exp: DateTime<Utc>,
|
pub exp: DateTime<Utc>,
|
||||||
|
#[serde(with = "chrono::serde::ts_seconds")]
|
||||||
pub iat: DateTime<Utc>,
|
pub iat: DateTime<Utc>,
|
||||||
|
pub jti: Uuid,
|
||||||
pub user: String,
|
pub user: String,
|
||||||
pub groups: HashSet<String>,
|
pub groups: HashSet<String>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ authors.workspace = true
|
|||||||
homepage.workspace = true
|
homepage.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
test = []
|
test = []
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ authors.workspace = true
|
|||||||
homepage.workspace = true
|
homepage.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
test = []
|
test = []
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pub struct Model {
|
|||||||
pub lowercase_display_name: String,
|
pub lowercase_display_name: String,
|
||||||
pub creation_date: chrono::NaiveDateTime,
|
pub creation_date: chrono::NaiveDateTime,
|
||||||
pub uuid: Uuid,
|
pub uuid: Uuid,
|
||||||
|
pub modified_date: chrono::NaiveDateTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
@@ -39,6 +40,7 @@ impl From<Model> for lldap_domain::types::Group {
|
|||||||
uuid: group.uuid,
|
uuid: group.uuid,
|
||||||
users: vec![],
|
users: vec![],
|
||||||
attributes: Vec::new(),
|
attributes: Vec::new(),
|
||||||
|
modified_date: group.modified_date,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,6 +53,7 @@ impl From<Model> for lldap_domain::types::GroupDetails {
|
|||||||
creation_date: group.creation_date,
|
creation_date: group.creation_date,
|
||||||
uuid: group.uuid,
|
uuid: group.uuid,
|
||||||
attributes: Vec::new(),
|
attributes: Vec::new(),
|
||||||
|
modified_date: group.modified_date,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ pub struct Model {
|
|||||||
pub totp_secret: Option<String>,
|
pub totp_secret: Option<String>,
|
||||||
pub mfa_type: Option<String>,
|
pub mfa_type: Option<String>,
|
||||||
pub uuid: Uuid,
|
pub uuid: Uuid,
|
||||||
|
pub modified_date: chrono::NaiveDateTime,
|
||||||
|
pub password_modified_date: chrono::NaiveDateTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EntityName for Entity {
|
impl EntityName for Entity {
|
||||||
@@ -40,6 +42,8 @@ pub enum Column {
|
|||||||
TotpSecret,
|
TotpSecret,
|
||||||
MfaType,
|
MfaType,
|
||||||
Uuid,
|
Uuid,
|
||||||
|
ModifiedDate,
|
||||||
|
PasswordModifiedDate,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ColumnTrait for Column {
|
impl ColumnTrait for Column {
|
||||||
@@ -56,6 +60,8 @@ impl ColumnTrait for Column {
|
|||||||
Column::TotpSecret => ColumnType::String(StringLen::N(64)),
|
Column::TotpSecret => ColumnType::String(StringLen::N(64)),
|
||||||
Column::MfaType => ColumnType::String(StringLen::N(64)),
|
Column::MfaType => ColumnType::String(StringLen::N(64)),
|
||||||
Column::Uuid => ColumnType::String(StringLen::N(36)),
|
Column::Uuid => ColumnType::String(StringLen::N(36)),
|
||||||
|
Column::ModifiedDate => ColumnType::DateTime,
|
||||||
|
Column::PasswordModifiedDate => ColumnType::DateTime,
|
||||||
}
|
}
|
||||||
.def()
|
.def()
|
||||||
}
|
}
|
||||||
@@ -121,6 +127,8 @@ impl From<Model> for lldap_domain::types::User {
|
|||||||
creation_date: user.creation_date,
|
creation_date: user.creation_date,
|
||||||
uuid: user.uuid,
|
uuid: user.uuid,
|
||||||
attributes: Vec::new(),
|
attributes: Vec::new(),
|
||||||
|
modified_date: user.modified_date,
|
||||||
|
password_modified_date: user.password_modified_date,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ edition.workspace = true
|
|||||||
homepage.workspace = true
|
homepage.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
test = []
|
test = []
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ pub fn deserialize_attribute_value(
|
|||||||
let parse_int = |value: &String| -> Result<i64> {
|
let parse_int = |value: &String| -> Result<i64> {
|
||||||
value
|
value
|
||||||
.parse::<i64>()
|
.parse::<i64>()
|
||||||
.with_context(|| format!("Invalid integer value {}", value))
|
.with_context(|| format!("Invalid integer value {value}"))
|
||||||
};
|
};
|
||||||
let parse_date = |value: &String| -> Result<chrono::NaiveDateTime> {
|
let parse_date = |value: &String| -> Result<chrono::NaiveDateTime> {
|
||||||
Ok(chrono::DateTime::parse_from_rfc3339(value)
|
Ok(chrono::DateTime::parse_from_rfc3339(value)
|
||||||
.with_context(|| format!("Invalid date value {}", value))?
|
.with_context(|| format!("Invalid date value {value}"))?
|
||||||
.naive_utc())
|
.naive_utc())
|
||||||
};
|
};
|
||||||
let parse_photo = |value: &String| -> Result<JpegPhoto> {
|
let parse_photo = |value: &String| -> Result<JpegPhoto> {
|
||||||
|
|||||||
@@ -34,6 +34,24 @@ impl From<Schema> for PublicSchema {
|
|||||||
is_hardcoded: true,
|
is_hardcoded: true,
|
||||||
is_readonly: 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 {
|
AttributeSchema {
|
||||||
name: "mail".into(),
|
name: "mail".into(),
|
||||||
attribute_type: AttributeType::String,
|
attribute_type: AttributeType::String,
|
||||||
@@ -85,6 +103,15 @@ impl From<Schema> for PublicSchema {
|
|||||||
is_hardcoded: true,
|
is_hardcoded: true,
|
||||||
is_readonly: 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 {
|
AttributeSchema {
|
||||||
name: "uuid".into(),
|
name: "uuid".into(),
|
||||||
attribute_type: AttributeType::String,
|
attribute_type: AttributeType::String,
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ use sea_orm::{
|
|||||||
DbErr, DeriveValueType, QueryResult, TryFromU64, TryGetError, TryGetable, Value,
|
DbErr, DeriveValueType, QueryResult, TryFromU64, TryGetError, TryGetable, Value,
|
||||||
entity::IntoActiveValue,
|
entity::IntoActiveValue,
|
||||||
sea_query::{
|
sea_query::{
|
||||||
ArrayType, ColumnType, Nullable, SeaRc, StringLen, ValueTypeErr,
|
ArrayType, ColumnType, SeaRc, StringLen, ValueTypeErr, extension::mysql::MySqlType,
|
||||||
extension::mysql::MySqlType, value::ValueType,
|
value::ValueType,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -377,7 +377,7 @@ impl std::fmt::Debug for JpegPhoto {
|
|||||||
encoded.push_str(" ...");
|
encoded.push_str(" ...");
|
||||||
};
|
};
|
||||||
f.debug_tuple("JpegPhoto")
|
f.debug_tuple("JpegPhoto")
|
||||||
.field(&format!("b64[{}]", encoded))
|
.field(&format!("b64[{encoded}]"))
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -415,12 +415,6 @@ impl JpegPhoto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Nullable for JpegPhoto {
|
|
||||||
fn null() -> Value {
|
|
||||||
JpegPhoto::null().into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IntoActiveValue<Serialized> for JpegPhoto {
|
impl IntoActiveValue<Serialized> for JpegPhoto {
|
||||||
fn into_active_value(self) -> sea_orm::ActiveValue<Serialized> {
|
fn into_active_value(self) -> sea_orm::ActiveValue<Serialized> {
|
||||||
if self.is_empty() {
|
if self.is_empty() {
|
||||||
@@ -546,6 +540,8 @@ pub struct User {
|
|||||||
pub creation_date: NaiveDateTime,
|
pub creation_date: NaiveDateTime,
|
||||||
pub uuid: Uuid,
|
pub uuid: Uuid,
|
||||||
pub attributes: Vec<Attribute>,
|
pub attributes: Vec<Attribute>,
|
||||||
|
pub modified_date: NaiveDateTime,
|
||||||
|
pub password_modified_date: NaiveDateTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "test")]
|
#[cfg(feature = "test")]
|
||||||
@@ -559,6 +555,8 @@ impl Default for User {
|
|||||||
creation_date: epoch,
|
creation_date: epoch,
|
||||||
uuid: Uuid::from_name_and_date("", &epoch),
|
uuid: Uuid::from_name_and_date("", &epoch),
|
||||||
attributes: Vec::new(),
|
attributes: Vec::new(),
|
||||||
|
modified_date: epoch,
|
||||||
|
password_modified_date: epoch,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -654,6 +652,7 @@ pub struct Group {
|
|||||||
pub uuid: Uuid,
|
pub uuid: Uuid,
|
||||||
pub users: Vec<UserId>,
|
pub users: Vec<UserId>,
|
||||||
pub attributes: Vec<Attribute>,
|
pub attributes: Vec<Attribute>,
|
||||||
|
pub modified_date: NaiveDateTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
@@ -663,6 +662,7 @@ pub struct GroupDetails {
|
|||||||
pub creation_date: NaiveDateTime,
|
pub creation_date: NaiveDateTime,
|
||||||
pub uuid: Uuid,
|
pub uuid: Uuid,
|
||||||
pub attributes: Vec<Attribute>,
|
pub attributes: Vec<Attribute>,
|
||||||
|
pub modified_date: NaiveDateTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ edition.workspace = true
|
|||||||
homepage.workspace = true
|
homepage.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
[dependencies.serde]
|
[dependencies.serde]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ authors.workspace = true
|
|||||||
homepage.workspace = true
|
homepage.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "*"
|
anyhow = "*"
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ pub fn export_schema(output_file: Option<String>) -> anyhow::Result<()> {
|
|||||||
use lldap_sql_backend_handler::SqlBackendHandler;
|
use lldap_sql_backend_handler::SqlBackendHandler;
|
||||||
let output = schema::<SqlBackendHandler>().as_schema_language();
|
let output = schema::<SqlBackendHandler>().as_schema_language();
|
||||||
match output_file {
|
match output_file {
|
||||||
None => println!("{}", output),
|
None => println!("{output}"),
|
||||||
Some(path) => {
|
Some(path) => {
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::prelude::*;
|
use std::io::prelude::*;
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-254
@@ -1,27 +1,30 @@
|
|||||||
|
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 crate::api::{Context, field_error_callback};
|
||||||
use anyhow::{Context as AnyhowContext, anyhow};
|
use anyhow::anyhow;
|
||||||
use juniper::{FieldError, FieldResult, GraphQLInputObject, GraphQLObject, graphql_object};
|
use juniper::{FieldError, FieldResult, graphql_object};
|
||||||
use lldap_access_control::{
|
use lldap_access_control::{
|
||||||
AdminBackendHandler, ReadonlyBackendHandler, UserReadableBackendHandler,
|
AdminBackendHandler, UserReadableBackendHandler, UserWriteableBackendHandler,
|
||||||
UserWriteableBackendHandler,
|
|
||||||
};
|
};
|
||||||
use lldap_domain::{
|
use lldap_domain::{
|
||||||
deserialize::deserialize_attribute_value,
|
requests::{CreateAttributeRequest, CreateUserRequest, UpdateGroupRequest, UpdateUserRequest},
|
||||||
public_schema::PublicSchema,
|
types::{AttributeName, AttributeType, Email, GroupId, LdapObjectClass, UserId},
|
||||||
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_domain_handlers::handler::BackendHandler;
|
||||||
use lldap_validation::attributes::{ALLOWED_CHARACTERS_DESCRIPTION, validate_attribute_name};
|
use lldap_validation::attributes::{ALLOWED_CHARACTERS_DESCRIPTION, validate_attribute_name};
|
||||||
use std::{collections::BTreeMap, sync::Arc};
|
use std::sync::Arc;
|
||||||
use tracing::{Instrument, Span, debug, debug_span};
|
use tracing::{Instrument, debug, debug_span};
|
||||||
|
|
||||||
|
use helpers::{
|
||||||
|
UnpackedAttributes, consolidate_attributes, create_group_with_details, deserialize_attribute,
|
||||||
|
unpack_attributes,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(PartialEq, Eq, Debug)]
|
#[derive(PartialEq, Eq, Debug)]
|
||||||
/// The top-level GraphQL mutation type.
|
/// The top-level GraphQL mutation type.
|
||||||
@@ -42,183 +45,6 @@ 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>)]
|
#[graphql_object(context = Context<Handler>)]
|
||||||
impl<Handler: BackendHandler> Mutation<Handler> {
|
impl<Handler: BackendHandler> Mutation<Handler> {
|
||||||
async fn create_user(
|
async fn create_user(
|
||||||
@@ -721,66 +547,6 @@ impl<Handler: BackendHandler> Mutation<Handler> {
|
|||||||
Ok(Success::new())
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,267 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
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,6 +7,7 @@ edition.workspace = true
|
|||||||
homepage.workspace = true
|
homepage.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "*"
|
anyhow = "*"
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ mod tests {
|
|||||||
users: vec![UserId::new("bob")],
|
users: vec![UserId::new("bob")],
|
||||||
uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"),
|
uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"),
|
||||||
attributes: Vec::new(),
|
attributes: Vec::new(),
|
||||||
|
modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
||||||
}])
|
}])
|
||||||
});
|
});
|
||||||
let ldap_handler = setup_bound_admin_handler(mock).await;
|
let ldap_handler = setup_bound_admin_handler(mock).await;
|
||||||
@@ -218,6 +219,7 @@ mod tests {
|
|||||||
users: vec![UserId::new("bob")],
|
users: vec![UserId::new("bob")],
|
||||||
uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"),
|
uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"),
|
||||||
attributes: Vec::new(),
|
attributes: Vec::new(),
|
||||||
|
modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
||||||
}])
|
}])
|
||||||
});
|
});
|
||||||
let ldap_handler = setup_bound_admin_handler(mock).await;
|
let ldap_handler = setup_bound_admin_handler(mock).await;
|
||||||
|
|||||||
+368
-24
@@ -72,11 +72,17 @@ pub fn get_group_attribute(
|
|||||||
.to_rfc3339()
|
.to_rfc3339()
|
||||||
.into_bytes(),
|
.into_bytes(),
|
||||||
],
|
],
|
||||||
|
GroupFieldType::ModifiedDate => vec![
|
||||||
|
chrono::Utc
|
||||||
|
.from_utc_datetime(&group.modified_date)
|
||||||
|
.to_rfc3339()
|
||||||
|
.into_bytes(),
|
||||||
|
],
|
||||||
GroupFieldType::Member => group
|
GroupFieldType::Member => group
|
||||||
.users
|
.users
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|u| user_filter.as_ref().map(|f| *u == f).unwrap_or(true))
|
.filter(|u| user_filter.as_ref().map(|f| *u == f).unwrap_or(true))
|
||||||
.map(|u| format!("uid={},ou=people,{}", u, base_dn_str).into_bytes())
|
.map(|u| format!("uid={u},ou=people,{base_dn_str}").into_bytes())
|
||||||
.collect(),
|
.collect(),
|
||||||
GroupFieldType::Uuid => vec![group.uuid.to_string().into_bytes()],
|
GroupFieldType::Uuid => vec![group.uuid.to_string().into_bytes()],
|
||||||
GroupFieldType::Attribute(attr, _, _) => get_custom_attribute(&group.attributes, &attr)?,
|
GroupFieldType::Attribute(attr, _, _) => get_custom_attribute(&group.attributes, &attr)?,
|
||||||
@@ -86,8 +92,7 @@ pub fn get_group_attribute(
|
|||||||
"+" => return None,
|
"+" => return None,
|
||||||
"*" => {
|
"*" => {
|
||||||
panic!(
|
panic!(
|
||||||
"Matched {}, * should have been expanded into attribute list and * removed",
|
"Matched {attribute}, * should have been expanded into attribute list and * removed"
|
||||||
attribute
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
@@ -179,11 +184,11 @@ fn get_group_attribute_equality_filter(
|
|||||||
]),
|
]),
|
||||||
(Ok(_), Err(e)) => {
|
(Ok(_), Err(e)) => {
|
||||||
warn!("Invalid value for attribute {} (lowercased): {}", field, e);
|
warn!("Invalid value for attribute {} (lowercased): {}", field, e);
|
||||||
GroupRequestFilter::from(false)
|
GroupRequestFilter::False
|
||||||
}
|
}
|
||||||
(Err(e), _) => {
|
(Err(e), _) => {
|
||||||
warn!("Invalid value for attribute {}: {}", field, e);
|
warn!("Invalid value for attribute {}: {}", field, e);
|
||||||
GroupRequestFilter::from(false)
|
GroupRequestFilter::False
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,14 +209,14 @@ fn convert_group_filter(
|
|||||||
.map(|id| GroupRequestFilter::GroupId(GroupId(id)))
|
.map(|id| GroupRequestFilter::GroupId(GroupId(id)))
|
||||||
.unwrap_or_else(|_| {
|
.unwrap_or_else(|_| {
|
||||||
warn!("Given group id is not a valid integer: {}", value_lc);
|
warn!("Given group id is not a valid integer: {}", value_lc);
|
||||||
GroupRequestFilter::from(false)
|
GroupRequestFilter::False
|
||||||
})),
|
})),
|
||||||
GroupFieldType::DisplayName => Ok(GroupRequestFilter::DisplayName(value_lc.into())),
|
GroupFieldType::DisplayName => Ok(GroupRequestFilter::DisplayName(value_lc.into())),
|
||||||
GroupFieldType::Uuid => Uuid::try_from(value_lc.as_str())
|
GroupFieldType::Uuid => Uuid::try_from(value_lc.as_str())
|
||||||
.map(GroupRequestFilter::Uuid)
|
.map(GroupRequestFilter::Uuid)
|
||||||
.map_err(|e| LdapError {
|
.map_err(|e| LdapError {
|
||||||
code: LdapResultCode::Other,
|
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(
|
GroupFieldType::Member => Ok(get_user_id_from_distinguished_name_or_plain_name(
|
||||||
&value_lc,
|
&value_lc,
|
||||||
@@ -221,7 +226,7 @@ fn convert_group_filter(
|
|||||||
.map(GroupRequestFilter::Member)
|
.map(GroupRequestFilter::Member)
|
||||||
.unwrap_or_else(|e| {
|
.unwrap_or_else(|e| {
|
||||||
warn!("Invalid member filter on group: {}", e);
|
warn!("Invalid member filter on group: {}", e);
|
||||||
GroupRequestFilter::from(false)
|
GroupRequestFilter::False
|
||||||
})),
|
})),
|
||||||
GroupFieldType::ObjectClass => Ok(GroupRequestFilter::from(
|
GroupFieldType::ObjectClass => Ok(GroupRequestFilter::from(
|
||||||
get_default_group_object_classes()
|
get_default_group_object_classes()
|
||||||
@@ -241,7 +246,7 @@ fn convert_group_filter(
|
|||||||
.map(GroupRequestFilter::DisplayName)
|
.map(GroupRequestFilter::DisplayName)
|
||||||
.unwrap_or_else(|_| {
|
.unwrap_or_else(|_| {
|
||||||
warn!("Invalid dn filter on group: {}", value_lc);
|
warn!("Invalid dn filter on group: {}", value_lc);
|
||||||
GroupRequestFilter::from(false)
|
GroupRequestFilter::False
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
GroupFieldType::NoMatch => {
|
GroupFieldType::NoMatch => {
|
||||||
@@ -252,7 +257,7 @@ fn convert_group_filter(
|
|||||||
field
|
field
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Ok(GroupRequestFilter::from(false))
|
Ok(GroupRequestFilter::False)
|
||||||
}
|
}
|
||||||
GroupFieldType::Attribute(field, typ, is_list) => Ok(
|
GroupFieldType::Attribute(field, typ, is_list) => Ok(
|
||||||
get_group_attribute_equality_filter(&field, typ, is_list, value),
|
get_group_attribute_equality_filter(&field, typ, is_list, value),
|
||||||
@@ -261,23 +266,61 @@ fn convert_group_filter(
|
|||||||
code: LdapResultCode::UnwillingToPerform,
|
code: LdapResultCode::UnwillingToPerform,
|
||||||
message: "Creation date filter for groups not supported".to_owned(),
|
message: "Creation date filter for groups not supported".to_owned(),
|
||||||
}),
|
}),
|
||||||
|
GroupFieldType::ModifiedDate => Err(LdapError {
|
||||||
|
code: LdapResultCode::UnwillingToPerform,
|
||||||
|
message: "Modified date filter for groups not supported".to_owned(),
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LdapFilter::And(filters) => Ok(GroupRequestFilter::And(
|
LdapFilter::And(filters) => {
|
||||||
filters.iter().map(rec).collect::<LdapResult<_>>()?,
|
let res = filters
|
||||||
)),
|
.iter()
|
||||||
LdapFilter::Or(filters) => Ok(GroupRequestFilter::Or(
|
.map(rec)
|
||||||
filters.iter().map(rec).collect::<LdapResult<_>>()?,
|
.filter(|f| !matches!(f, Ok(GroupRequestFilter::True)))
|
||||||
)),
|
.flat_map(|f| match f {
|
||||||
LdapFilter::Not(filter) => Ok(GroupRequestFilter::Not(Box::new(rec(filter)?))),
|
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::Present(field) => {
|
LdapFilter::Present(field) => {
|
||||||
let field = AttributeName::from(field.as_str());
|
let field = AttributeName::from(field.as_str());
|
||||||
Ok(match map_group_field(&field, schema) {
|
Ok(match map_group_field(&field, schema) {
|
||||||
GroupFieldType::Attribute(name, _, _) => {
|
GroupFieldType::Attribute(name, _, _) => {
|
||||||
GroupRequestFilter::CustomAttributePresent(name)
|
GroupRequestFilter::CustomAttributePresent(name)
|
||||||
}
|
}
|
||||||
GroupFieldType::NoMatch => GroupRequestFilter::from(false),
|
GroupFieldType::NoMatch => GroupRequestFilter::False,
|
||||||
_ => GroupRequestFilter::from(true),
|
_ => GroupRequestFilter::True,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
LdapFilter::Substring(field, substring_filter) => {
|
LdapFilter::Substring(field, substring_filter) => {
|
||||||
@@ -286,19 +329,18 @@ fn convert_group_filter(
|
|||||||
GroupFieldType::DisplayName => Ok(GroupRequestFilter::DisplayNameSubString(
|
GroupFieldType::DisplayName => Ok(GroupRequestFilter::DisplayNameSubString(
|
||||||
substring_filter.clone().into(),
|
substring_filter.clone().into(),
|
||||||
)),
|
)),
|
||||||
GroupFieldType::NoMatch => Ok(GroupRequestFilter::from(false)),
|
GroupFieldType::NoMatch => Ok(GroupRequestFilter::False),
|
||||||
_ => Err(LdapError {
|
_ => Err(LdapError {
|
||||||
code: LdapResultCode::UnwillingToPerform,
|
code: LdapResultCode::UnwillingToPerform,
|
||||||
message: format!(
|
message: format!(
|
||||||
"Unsupported group attribute for substring filter: \"{}\"",
|
"Unsupported group attribute for substring filter: \"{field}\""
|
||||||
field
|
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => Err(LdapError {
|
_ => Err(LdapError {
|
||||||
code: LdapResultCode::UnwillingToPerform,
|
code: LdapResultCode::UnwillingToPerform,
|
||||||
message: format!("Unsupported group filter: {:?}", filter),
|
message: format!("Unsupported group filter: {filter:?}"),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -318,7 +360,7 @@ pub async fn get_groups_list<Backend: GroupListerBackendHandler>(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| LdapError {
|
.map_err(|e| LdapError {
|
||||||
code: LdapResultCode::Other,
|
code: LdapResultCode::Other,
|
||||||
message: format!(r#"Error while listing groups "{}": {:#}"#, base, e),
|
message: format!(r#"Error while listing groups "{base}": {e:#}"#),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,3 +388,305 @@ 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()]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+493
-31
@@ -3,10 +3,10 @@ use crate::core::{
|
|||||||
utils::{
|
utils::{
|
||||||
ExpandedAttributes, LdapInfo, UserFieldType, expand_attribute_wildcards,
|
ExpandedAttributes, LdapInfo, UserFieldType, expand_attribute_wildcards,
|
||||||
get_custom_attribute, get_group_id_from_distinguished_name_or_plain_name,
|
get_custom_attribute, get_group_id_from_distinguished_name_or_plain_name,
|
||||||
get_user_id_from_distinguished_name_or_plain_name, map_user_field,
|
get_user_id_from_distinguished_name_or_plain_name, map_user_field, to_generalized_time,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use chrono::TimeZone;
|
|
||||||
use ldap3_proto::{
|
use ldap3_proto::{
|
||||||
LdapFilter, LdapPartialAttribute, LdapResultCode, LdapSearchResultEntry, proto::LdapOp,
|
LdapFilter, LdapPartialAttribute, LdapResultCode, LdapSearchResultEntry, proto::LdapOp,
|
||||||
};
|
};
|
||||||
@@ -87,12 +87,15 @@ pub fn get_user_attribute(
|
|||||||
UserFieldType::PrimaryField(UserColumn::DisplayName) => {
|
UserFieldType::PrimaryField(UserColumn::DisplayName) => {
|
||||||
vec![user.display_name.clone()?.into_bytes()]
|
vec![user.display_name.clone()?.into_bytes()]
|
||||||
}
|
}
|
||||||
UserFieldType::PrimaryField(UserColumn::CreationDate) => vec![
|
UserFieldType::PrimaryField(UserColumn::CreationDate) => {
|
||||||
chrono::Utc
|
vec![to_generalized_time(&user.creation_date)]
|
||||||
.from_utc_datetime(&user.creation_date)
|
}
|
||||||
.to_rfc3339()
|
UserFieldType::PrimaryField(UserColumn::ModifiedDate) => {
|
||||||
.into_bytes(),
|
vec![to_generalized_time(&user.modified_date)]
|
||||||
],
|
}
|
||||||
|
UserFieldType::PrimaryField(UserColumn::PasswordModifiedDate) => {
|
||||||
|
vec![to_generalized_time(&user.password_modified_date)]
|
||||||
|
}
|
||||||
UserFieldType::Attribute(attr, _, _) => get_custom_attribute(&user.attributes, &attr)?,
|
UserFieldType::Attribute(attr, _, _) => get_custom_attribute(&user.attributes, &attr)?,
|
||||||
UserFieldType::NoMatch => match attribute.as_str() {
|
UserFieldType::NoMatch => match attribute.as_str() {
|
||||||
"1.1" => return None,
|
"1.1" => return None,
|
||||||
@@ -100,8 +103,7 @@ pub fn get_user_attribute(
|
|||||||
"+" => return None,
|
"+" => return None,
|
||||||
"*" => {
|
"*" => {
|
||||||
panic!(
|
panic!(
|
||||||
"Matched {}, * should have been expanded into attribute list and * removed",
|
"Matched {attribute}, * should have been expanded into attribute list and * removed"
|
||||||
attribute
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
@@ -191,11 +193,11 @@ fn get_user_attribute_equality_filter(
|
|||||||
]),
|
]),
|
||||||
(Ok(_), Err(e)) => {
|
(Ok(_), Err(e)) => {
|
||||||
warn!("Invalid value for attribute {} (lowercased): {}", field, e);
|
warn!("Invalid value for attribute {} (lowercased): {}", field, e);
|
||||||
UserRequestFilter::from(false)
|
UserRequestFilter::False
|
||||||
}
|
}
|
||||||
(Err(e), _) => {
|
(Err(e), _) => {
|
||||||
warn!("Invalid value for attribute {}: {}", field, e);
|
warn!("Invalid value for attribute {}: {}", field, e);
|
||||||
UserRequestFilter::from(false)
|
UserRequestFilter::False
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,13 +209,47 @@ fn convert_user_filter(
|
|||||||
) -> LdapResult<UserRequestFilter> {
|
) -> LdapResult<UserRequestFilter> {
|
||||||
let rec = |f| convert_user_filter(ldap_info, f, schema);
|
let rec = |f| convert_user_filter(ldap_info, f, schema);
|
||||||
match filter {
|
match filter {
|
||||||
LdapFilter::And(filters) => Ok(UserRequestFilter::And(
|
LdapFilter::And(filters) => {
|
||||||
filters.iter().map(rec).collect::<LdapResult<_>>()?,
|
let res = filters
|
||||||
)),
|
.iter()
|
||||||
LdapFilter::Or(filters) => Ok(UserRequestFilter::Or(
|
.map(rec)
|
||||||
filters.iter().map(rec).collect::<LdapResult<_>>()?,
|
.filter(|c| !matches!(c, Ok(UserRequestFilter::True)))
|
||||||
)),
|
.flat_map(|f| match f {
|
||||||
LdapFilter::Not(filter) => Ok(UserRequestFilter::Not(Box::new(rec(filter)?))),
|
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::Equality(field, value) => {
|
LdapFilter::Equality(field, value) => {
|
||||||
let field = AttributeName::from(field.as_str());
|
let field = AttributeName::from(field.as_str());
|
||||||
let value_lc = value.to_ascii_lowercase();
|
let value_lc = value.to_ascii_lowercase();
|
||||||
@@ -225,6 +261,21 @@ fn convert_user_filter(
|
|||||||
UserColumn::LowercaseEmail,
|
UserColumn::LowercaseEmail,
|
||||||
value_lc,
|
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) => {
|
UserFieldType::PrimaryField(field) => {
|
||||||
Ok(UserRequestFilter::Equality(field, value_lc))
|
Ok(UserRequestFilter::Equality(field, value_lc))
|
||||||
}
|
}
|
||||||
@@ -239,7 +290,7 @@ fn convert_user_filter(
|
|||||||
field
|
field
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Ok(UserRequestFilter::from(false))
|
Ok(UserRequestFilter::False)
|
||||||
}
|
}
|
||||||
UserFieldType::ObjectClass => Ok(UserRequestFilter::from(
|
UserFieldType::ObjectClass => Ok(UserRequestFilter::from(
|
||||||
get_default_user_object_classes()
|
get_default_user_object_classes()
|
||||||
@@ -258,7 +309,7 @@ fn convert_user_filter(
|
|||||||
.map(UserRequestFilter::MemberOf)
|
.map(UserRequestFilter::MemberOf)
|
||||||
.unwrap_or_else(|e| {
|
.unwrap_or_else(|e| {
|
||||||
warn!("Invalid memberOf filter: {}", e);
|
warn!("Invalid memberOf filter: {}", e);
|
||||||
UserRequestFilter::from(false)
|
UserRequestFilter::False
|
||||||
})),
|
})),
|
||||||
UserFieldType::EntryDn | UserFieldType::Dn => {
|
UserFieldType::EntryDn | UserFieldType::Dn => {
|
||||||
Ok(get_user_id_from_distinguished_name_or_plain_name(
|
Ok(get_user_id_from_distinguished_name_or_plain_name(
|
||||||
@@ -269,7 +320,7 @@ fn convert_user_filter(
|
|||||||
.map(UserRequestFilter::UserId)
|
.map(UserRequestFilter::UserId)
|
||||||
.unwrap_or_else(|_| {
|
.unwrap_or_else(|_| {
|
||||||
warn!("Invalid dn filter on user: {}", value_lc);
|
warn!("Invalid dn filter on user: {}", value_lc);
|
||||||
UserRequestFilter::from(false)
|
UserRequestFilter::False
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -280,8 +331,8 @@ fn convert_user_filter(
|
|||||||
UserFieldType::Attribute(name, _, _) => {
|
UserFieldType::Attribute(name, _, _) => {
|
||||||
UserRequestFilter::CustomAttributePresent(name)
|
UserRequestFilter::CustomAttributePresent(name)
|
||||||
}
|
}
|
||||||
UserFieldType::NoMatch => UserRequestFilter::from(false),
|
UserFieldType::NoMatch => UserRequestFilter::False,
|
||||||
_ => UserRequestFilter::from(true),
|
_ => UserRequestFilter::True,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
LdapFilter::Substring(field, substring_filter) => {
|
LdapFilter::Substring(field, substring_filter) => {
|
||||||
@@ -298,12 +349,9 @@ fn convert_user_filter(
|
|||||||
| UserFieldType::PrimaryField(UserColumn::CreationDate)
|
| UserFieldType::PrimaryField(UserColumn::CreationDate)
|
||||||
| UserFieldType::PrimaryField(UserColumn::Uuid) => Err(LdapError {
|
| UserFieldType::PrimaryField(UserColumn::Uuid) => Err(LdapError {
|
||||||
code: LdapResultCode::UnwillingToPerform,
|
code: LdapResultCode::UnwillingToPerform,
|
||||||
message: format!(
|
message: format!("Unsupported user attribute for substring filter: {field:?}"),
|
||||||
"Unsupported user attribute for substring filter: {:?}",
|
|
||||||
field
|
|
||||||
),
|
|
||||||
}),
|
}),
|
||||||
UserFieldType::NoMatch => Ok(UserRequestFilter::from(false)),
|
UserFieldType::NoMatch => Ok(UserRequestFilter::False),
|
||||||
UserFieldType::PrimaryField(UserColumn::Email) => Ok(UserRequestFilter::SubString(
|
UserFieldType::PrimaryField(UserColumn::Email) => Ok(UserRequestFilter::SubString(
|
||||||
UserColumn::LowercaseEmail,
|
UserColumn::LowercaseEmail,
|
||||||
substring_filter.clone().into(),
|
substring_filter.clone().into(),
|
||||||
@@ -316,7 +364,7 @@ fn convert_user_filter(
|
|||||||
}
|
}
|
||||||
_ => Err(LdapError {
|
_ => Err(LdapError {
|
||||||
code: LdapResultCode::UnwillingToPerform,
|
code: LdapResultCode::UnwillingToPerform,
|
||||||
message: format!("Unsupported user filter: {:?}", filter),
|
message: format!("Unsupported user filter: {filter:?}"),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -341,7 +389,7 @@ pub async fn get_user_list<Backend: UserListerBackendHandler>(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| LdapError {
|
.map_err(|e| LdapError {
|
||||||
code: LdapResultCode::Other,
|
code: LdapResultCode::Other,
|
||||||
message: format!(r#"Error while searching user "{}": {:#}"#, base, e),
|
message: format!(r#"Error while searching user "{base}": {e:#}"#),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,3 +415,417 @@ 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},
|
group::{REQUIRED_GROUP_ATTRIBUTES, get_default_group_object_classes},
|
||||||
user::{REQUIRED_USER_ATTRIBUTES, get_default_user_object_classes},
|
user::{REQUIRED_USER_ATTRIBUTES, get_default_user_object_classes},
|
||||||
};
|
};
|
||||||
use chrono::TimeZone;
|
use chrono::{NaiveDateTime, TimeZone};
|
||||||
use itertools::join;
|
use itertools::join;
|
||||||
use ldap3_proto::LdapResultCode;
|
use ldap3_proto::LdapResultCode;
|
||||||
use lldap_domain::{
|
use lldap_domain::{
|
||||||
@@ -18,6 +18,16 @@ use lldap_domain_model::model::UserColumn;
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use tracing::{debug, instrument, warn};
|
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)>
|
fn make_dn_pair<I>(mut iter: I) -> LdapResult<(String, String)>
|
||||||
where
|
where
|
||||||
I: Iterator<Item = String>,
|
I: Iterator<Item = String>,
|
||||||
@@ -66,10 +76,9 @@ impl UserOrGroupName {
|
|||||||
UserOrGroupName::InvalidSyntax(err) => return err,
|
UserOrGroupName::InvalidSyntax(err) => return err,
|
||||||
UserOrGroupName::UnexpectedFormat
|
UserOrGroupName::UnexpectedFormat
|
||||||
| UserOrGroupName::User(_)
|
| UserOrGroupName::User(_)
|
||||||
| UserOrGroupName::Group(_) => format!(
|
| UserOrGroupName::Group(_) => {
|
||||||
r#"Unexpected DN format. Got "{}", expected: {}"#,
|
format!(r#"Unexpected DN format. Got "{input}", expected: {expected_format}"#)
|
||||||
input, expected_format
|
}
|
||||||
),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,7 +114,7 @@ pub fn get_user_id_from_distinguished_name(
|
|||||||
) -> LdapResult<UserId> {
|
) -> LdapResult<UserId> {
|
||||||
match get_user_or_group_id_from_distinguished_name(dn, base_tree) {
|
match get_user_or_group_id_from_distinguished_name(dn, base_tree) {
|
||||||
UserOrGroupName::User(user_id) => Ok(user_id),
|
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}""#))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +125,7 @@ pub fn get_group_id_from_distinguished_name(
|
|||||||
) -> LdapResult<GroupName> {
|
) -> LdapResult<GroupName> {
|
||||||
match get_user_or_group_id_from_distinguished_name(dn, base_tree) {
|
match get_user_or_group_id_from_distinguished_name(dn, base_tree) {
|
||||||
UserOrGroupName::Group(group_name) => Ok(group_name),
|
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}""#))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,9 +249,15 @@ pub fn map_user_field(field: &AttributeName, schema: &PublicSchema) -> UserField
|
|||||||
AttributeType::JpegPhoto,
|
AttributeType::JpegPhoto,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
"creationdate" | "createtimestamp" | "modifytimestamp" | "creation_date" => {
|
"creationdate" | "createtimestamp" | "creation_date" => {
|
||||||
UserFieldType::PrimaryField(UserColumn::CreationDate)
|
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),
|
"entryuuid" | "uuid" => UserFieldType::PrimaryField(UserColumn::Uuid),
|
||||||
_ => schema
|
_ => schema
|
||||||
.get_schema()
|
.get_schema()
|
||||||
@@ -258,6 +273,7 @@ pub enum GroupFieldType {
|
|||||||
GroupId,
|
GroupId,
|
||||||
DisplayName,
|
DisplayName,
|
||||||
CreationDate,
|
CreationDate,
|
||||||
|
ModifiedDate,
|
||||||
ObjectClass,
|
ObjectClass,
|
||||||
Dn,
|
Dn,
|
||||||
// Like Dn, but returned as part of the attributes.
|
// Like Dn, but returned as part of the attributes.
|
||||||
@@ -273,9 +289,8 @@ pub fn map_group_field(field: &AttributeName, schema: &PublicSchema) -> GroupFie
|
|||||||
"entrydn" => GroupFieldType::EntryDn,
|
"entrydn" => GroupFieldType::EntryDn,
|
||||||
"objectclass" => GroupFieldType::ObjectClass,
|
"objectclass" => GroupFieldType::ObjectClass,
|
||||||
"cn" | "displayname" | "uid" | "display_name" | "id" => GroupFieldType::DisplayName,
|
"cn" | "displayname" | "uid" | "display_name" | "id" => GroupFieldType::DisplayName,
|
||||||
"creationdate" | "createtimestamp" | "modifytimestamp" | "creation_date" => {
|
"creationdate" | "createtimestamp" | "creation_date" => GroupFieldType::CreationDate,
|
||||||
GroupFieldType::CreationDate
|
"modifytimestamp" | "modifydate" | "modified_date" => GroupFieldType::ModifiedDate,
|
||||||
}
|
|
||||||
"member" | "uniquemember" => GroupFieldType::Member,
|
"member" | "uniquemember" => GroupFieldType::Member,
|
||||||
"entryuuid" | "uuid" => GroupFieldType::Uuid,
|
"entryuuid" | "uuid" => GroupFieldType::Uuid,
|
||||||
"group_id" | "groupid" => GroupFieldType::GroupId,
|
"group_id" | "groupid" => GroupFieldType::GroupId,
|
||||||
@@ -295,16 +310,27 @@ pub struct LdapInfo {
|
|||||||
pub ignored_group_attributes: Vec<AttributeName>,
|
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(
|
pub fn get_custom_attribute(
|
||||||
attributes: &[Attribute],
|
attributes: &[Attribute],
|
||||||
attribute_name: &AttributeName,
|
attribute_name: &AttributeName,
|
||||||
) -> Option<Vec<Vec<u8>>> {
|
) -> Option<Vec<Vec<u8>>> {
|
||||||
let convert_date = |date| {
|
|
||||||
chrono::Utc
|
|
||||||
.from_utc_datetime(date)
|
|
||||||
.to_rfc3339()
|
|
||||||
.into_bytes()
|
|
||||||
};
|
|
||||||
attributes
|
attributes
|
||||||
.iter()
|
.iter()
|
||||||
.find(|a| &a.name == attribute_name)
|
.find(|a| &a.name == attribute_name)
|
||||||
@@ -330,9 +356,9 @@ pub fn get_custom_attribute(
|
|||||||
AttributeValue::JpegPhoto(Cardinality::Unbounded(l)) => {
|
AttributeValue::JpegPhoto(Cardinality::Unbounded(l)) => {
|
||||||
l.iter().map(|p| p.clone().into_bytes()).collect()
|
l.iter().map(|p| p.clone().into_bytes()).collect()
|
||||||
}
|
}
|
||||||
AttributeValue::DateTime(Cardinality::Singleton(dt)) => vec![convert_date(dt)],
|
AttributeValue::DateTime(Cardinality::Singleton(dt)) => vec![to_generalized_time(dt)],
|
||||||
AttributeValue::DateTime(Cardinality::Unbounded(l)) => {
|
AttributeValue::DateTime(Cardinality::Unbounded(l)) => {
|
||||||
l.iter().map(convert_date).collect()
|
l.iter().map(to_generalized_time).collect()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -343,7 +369,7 @@ pub struct ObjectClassList(Vec<LdapObjectClass>);
|
|||||||
// See RFC4512 section 4.2.1 "objectClasses"
|
// See RFC4512 section 4.2.1 "objectClasses"
|
||||||
impl ObjectClassList {
|
impl ObjectClassList {
|
||||||
pub fn format_for_ldap_schema_description(&self) -> String {
|
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}'")), " ")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,13 +463,23 @@ impl LdapSchemaDescription {
|
|||||||
// See RFC4512 section 4.2.2 "attributeTypes"
|
// See RFC4512 section 4.2.2 "attributeTypes"
|
||||||
// Parameter 'index_offset' is an offset for the enumeration of this list of attributes,
|
// Parameter 'index_offset' is an offset for the enumeration of this list of attributes,
|
||||||
// it has been preceeded by the list of hardcoded attributes.
|
// it has been preceeded by the list of hardcoded attributes.
|
||||||
pub fn formatted_attribute_list(&self, index_offset: usize) -> Vec<Vec<u8>> {
|
pub fn formatted_attribute_list(
|
||||||
|
&self,
|
||||||
|
index_offset: usize,
|
||||||
|
exclude_attributes: Vec<&str>,
|
||||||
|
) -> Vec<Vec<u8>> {
|
||||||
let mut formatted_list: Vec<Vec<u8>> = Vec::new();
|
let mut formatted_list: Vec<Vec<u8>> = Vec::new();
|
||||||
|
|
||||||
for (index, attribute) in self.all_attributes().attributes.into_iter().enumerate() {
|
for (index, attribute) in self
|
||||||
|
.all_attributes()
|
||||||
|
.attributes
|
||||||
|
.into_iter()
|
||||||
|
.filter(|attr| !exclude_attributes.contains(&attr.name.as_str()))
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
formatted_list.push(
|
formatted_list.push(
|
||||||
format!(
|
format!(
|
||||||
"( 2.{} NAME '{}' DESC 'LLDAP: {}' SUP {:?} )",
|
"( 10.{} NAME '{}' DESC 'LLDAP: {}' SUP {:?} )",
|
||||||
(index + index_offset),
|
(index + index_offset),
|
||||||
attribute.name,
|
attribute.name,
|
||||||
if attribute.is_hardcoded {
|
if attribute.is_hardcoded {
|
||||||
@@ -506,4 +542,14 @@ mod tests {
|
|||||||
parsed_dn
|
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,10 +33,7 @@ pub(crate) async fn create_user_or_group(
|
|||||||
}
|
}
|
||||||
err => Err(err.into_ldap_error(
|
err => Err(err.into_ldap_error(
|
||||||
&request.dn,
|
&request.dn,
|
||||||
format!(
|
format!(r#""uid=id,ou=people,{base_dn_str}" or "uid=id,ou=groups,{base_dn_str}""#),
|
||||||
r#""uid=id,ou=people,{}" or "uid=id,ou=groups,{}""#,
|
|
||||||
base_dn_str, base_dn_str
|
|
||||||
),
|
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,10 +70,7 @@ async fn create_user(
|
|||||||
std::str::from_utf8(val)
|
std::str::from_utf8(val)
|
||||||
.map_err(|e| LdapError {
|
.map_err(|e| LdapError {
|
||||||
code: LdapResultCode::ConstraintViolation,
|
code: LdapResultCode::ConstraintViolation,
|
||||||
message: format!(
|
message: format!("Attribute value is invalid UTF-8: {e:#?} (value {val:?})"),
|
||||||
"Attribute value is invalid UTF-8: {:#?} (value {:?})",
|
|
||||||
e, val
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
.map(str::to_owned)
|
.map(str::to_owned)
|
||||||
}
|
}
|
||||||
@@ -92,7 +86,7 @@ async fn create_user(
|
|||||||
value: deserialize::deserialize_attribute_value(&[value], typ, false).map_err(|e| {
|
value: deserialize::deserialize_attribute_value(&[value], typ, false).map_err(|e| {
|
||||||
LdapError {
|
LdapError {
|
||||||
code: LdapResultCode::ConstraintViolation,
|
code: LdapResultCode::ConstraintViolation,
|
||||||
message: format!("Invalid attribute value: {}", e),
|
message: format!("Invalid attribute value: {e}"),
|
||||||
}
|
}
|
||||||
})?,
|
})?,
|
||||||
})
|
})
|
||||||
@@ -134,7 +128,7 @@ async fn create_user(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| LdapError {
|
.map_err(|e| LdapError {
|
||||||
code: LdapResultCode::OperationsError,
|
code: LdapResultCode::OperationsError,
|
||||||
message: format!("Could not create user: {:#?}", e),
|
message: format!("Could not create user: {e:#?}"),
|
||||||
})?;
|
})?;
|
||||||
Ok(vec![make_add_response(
|
Ok(vec![make_add_response(
|
||||||
LdapResultCode::Success,
|
LdapResultCode::Success,
|
||||||
@@ -156,7 +150,7 @@ async fn create_group(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| LdapError {
|
.map_err(|e| LdapError {
|
||||||
code: LdapResultCode::OperationsError,
|
code: LdapResultCode::OperationsError,
|
||||||
message: format!("Could not create group: {:#?}", e),
|
message: format!("Could not create group: {e:#?}"),
|
||||||
})?;
|
})?;
|
||||||
Ok(vec![make_add_response(
|
Ok(vec![make_add_response(
|
||||||
LdapResultCode::Success,
|
LdapResultCode::Success,
|
||||||
|
|||||||
@@ -30,10 +30,7 @@ pub(crate) async fn delete_user_or_group(
|
|||||||
UserOrGroupName::Group(group_name) => delete_group(backend_handler, group_name).await,
|
UserOrGroupName::Group(group_name) => delete_group(backend_handler, group_name).await,
|
||||||
err => Err(err.into_ldap_error(
|
err => Err(err.into_ldap_error(
|
||||||
&request,
|
&request,
|
||||||
format!(
|
format!(r#""uid=id,ou=people,{base_dn_str}" or "uid=id,ou=groups,{base_dn_str}""#),
|
||||||
r#""uid=id,ou=people,{}" or "uid=id,ou=groups,{}""#,
|
|
||||||
base_dn_str, base_dn_str
|
|
||||||
),
|
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,7 +50,7 @@ async fn delete_user(
|
|||||||
},
|
},
|
||||||
e => LdapError {
|
e => LdapError {
|
||||||
code: LdapResultCode::OperationsError,
|
code: LdapResultCode::OperationsError,
|
||||||
message: format!("Error while finding user: {:?}", e),
|
message: format!("Error while finding user: {e:?}"),
|
||||||
},
|
},
|
||||||
})?;
|
})?;
|
||||||
backend_handler
|
backend_handler
|
||||||
@@ -61,7 +58,7 @@ async fn delete_user(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| LdapError {
|
.map_err(|e| LdapError {
|
||||||
code: LdapResultCode::OperationsError,
|
code: LdapResultCode::OperationsError,
|
||||||
message: format!("Error while deleting user: {:?}", e),
|
message: format!("Error while deleting user: {e:?}"),
|
||||||
})?;
|
})?;
|
||||||
Ok(vec![make_del_response(
|
Ok(vec![make_del_response(
|
||||||
LdapResultCode::Success,
|
LdapResultCode::Success,
|
||||||
@@ -79,7 +76,7 @@ async fn delete_group(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| LdapError {
|
.map_err(|e| LdapError {
|
||||||
code: LdapResultCode::OperationsError,
|
code: LdapResultCode::OperationsError,
|
||||||
message: format!("Error while finding group: {:?}", e),
|
message: format!("Error while finding group: {e:?}"),
|
||||||
})?;
|
})?;
|
||||||
let group_id = groups
|
let group_id = groups
|
||||||
.iter()
|
.iter()
|
||||||
@@ -94,7 +91,7 @@ async fn delete_group(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| LdapError {
|
.map_err(|e| LdapError {
|
||||||
code: LdapResultCode::OperationsError,
|
code: LdapResultCode::OperationsError,
|
||||||
message: format!("Error while deleting group: {:?}", e),
|
message: format!("Error while deleting group: {e:?}"),
|
||||||
})?;
|
})?;
|
||||||
Ok(vec![make_del_response(
|
Ok(vec![make_del_response(
|
||||||
LdapResultCode::Success,
|
LdapResultCode::Success,
|
||||||
@@ -157,6 +154,7 @@ mod tests {
|
|||||||
uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
|
uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
|
||||||
users: Vec::new(),
|
users: Vec::new(),
|
||||||
attributes: Vec::new(),
|
attributes: Vec::new(),
|
||||||
|
modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
||||||
}])
|
}])
|
||||||
});
|
});
|
||||||
mock.expect_delete_group()
|
mock.expect_delete_group()
|
||||||
@@ -287,6 +285,7 @@ mod tests {
|
|||||||
uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
|
uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
|
||||||
users: Vec::new(),
|
users: Vec::new(),
|
||||||
attributes: Vec::new(),
|
attributes: Vec::new(),
|
||||||
|
modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
||||||
}])
|
}])
|
||||||
});
|
});
|
||||||
mock.expect_delete_group()
|
mock.expect_delete_group()
|
||||||
|
|||||||
+18
-30
@@ -2,7 +2,7 @@ use crate::{
|
|||||||
compare,
|
compare,
|
||||||
core::{
|
core::{
|
||||||
error::{LdapError, LdapResult},
|
error::{LdapError, LdapResult},
|
||||||
utils::{LdapInfo, parse_distinguished_name},
|
utils::LdapInfo,
|
||||||
},
|
},
|
||||||
create, delete, modify,
|
create, delete, modify,
|
||||||
password::{self, do_password_modification},
|
password::{self, do_password_modification},
|
||||||
@@ -18,7 +18,7 @@ use ldap3_proto::proto::{
|
|||||||
};
|
};
|
||||||
use lldap_access_control::AccessControlledBackendHandler;
|
use lldap_access_control::AccessControlledBackendHandler;
|
||||||
use lldap_auth::access_control::ValidationResults;
|
use lldap_auth::access_control::ValidationResults;
|
||||||
use lldap_domain::{public_schema::PublicSchema, types::AttributeName};
|
use lldap_domain::public_schema::PublicSchema;
|
||||||
use lldap_domain_handlers::handler::{BackendHandler, LoginHandler, ReadSchemaBackendHandler};
|
use lldap_domain_handlers::handler::{BackendHandler, LoginHandler, ReadSchemaBackendHandler};
|
||||||
use lldap_opaque_handler::OpaqueHandler;
|
use lldap_opaque_handler::OpaqueHandler;
|
||||||
use tracing::{debug, instrument};
|
use tracing::{debug, instrument};
|
||||||
@@ -59,7 +59,7 @@ pub(crate) fn make_modify_response(code: LdapResultCode, message: String) -> Lda
|
|||||||
pub struct LdapHandler<Backend> {
|
pub struct LdapHandler<Backend> {
|
||||||
user_info: Option<ValidationResults>,
|
user_info: Option<ValidationResults>,
|
||||||
backend_handler: AccessControlledBackendHandler<Backend>,
|
backend_handler: AccessControlledBackendHandler<Backend>,
|
||||||
ldap_info: LdapInfo,
|
ldap_info: &'static LdapInfo,
|
||||||
session_uuid: uuid::Uuid,
|
session_uuid: uuid::Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,26 +89,13 @@ enum Credentials<'s> {
|
|||||||
impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend> {
|
impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend> {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
backend_handler: AccessControlledBackendHandler<Backend>,
|
backend_handler: AccessControlledBackendHandler<Backend>,
|
||||||
mut ldap_base_dn: String,
|
ldap_info: &'static LdapInfo,
|
||||||
ignored_user_attributes: Vec<AttributeName>,
|
|
||||||
ignored_group_attributes: Vec<AttributeName>,
|
|
||||||
session_uuid: uuid::Uuid,
|
session_uuid: uuid::Uuid,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
ldap_base_dn.make_ascii_lowercase();
|
|
||||||
Self {
|
Self {
|
||||||
user_info: None,
|
user_info: None,
|
||||||
backend_handler,
|
backend_handler,
|
||||||
ldap_info: LdapInfo {
|
ldap_info,
|
||||||
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,
|
session_uuid,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,9 +104,9 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
|
|||||||
pub fn new_for_tests(backend_handler: Backend, ldap_base_dn: &str) -> Self {
|
pub fn new_for_tests(backend_handler: Backend, ldap_base_dn: &str) -> Self {
|
||||||
Self::new(
|
Self::new(
|
||||||
AccessControlledBackendHandler::new(backend_handler),
|
AccessControlledBackendHandler::new(backend_handler),
|
||||||
ldap_base_dn.to_string(),
|
Box::leak(Box::new(
|
||||||
vec![],
|
LdapInfo::new(ldap_base_dn, Vec::new(), Vec::new()).unwrap(),
|
||||||
vec![],
|
)),
|
||||||
uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
|
uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -155,7 +142,7 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
|
|||||||
|
|
||||||
let schema = backend_handler.get_schema().await.map_err(|e| LdapError {
|
let schema = backend_handler.get_schema().await.map_err(|e| LdapError {
|
||||||
code: LdapResultCode::OperationsError,
|
code: LdapResultCode::OperationsError,
|
||||||
message: format!("Unable to get schema: {:#}", e),
|
message: format!("Unable to get schema: {e:#}"),
|
||||||
})?;
|
})?;
|
||||||
return Ok(vec![
|
return Ok(vec![
|
||||||
make_ldap_subschema_entry(PublicSchema::from(schema)),
|
make_ldap_subschema_entry(PublicSchema::from(schema)),
|
||||||
@@ -174,13 +161,13 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
|
|||||||
let backend_handler = self
|
let backend_handler = self
|
||||||
.backend_handler
|
.backend_handler
|
||||||
.get_user_restricted_lister_handler(user_info);
|
.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))]
|
#[instrument(skip_all, level = "debug", fields(dn = %request.dn))]
|
||||||
pub async fn do_bind(&mut self, request: &LdapBindRequest) -> Vec<LdapOp> {
|
pub async fn do_bind(&mut self, request: &LdapBindRequest) -> Vec<LdapOp> {
|
||||||
let (code, message) =
|
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) => {
|
Ok(user_id) => {
|
||||||
self.user_info = self
|
self.user_info = self
|
||||||
.backend_handler
|
.backend_handler
|
||||||
@@ -214,7 +201,7 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
|
|||||||
};
|
};
|
||||||
do_password_modification(
|
do_password_modification(
|
||||||
credentials,
|
credentials,
|
||||||
&self.ldap_info,
|
self.ldap_info,
|
||||||
&self.backend_handler,
|
&self.backend_handler,
|
||||||
self.get_opaque_handler(),
|
self.get_opaque_handler(),
|
||||||
&password_request,
|
&password_request,
|
||||||
@@ -224,7 +211,7 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
|
|||||||
}
|
}
|
||||||
Err(e) => vec![make_extended_response(
|
Err(e) => vec![make_extended_response(
|
||||||
LdapResultCode::ProtocolError,
|
LdapResultCode::ProtocolError,
|
||||||
format!("Error while parsing password modify request: {:#?}", e),
|
format!("Error while parsing password modify request: {e:#?}"),
|
||||||
)],
|
)],
|
||||||
},
|
},
|
||||||
OID_WHOAMI => {
|
OID_WHOAMI => {
|
||||||
@@ -260,7 +247,7 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
|
|||||||
self.backend_handler
|
self.backend_handler
|
||||||
.get_readable_handler(credentials, &user_id)
|
.get_readable_handler(credentials, &user_id)
|
||||||
},
|
},
|
||||||
&self.ldap_info,
|
self.ldap_info,
|
||||||
credentials,
|
credentials,
|
||||||
request,
|
request,
|
||||||
)
|
)
|
||||||
@@ -278,7 +265,7 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
|
|||||||
code: LdapResultCode::InsufficentAccessRights,
|
code: LdapResultCode::InsufficentAccessRights,
|
||||||
message: "Unauthorized write".to_string(),
|
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")]
|
#[instrument(skip_all, level = "debug")]
|
||||||
@@ -291,7 +278,7 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
|
|||||||
code: LdapResultCode::InsufficentAccessRights,
|
code: LdapResultCode::InsufficentAccessRights,
|
||||||
message: "Unauthorized write".to_string(),
|
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")]
|
#[instrument(skip_all, level = "debug")]
|
||||||
@@ -343,7 +330,7 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
|
|||||||
.unwrap_or_else(|e: LdapError| vec![make_search_error(e.code, e.message)]),
|
.unwrap_or_else(|e: LdapError| vec![make_search_error(e.code, e.message)]),
|
||||||
op => vec![make_extended_response(
|
op => vec![make_extended_response(
|
||||||
LdapResultCode::UnwillingToPerform,
|
LdapResultCode::UnwillingToPerform,
|
||||||
format!("Unsupported operation: {:#?}", op),
|
format!("Unsupported operation: {op:#?}"),
|
||||||
)],
|
)],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -401,6 +388,7 @@ pub mod tests {
|
|||||||
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
||||||
uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
|
uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
|
||||||
attributes: Vec::new(),
|
attributes: Vec::new(),
|
||||||
|
modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
||||||
});
|
});
|
||||||
Ok(set)
|
Ok(set)
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ pub(crate) mod modify;
|
|||||||
pub(crate) mod password;
|
pub(crate) mod password;
|
||||||
pub(crate) mod search;
|
pub(crate) mod search;
|
||||||
|
|
||||||
pub use core::utils::{UserFieldType, map_group_field, map_user_field};
|
pub use core::utils::{LdapInfo, UserFieldType, map_group_field, map_user_field};
|
||||||
pub use handler::LdapHandler;
|
pub use handler::LdapHandler;
|
||||||
|
|
||||||
pub use core::group::get_default_group_object_classes;
|
pub use core::group::get_default_group_object_classes;
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ async fn handle_modify_change(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| LdapError {
|
.map_err(|e| LdapError {
|
||||||
code: LdapResultCode::Other,
|
code: LdapResultCode::Other,
|
||||||
message: format!("Error while changing the password: {:#?}", e),
|
message: format!("Error while changing the password: {e:#?}"),
|
||||||
})?;
|
})?;
|
||||||
} else {
|
} else {
|
||||||
return Err(LdapError {
|
return Err(LdapError {
|
||||||
@@ -94,7 +94,7 @@ where
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| LdapError {
|
.map_err(|e| LdapError {
|
||||||
code: LdapResultCode::OperationsError,
|
code: LdapResultCode::OperationsError,
|
||||||
message: format!("Internal error while requesting user's groups: {:#?}", e),
|
message: format!("Internal error while requesting user's groups: {e:#?}"),
|
||||||
})?
|
})?
|
||||||
.iter()
|
.iter()
|
||||||
.any(|g| g.display_name == "lldap_admin".into());
|
.any(|g| g.display_name == "lldap_admin".into());
|
||||||
@@ -115,7 +115,7 @@ where
|
|||||||
}
|
}
|
||||||
Err(e) => Err(LdapError {
|
Err(e) => Err(LdapError {
|
||||||
code: LdapResultCode::InvalidDNSyntax,
|
code: LdapResultCode::InvalidDNSyntax,
|
||||||
message: format!("Invalid username: {}", e),
|
message: format!("Invalid username: {e}"),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -158,6 +158,7 @@ mod tests {
|
|||||||
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
||||||
uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
|
uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
|
||||||
attributes: Vec::new(),
|
attributes: Vec::new(),
|
||||||
|
modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(g)
|
Ok(g)
|
||||||
@@ -166,7 +167,7 @@ mod tests {
|
|||||||
|
|
||||||
fn make_password_modify_request(target_user: &str) -> LdapModifyRequest {
|
fn make_password_modify_request(target_user: &str) -> LdapModifyRequest {
|
||||||
LdapModifyRequest {
|
LdapModifyRequest {
|
||||||
dn: format!("uid={},ou=people,dc=example,dc=com", target_user),
|
dn: format!("uid={target_user},ou=people,dc=example,dc=com"),
|
||||||
changes: vec![LdapModify {
|
changes: vec![LdapModify {
|
||||||
operation: LdapModifyType::Replace,
|
operation: LdapModifyType::Replace,
|
||||||
modification: ldap3_proto::LdapPartialAttribute {
|
modification: ldap3_proto::LdapPartialAttribute {
|
||||||
@@ -284,7 +285,7 @@ mod tests {
|
|||||||
let request = {
|
let request = {
|
||||||
let target_user = "bob";
|
let target_user = "bob";
|
||||||
LdapModifyRequest {
|
LdapModifyRequest {
|
||||||
dn: format!("uid={},ou=people,dc=example,dc=com", target_user),
|
dn: format!("uid={target_user},ou=people,dc=example,dc=com"),
|
||||||
changes: vec![LdapModify {
|
changes: vec![LdapModify {
|
||||||
operation: LdapModifyType::Replace,
|
operation: LdapModifyType::Replace,
|
||||||
modification: ldap3_proto::LdapPartialAttribute {
|
modification: ldap3_proto::LdapPartialAttribute {
|
||||||
|
|||||||
@@ -112,8 +112,7 @@ pub(crate) async fn do_password_modification<Handler: BackendHandler>(
|
|||||||
.map_err(|e| LdapError {
|
.map_err(|e| LdapError {
|
||||||
code: LdapResultCode::OperationsError,
|
code: LdapResultCode::OperationsError,
|
||||||
message: format!(
|
message: format!(
|
||||||
"Internal error while requesting user's groups: {:#?}",
|
"Internal error while requesting user's groups: {e:#?}"
|
||||||
e
|
|
||||||
),
|
),
|
||||||
})?
|
})?
|
||||||
.iter()
|
.iter()
|
||||||
@@ -131,7 +130,7 @@ pub(crate) async fn do_password_modification<Handler: BackendHandler>(
|
|||||||
{
|
{
|
||||||
Err(LdapError {
|
Err(LdapError {
|
||||||
code: LdapResultCode::Other,
|
code: LdapResultCode::Other,
|
||||||
message: format!("Error while changing the password: {:#?}", e),
|
message: format!("Error while changing the password: {e:#?}"),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
Ok(vec![make_extended_response(
|
Ok(vec![make_extended_response(
|
||||||
@@ -142,7 +141,7 @@ pub(crate) async fn do_password_modification<Handler: BackendHandler>(
|
|||||||
}
|
}
|
||||||
Err(e) => Err(LdapError {
|
Err(e) => Err(LdapError {
|
||||||
code: LdapResultCode::InvalidDNSyntax,
|
code: LdapResultCode::InvalidDNSyntax,
|
||||||
message: format!("Invalid username: {}", e),
|
message: format!("Invalid username: {e}"),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -264,6 +263,7 @@ pub mod tests {
|
|||||||
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
||||||
uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
|
uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
|
||||||
attributes: Vec::new(),
|
attributes: Vec::new(),
|
||||||
|
modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
||||||
});
|
});
|
||||||
Ok(set)
|
Ok(set)
|
||||||
});
|
});
|
||||||
@@ -521,6 +521,7 @@ pub mod tests {
|
|||||||
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
||||||
uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
|
uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
|
||||||
attributes: Vec::new(),
|
attributes: Vec::new(),
|
||||||
|
modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
||||||
});
|
});
|
||||||
mock.expect_get_user_groups()
|
mock.expect_get_user_groups()
|
||||||
.with(eq(UserId::new("bob")))
|
.with(eq(UserId::new("bob")))
|
||||||
|
|||||||
+244
-763
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ edition.workspace = true
|
|||||||
homepage.workspace = true
|
homepage.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
test = []
|
test = []
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ edition.workspace = true
|
|||||||
homepage.workspace = true
|
homepage.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
test = []
|
test = []
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ pub mod tests {
|
|||||||
handler
|
handler
|
||||||
.create_user(CreateUserRequest {
|
.create_user(CreateUserRequest {
|
||||||
user_id: UserId::new(name),
|
user_id: UserId::new(name),
|
||||||
email: format!("{}@bob.bob", name).into(),
|
email: format!("{name}@bob.bob").into(),
|
||||||
display_name: Some("display ".to_string() + name),
|
display_name: Some("display ".to_string() + name),
|
||||||
attributes: vec![
|
attributes: vec![
|
||||||
DomainAttribute {
|
DomainAttribute {
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ impl GroupBackendHandler for SqlBackendHandler {
|
|||||||
.one(&self.sql_pool)
|
.one(&self.sql_pool)
|
||||||
.await?
|
.await?
|
||||||
.map(Into::<GroupDetails>::into)
|
.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()
|
let attributes = model::GroupAttributes::find()
|
||||||
.filter(model::GroupAttributesColumn::GroupId.eq(group_details.group_id))
|
.filter(model::GroupAttributesColumn::GroupId.eq(group_details.group_id))
|
||||||
.order_by_asc(model::GroupAttributesColumn::AttributeName)
|
.order_by_asc(model::GroupAttributesColumn::AttributeName)
|
||||||
@@ -206,6 +206,7 @@ impl GroupBackendHandler for SqlBackendHandler {
|
|||||||
lowercase_display_name: Set(lower_display_name),
|
lowercase_display_name: Set(lower_display_name),
|
||||||
creation_date: Set(now),
|
creation_date: Set(now),
|
||||||
uuid: Set(uuid),
|
uuid: Set(uuid),
|
||||||
|
modified_date: Set(now),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
Ok(self
|
Ok(self
|
||||||
@@ -252,8 +253,7 @@ impl GroupBackendHandler for SqlBackendHandler {
|
|||||||
.await?;
|
.await?;
|
||||||
if res.rows_affected == 0 {
|
if res.rows_affected == 0 {
|
||||||
return Err(DomainError::EntityNotFound(format!(
|
return Err(DomainError::EntityNotFound(format!(
|
||||||
"No such group: '{:?}'",
|
"No such group: '{group_id:?}'"
|
||||||
group_id
|
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -269,10 +269,12 @@ impl SqlBackendHandler {
|
|||||||
.display_name
|
.display_name
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|s| s.as_str().to_lowercase());
|
.map(|s| s.as_str().to_lowercase());
|
||||||
|
let now = chrono::Utc::now().naive_utc();
|
||||||
let update_group = model::groups::ActiveModel {
|
let update_group = model::groups::ActiveModel {
|
||||||
group_id: Set(request.group_id),
|
group_id: Set(request.group_id),
|
||||||
display_name: request.display_name.map(Set).unwrap_or_default(),
|
display_name: request.display_name.map(Set).unwrap_or_default(),
|
||||||
lowercase_display_name: lower_display_name.map(Set).unwrap_or_default(),
|
lowercase_display_name: lower_display_name.map(Set).unwrap_or_default(),
|
||||||
|
modified_date: Set(now),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
update_group.update(transaction).await?;
|
update_group.update(transaction).await?;
|
||||||
@@ -306,8 +308,7 @@ impl SqlBackendHandler {
|
|||||||
remove_group_attributes.push(attribute);
|
remove_group_attributes.push(attribute);
|
||||||
} else {
|
} else {
|
||||||
return Err(DomainError::InternalError(format!(
|
return Err(DomainError::InternalError(format!(
|
||||||
"Group attribute name {} doesn't exist in the schema, yet was attempted to be removed from the database",
|
"Group attribute name {attribute} doesn't exist in the schema, yet was attempted to be removed from the database"
|
||||||
attribute
|
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ pub enum Users {
|
|||||||
TotpSecret,
|
TotpSecret,
|
||||||
MfaType,
|
MfaType,
|
||||||
Uuid,
|
Uuid,
|
||||||
|
ModifiedDate,
|
||||||
|
PasswordModifiedDate,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(DeriveIden, PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Copy)]
|
#[derive(DeriveIden, PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Copy)]
|
||||||
@@ -37,6 +39,7 @@ pub(crate) enum Groups {
|
|||||||
LowercaseDisplayName,
|
LowercaseDisplayName,
|
||||||
CreationDate,
|
CreationDate,
|
||||||
Uuid,
|
Uuid,
|
||||||
|
ModifiedDate,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(DeriveIden, Clone, Copy)]
|
#[derive(DeriveIden, Clone, Copy)]
|
||||||
@@ -1112,6 +1115,53 @@ async fn migrate_to_v10(transaction: DatabaseTransaction) -> Result<DatabaseTran
|
|||||||
Ok(transaction)
|
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.
|
// This is needed to make an array of async functions.
|
||||||
macro_rules! to_sync {
|
macro_rules! to_sync {
|
||||||
($l:ident) => {
|
($l:ident) => {
|
||||||
@@ -1142,6 +1192,7 @@ pub(crate) async fn migrate_from_version(
|
|||||||
to_sync!(migrate_to_v8),
|
to_sync!(migrate_to_v8),
|
||||||
to_sync!(migrate_to_v9),
|
to_sync!(migrate_to_v9),
|
||||||
to_sync!(migrate_to_v10),
|
to_sync!(migrate_to_v10),
|
||||||
|
to_sync!(migrate_to_v11),
|
||||||
];
|
];
|
||||||
assert_eq!(migrations.len(), (LAST_SCHEMA_VERSION.0 - 1) as usize);
|
assert_eq!(migrations.len(), (LAST_SCHEMA_VERSION.0 - 1) as usize);
|
||||||
for migration in 2..=last_version.0 {
|
for migration in 2..=last_version.0 {
|
||||||
|
|||||||
@@ -197,9 +197,12 @@ impl OpaqueHandler for SqlOpaqueHandler {
|
|||||||
let password_file =
|
let password_file =
|
||||||
opaque::server::registration::get_password_file(request.registration_upload);
|
opaque::server::registration::get_password_file(request.registration_upload);
|
||||||
// Set the user password to the new password.
|
// Set the user password to the new password.
|
||||||
|
let now = chrono::Utc::now().naive_utc();
|
||||||
let user_update = model::users::ActiveModel {
|
let user_update = model::users::ActiveModel {
|
||||||
user_id: ActiveValue::Set(username.clone()),
|
user_id: ActiveValue::Set(username.clone()),
|
||||||
password_hash: ActiveValue::Set(Some(password_file.serialize())),
|
password_hash: ActiveValue::Set(Some(password_file.serialize())),
|
||||||
|
password_modified_date: ActiveValue::Set(now),
|
||||||
|
modified_date: ActiveValue::Set(now),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
user_update.update(&self.sql_pool).await?;
|
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)]
|
#[derive(Copy, PartialEq, Eq, Debug, Clone, PartialOrd, Ord, DeriveValueType)]
|
||||||
pub struct SchemaVersion(pub i16);
|
pub struct SchemaVersion(pub i16);
|
||||||
|
|
||||||
pub const LAST_SCHEMA_VERSION: SchemaVersion = SchemaVersion(10);
|
pub const LAST_SCHEMA_VERSION: SchemaVersion = SchemaVersion(11);
|
||||||
|
|
||||||
#[derive(Copy, PartialEq, Eq, Debug, Clone, PartialOrd, Ord)]
|
#[derive(Copy, PartialEq, Eq, Debug, Clone, PartialOrd, Ord)]
|
||||||
pub struct PrivateKeyHash(pub [u8; 32]);
|
pub struct PrivateKeyHash(pub [u8; 32]);
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ use crate::sql_backend_handler::SqlBackendHandler;
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use lldap_domain::{
|
use lldap_domain::{
|
||||||
requests::{CreateUserRequest, UpdateUserRequest},
|
requests::{CreateUserRequest, UpdateUserRequest},
|
||||||
types::{AttributeName, GroupDetails, GroupId, Serialized, User, UserAndGroups, UserId, Uuid},
|
schema::Schema,
|
||||||
|
types::{
|
||||||
|
Attribute, AttributeName, GroupDetails, GroupId, Serialized, User, UserAndGroups, UserId,
|
||||||
|
Uuid,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use lldap_domain_handlers::handler::{
|
use lldap_domain_handlers::handler::{
|
||||||
ReadSchemaBackendHandler, UserBackendHandler, UserListerBackendHandler, UserRequestFilter,
|
ReadSchemaBackendHandler, UserBackendHandler, UserListerBackendHandler, UserRequestFilter,
|
||||||
@@ -185,18 +189,12 @@ impl UserListerBackendHandler for SqlBackendHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SqlBackendHandler {
|
impl SqlBackendHandler {
|
||||||
async fn update_user_with_transaction(
|
fn compute_user_attribute_changes(
|
||||||
transaction: &DatabaseTransaction,
|
user_id: &UserId,
|
||||||
request: UpdateUserRequest,
|
insert_attributes: Vec<Attribute>,
|
||||||
) -> Result<()> {
|
delete_attributes: Vec<AttributeName>,
|
||||||
let lower_email = request.email.as_ref().map(|s| s.as_str().to_lowercase());
|
schema: &Schema,
|
||||||
let update_user = model::users::ActiveModel {
|
) -> Result<(Vec<model::user_attributes::ActiveModel>, Vec<AttributeName>)> {
|
||||||
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 update_user_attributes = Vec::new();
|
||||||
let mut remove_user_attributes = Vec::new();
|
let mut remove_user_attributes = Vec::new();
|
||||||
let mut process_serialized =
|
let mut process_serialized =
|
||||||
@@ -206,24 +204,20 @@ impl SqlBackendHandler {
|
|||||||
}
|
}
|
||||||
ActiveValue::Set(_) => {
|
ActiveValue::Set(_) => {
|
||||||
update_user_attributes.push(model::user_attributes::ActiveModel {
|
update_user_attributes.push(model::user_attributes::ActiveModel {
|
||||||
user_id: Set(request.user_id.clone()),
|
user_id: Set(user_id.clone()),
|
||||||
attribute_name: Set(attribute_name),
|
attribute_name: Set(attribute_name),
|
||||||
value,
|
value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
};
|
};
|
||||||
let schema = Self::get_schema_with_transaction(transaction).await?;
|
for attribute in insert_attributes {
|
||||||
for attribute in request.insert_attributes {
|
|
||||||
if schema
|
if schema
|
||||||
.user_attributes
|
.user_attributes
|
||||||
.get_attribute_type(&attribute.name)
|
.get_attribute_type(&attribute.name)
|
||||||
.is_some()
|
.is_some()
|
||||||
{
|
{
|
||||||
process_serialized(
|
process_serialized(ActiveValue::Set(attribute.value.into()), attribute.name);
|
||||||
ActiveValue::Set(attribute.value.into()),
|
|
||||||
attribute.name.clone(),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
return Err(DomainError::InternalError(format!(
|
return Err(DomainError::InternalError(format!(
|
||||||
"User attribute name {} doesn't exist in the schema, yet was attempted to be inserted in the database",
|
"User attribute name {} doesn't exist in the schema, yet was attempted to be inserted in the database",
|
||||||
@@ -231,7 +225,7 @@ impl SqlBackendHandler {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for attribute in request.delete_attributes {
|
for attribute in delete_attributes {
|
||||||
if schema
|
if schema
|
||||||
.user_attributes
|
.user_attributes
|
||||||
.get_attribute_type(&attribute)
|
.get_attribute_type(&attribute)
|
||||||
@@ -240,11 +234,35 @@ impl SqlBackendHandler {
|
|||||||
remove_user_attributes.push(attribute);
|
remove_user_attributes.push(attribute);
|
||||||
} else {
|
} else {
|
||||||
return Err(DomainError::InternalError(format!(
|
return Err(DomainError::InternalError(format!(
|
||||||
"User attribute name {} doesn't exist in the schema, yet was attempted to be removed from the database",
|
"User attribute name {attribute} 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?;
|
update_user.update(transaction).await?;
|
||||||
if !remove_user_attributes.is_empty() {
|
if !remove_user_attributes.is_empty() {
|
||||||
model::UserAttributes::delete_many()
|
model::UserAttributes::delete_many()
|
||||||
@@ -326,6 +344,8 @@ impl UserBackendHandler for SqlBackendHandler {
|
|||||||
display_name: to_value(&request.display_name),
|
display_name: to_value(&request.display_name),
|
||||||
creation_date: ActiveValue::Set(now),
|
creation_date: ActiveValue::Set(now),
|
||||||
uuid: ActiveValue::Set(uuid),
|
uuid: ActiveValue::Set(uuid),
|
||||||
|
modified_date: ActiveValue::Set(now),
|
||||||
|
password_modified_date: ActiveValue::Set(now),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let mut new_user_attributes = Vec::new();
|
let mut new_user_attributes = Vec::new();
|
||||||
@@ -384,8 +404,7 @@ impl UserBackendHandler for SqlBackendHandler {
|
|||||||
.await?;
|
.await?;
|
||||||
if res.rows_affected == 0 {
|
if res.rows_affected == 0 {
|
||||||
return Err(DomainError::EntityNotFound(format!(
|
return Err(DomainError::EntityNotFound(format!(
|
||||||
"No such user: '{}'",
|
"No such user: '{user_id}'"
|
||||||
user_id
|
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -393,25 +412,70 @@ impl UserBackendHandler for SqlBackendHandler {
|
|||||||
|
|
||||||
#[instrument(skip_all, level = "debug", err, fields(user_id = ?user_id.as_str(), group_id))]
|
#[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<()> {
|
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 {
|
let new_membership = model::memberships::ActiveModel {
|
||||||
user_id: ActiveValue::Set(user_id.clone()),
|
user_id: ActiveValue::Set(user_id),
|
||||||
group_id: ActiveValue::Set(group_id),
|
group_id: ActiveValue::Set(group_id),
|
||||||
};
|
};
|
||||||
new_membership.insert(&self.sql_pool).await?;
|
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?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip_all, level = "debug", err, fields(user_id = ?user_id.as_str(), group_id))]
|
#[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<()> {
|
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))
|
let res = model::Membership::delete_by_id((user_id.clone(), group_id))
|
||||||
.exec(&self.sql_pool)
|
.exec(transaction)
|
||||||
.await?;
|
.await?;
|
||||||
if res.rows_affected == 0 {
|
if res.rows_affected == 0 {
|
||||||
return Err(DomainError::EntityNotFound(format!(
|
return Err(sea_orm::DbErr::Custom(format!(
|
||||||
"No such membership: '{}' -> {:?}",
|
"No such membership: '{user_id}' -> {group_id:?}"
|
||||||
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),
|
||||||
|
})?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ edition.workspace = true
|
|||||||
homepage.workspace = true
|
homepage.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ edition.workspace = true
|
|||||||
homepage.workspace = true
|
homepage.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|||||||
+27
-5
@@ -3,6 +3,7 @@
|
|||||||
- [With Docker](#with-docker)
|
- [With Docker](#with-docker)
|
||||||
- [With Podman](#with-podman)
|
- [With Podman](#with-podman)
|
||||||
- [With Kubernetes](#with-kubernetes)
|
- [With Kubernetes](#with-kubernetes)
|
||||||
|
- [TrueNAS SCALE](#truenas-scale)
|
||||||
- [From a package repository](#from-a-package-repository)
|
- [From a package repository](#from-a-package-repository)
|
||||||
- [With FreeBSD](#with-freebsd)
|
- [With FreeBSD](#with-freebsd)
|
||||||
- [From source](#from-source)
|
- [From source](#from-source)
|
||||||
@@ -68,7 +69,7 @@ services:
|
|||||||
- LLDAP_JWT_SECRET=REPLACE_WITH_RANDOM
|
- LLDAP_JWT_SECRET=REPLACE_WITH_RANDOM
|
||||||
- LLDAP_KEY_SEED=REPLACE_WITH_RANDOM
|
- LLDAP_KEY_SEED=REPLACE_WITH_RANDOM
|
||||||
- LLDAP_LDAP_BASE_DN=dc=example,dc=com
|
- LLDAP_LDAP_BASE_DN=dc=example,dc=com
|
||||||
- LLDAP_LDAP_USER_PASS=adminPas$word
|
- LLDAP_LDAP_USER_PASS=CHANGE_ME # If the password contains '$', escape it (e.g. Pas$$word sets Pas$word)
|
||||||
# If using LDAPS, set enabled true and configure cert and key path
|
# If using LDAPS, set enabled true and configure cert and key path
|
||||||
# - LLDAP_LDAPS_OPTIONS__ENABLED=true
|
# - LLDAP_LDAPS_OPTIONS__ENABLED=true
|
||||||
# - LLDAP_LDAPS_OPTIONS__CERT_FILE=/path/to/certfile.crt
|
# - LLDAP_LDAPS_OPTIONS__CERT_FILE=/path/to/certfile.crt
|
||||||
@@ -93,7 +94,7 @@ front-end.
|
|||||||
### With Podman
|
### With Podman
|
||||||
|
|
||||||
LLDAP works well with rootless Podman either through command line deployment
|
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
|
include configuration with postgresql and file based secrets, but have comments
|
||||||
for several other deployment strategies.
|
for several other deployment strategies.
|
||||||
|
|
||||||
@@ -102,9 +103,30 @@ for several other deployment strategies.
|
|||||||
See https://github.com/Evantage-WS/lldap-kubernetes for a LLDAP deployment for Kubernetes
|
See https://github.com/Evantage-WS/lldap-kubernetes for a LLDAP deployment for Kubernetes
|
||||||
|
|
||||||
You can bootstrap your lldap instance (users, groups)
|
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.
|
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
|
### From a package repository
|
||||||
|
|
||||||
**Do not open issues in this repository for problems with third-party
|
**Do not open issues in this repository for problems with third-party
|
||||||
@@ -114,7 +136,7 @@ Depending on the distribution you use, it might be possible to install LLDAP
|
|||||||
from a package repository, officially supported by the distribution or
|
from a package repository, officially supported by the distribution or
|
||||||
community contributed.
|
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.
|
When using the distributed packages, the default login is `admin/password`. You can change that from the web UI after starting the service.
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -385,7 +407,7 @@ arguments to `cargo run`. Have a look at the docker template:
|
|||||||
`lldap_config.docker_template.toml`.
|
`lldap_config.docker_template.toml`.
|
||||||
|
|
||||||
You can also install it as a systemd service, see
|
You can also install it as a systemd service, see
|
||||||
[lldap.service](example_configs/lldap.service).
|
[lldap.service](../example_configs/lldap.service).
|
||||||
|
|
||||||
### Cross-compilation
|
### Cross-compilation
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# 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": "eYbat...",
|
"token": "Yh6RJV...",
|
||||||
"refreshToken": "3bCka...",
|
"refreshToken": "dww5jwU...",
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ Some specific clients have been tested to work and come with sample
|
|||||||
configuration files:
|
configuration files:
|
||||||
|
|
||||||
- [Airsonic Advanced](airsonic-advanced.md)
|
- [Airsonic Advanced](airsonic-advanced.md)
|
||||||
|
- [Apache HTTP Server](apache.md)
|
||||||
- [Apache Guacamole](apacheguacamole.md)
|
- [Apache Guacamole](apacheguacamole.md)
|
||||||
- [Apereo CAS Server](apereo_cas_server.md)
|
- [Apereo CAS Server](apereo_cas_server.md)
|
||||||
- [Authelia](authelia_config.yml)
|
- [Authelia](authelia.md)
|
||||||
- [Authentik](authentik.md)
|
- [Authentik](authentik.md)
|
||||||
- [Bookstack](bookstack.env.example)
|
- [Bookstack](bookstack.env.example)
|
||||||
- [Calibre-Web](calibre_web.md)
|
- [Calibre-Web](calibre_web.md)
|
||||||
- [Carpal](carpal.md)
|
- [Carpal](carpal.md)
|
||||||
|
- [Continuwuity](continuwuity.md)
|
||||||
- [Dell iDRAC](dell_idrac.md)
|
- [Dell iDRAC](dell_idrac.md)
|
||||||
- [Dex](dex_config.yml)
|
- [Dex](dex_config.yml)
|
||||||
- [Dokuwiki](dokuwiki.md)
|
- [Dokuwiki](dokuwiki.md)
|
||||||
@@ -19,6 +21,7 @@ configuration files:
|
|||||||
- [Ejabberd](ejabberd.md)
|
- [Ejabberd](ejabberd.md)
|
||||||
- [Emby](emby.md)
|
- [Emby](emby.md)
|
||||||
- [Ergo IRCd](ergo.md)
|
- [Ergo IRCd](ergo.md)
|
||||||
|
- [Gerrit](gerrit.md)
|
||||||
- [Gitea](gitea.md)
|
- [Gitea](gitea.md)
|
||||||
- [GitLab](gitlab.md)
|
- [GitLab](gitlab.md)
|
||||||
- [Grafana](grafana_ldap_config.toml)
|
- [Grafana](grafana_ldap_config.toml)
|
||||||
@@ -51,6 +54,7 @@ configuration files:
|
|||||||
- [Peertube](peertube.md)
|
- [Peertube](peertube.md)
|
||||||
- [Penpot](penpot.md)
|
- [Penpot](penpot.md)
|
||||||
- [pgAdmin](pgadmin.md)
|
- [pgAdmin](pgadmin.md)
|
||||||
|
- [Pocket-ID](pocket-id.md)
|
||||||
- [Portainer](portainer.md)
|
- [Portainer](portainer.md)
|
||||||
- [PowerDNS Admin](powerdns_admin.md)
|
- [PowerDNS Admin](powerdns_admin.md)
|
||||||
- [Prosody](prosody.md)
|
- [Prosody](prosody.md)
|
||||||
@@ -59,6 +63,7 @@ configuration files:
|
|||||||
- [Radicale](radicale.md)
|
- [Radicale](radicale.md)
|
||||||
- [Rancher](rancher.md)
|
- [Rancher](rancher.md)
|
||||||
- [Seafile](seafile.md)
|
- [Seafile](seafile.md)
|
||||||
|
- [Semaphore](semaphore.md)
|
||||||
- [Shaarli](shaarli.md)
|
- [Shaarli](shaarli.md)
|
||||||
- [Snipe-IT](snipe-it.md)
|
- [Snipe-IT](snipe-it.md)
|
||||||
- [SonarQube](sonarqube.md)
|
- [SonarQube](sonarqube.md)
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# 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"
|
||||||
|
```
|
||||||
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 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
|
||||||
|
```
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
###############################################################
|
|
||||||
# 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
|
# Additional settings
|
||||||
|
|
||||||
## Group
|
## Parent Group
|
||||||
```
|
```
|
||||||
---------
|
---------
|
||||||
```
|
```
|
||||||
@@ -99,6 +99,16 @@ ou=groups
|
|||||||
member
|
member
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## User membership attribute
|
||||||
|
```
|
||||||
|
distinguishedName
|
||||||
|
```
|
||||||
|
|
||||||
|
## Looking using user attribute
|
||||||
|
```
|
||||||
|
false
|
||||||
|
```
|
||||||
|
|
||||||
## Object uniqueness field
|
## Object uniqueness field
|
||||||
```
|
```
|
||||||
uid
|
uid
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ The script can:
|
|||||||
- `GROUP_SCHEMAS_DIR` (default value: `/bootstrap/group-schemas`) - directory where the group schema JSON configs could be found
|
- `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`)
|
- `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` (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
|
## Config files
|
||||||
|
|
||||||
@@ -69,6 +72,7 @@ Fields description:
|
|||||||
* `id`: it's just username (**MANDATORY**)
|
* `id`: it's just username (**MANDATORY**)
|
||||||
* `email`: self-explanatory (**MANDATORY**)
|
* `email`: self-explanatory (**MANDATORY**)
|
||||||
* `password`: would be used to set the password using `lldap_set_password` utility
|
* `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
|
* `displayName`: self-explanatory
|
||||||
* `firstName`: self-explanatory
|
* `firstName`: self-explanatory
|
||||||
* `lastName`: self-explanatory
|
* `lastName`: self-explanatory
|
||||||
@@ -127,7 +131,7 @@ Fields description:
|
|||||||
"isVisible": true
|
"isVisible": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "mail_alias",
|
"name": "mail-alias",
|
||||||
"attributeType": "STRING",
|
"attributeType": "STRING",
|
||||||
"isEditable": false,
|
"isEditable": false,
|
||||||
"isList": true,
|
"isList": true,
|
||||||
@@ -243,14 +247,14 @@ spec:
|
|||||||
restartPolicy: OnFailure
|
restartPolicy: OnFailure
|
||||||
containers:
|
containers:
|
||||||
- name: lldap-bootstrap
|
- name: lldap-bootstrap
|
||||||
image: lldap/lldap:v0.5.0
|
image: lldap/lldap:latest
|
||||||
|
|
||||||
command:
|
command:
|
||||||
- /bootstrap/bootstrap.sh
|
- /app/bootstrap.sh
|
||||||
|
|
||||||
env:
|
env:
|
||||||
- name: LLDAP_URL
|
- name: LLDAP_URL
|
||||||
value: "http://lldap:8080"
|
value: "http://lldap:17170"
|
||||||
|
|
||||||
- name: LLDAP_ADMIN_USERNAME
|
- name: LLDAP_ADMIN_USERNAME
|
||||||
valueFrom: { secretKeyRef: { name: lldap-admin-user, key: username } }
|
valueFrom: { secretKeyRef: { name: lldap-admin-user, key: username } }
|
||||||
@@ -262,11 +266,6 @@ spec:
|
|||||||
value: "true"
|
value: "true"
|
||||||
|
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: bootstrap
|
|
||||||
mountPath: /bootstrap/bootstrap.sh
|
|
||||||
readOnly: true
|
|
||||||
subPath: bootstrap.sh
|
|
||||||
|
|
||||||
- name: user-configs
|
- name: user-configs
|
||||||
mountPath: /bootstrap/user-configs
|
mountPath: /bootstrap/user-configs
|
||||||
readOnly: true
|
readOnly: true
|
||||||
@@ -276,27 +275,9 @@ spec:
|
|||||||
readOnly: true
|
readOnly: true
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- name: bootstrap
|
|
||||||
configMap:
|
|
||||||
name: bootstrap
|
|
||||||
defaultMode: 0555
|
|
||||||
items:
|
|
||||||
- key: bootstrap.sh
|
|
||||||
path: bootstrap.sh
|
|
||||||
|
|
||||||
- name: user-configs
|
- name: user-configs
|
||||||
projected:
|
projected:
|
||||||
sources:
|
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:
|
- secret:
|
||||||
name: lldap-bootstrap-configs
|
name: lldap-bootstrap-configs
|
||||||
items:
|
items:
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# 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)'
|
||||||
|
```
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# 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,7 +41,14 @@ name = "displayName"
|
|||||||
surname = "sn"
|
surname = "sn"
|
||||||
username = "uid"
|
username = "uid"
|
||||||
|
|
||||||
# If you want to map your ldap groups to grafana's groups, see: https://grafana.com/docs/grafana/latest/auth/ldap/#group-mappings
|
# 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
|
||||||
# As a quick example, here is how you would map lldap's admin group to grafana's admin
|
# As a quick example, here is how you would map lldap's admin group to grafana's admin
|
||||||
# [[servers.group_mappings]]
|
# [[servers.group_mappings]]
|
||||||
# group_dn = "cn=lldap_admin,ou=groups,dc=example,dc=org"
|
# 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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
DISPLAY_NAME=$(jq -r .displayName <<< $USER_JSON)
|
DISPLAY_NAME=$(jq -r '.displayName // .id' <<< $USER_JSON)
|
||||||
|
|
||||||
IS_ADMIN=false
|
IS_ADMIN=false
|
||||||
if [[ ! -z "$3" ]] && jq -e '.groups|map(.displayName)|index("'"$3"'")' <<< "$USER_JSON" > /dev/null 2>&1; then
|
if [[ ! -z "$3" ]] && jq -e '.groups|map(.displayName)|index("'"$3"'")' <<< "$USER_JSON" > /dev/null 2>&1; then
|
||||||
|
|||||||
@@ -58,9 +58,9 @@ services:
|
|||||||
- LDAP_SEARCH_BASE=ou=people,dc=example,dc=com
|
- LDAP_SEARCH_BASE=ou=people,dc=example,dc=com
|
||||||
- LDAP_BIND_DN=uid=admin,ou=people,dc=example,dc=com
|
- LDAP_BIND_DN=uid=admin,ou=people,dc=example,dc=com
|
||||||
- LDAP_BIND_PW=adminpassword
|
- LDAP_BIND_PW=adminpassword
|
||||||
- LDAP_QUERY_FILTER_USER=(&(objectClass=inetOrgPerson)(|(uid=%u)(mail=%u)))
|
- LDAP_QUERY_FILTER_USER=(&(objectClass=inetOrgPerson)(mail=%s))
|
||||||
- LDAP_QUERY_FILTER_GROUP=(&(objectClass=groupOfUniqueNames)(uid=%s))
|
- LDAP_QUERY_FILTER_GROUP=(&(objectClass=groupOfUniqueNames)(uid=%s))
|
||||||
- LDAP_QUERY_FILTER_ALIAS=(&(objectClass=inetOrgPerson)(|(uid=%u)(mail=%u)))
|
- LDAP_QUERY_FILTER_ALIAS=(&(objectClass=inetOrgPerson)(mail=%s))
|
||||||
- LDAP_QUERY_FILTER_DOMAIN=(mail=*@%s)
|
- LDAP_QUERY_FILTER_DOMAIN=(mail=*@%s)
|
||||||
# <<< Postfix LDAP Integration
|
# <<< Postfix LDAP Integration
|
||||||
# >>> Dovecot LDAP Integration
|
# >>> Dovecot LDAP Integration
|
||||||
@@ -78,7 +78,8 @@ services:
|
|||||||
container_name: roundcubemail
|
container_name: roundcubemail
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- roundcube_data:/var/www/html
|
- roundcube_config:/var/roundcube/config
|
||||||
|
- roundcube_plugins:/var/www/html/plugins
|
||||||
ports:
|
ports:
|
||||||
- "9002:80"
|
- "9002:80"
|
||||||
environment:
|
environment:
|
||||||
@@ -86,12 +87,15 @@ services:
|
|||||||
- ROUNDCUBEMAIL_SKIN=elastic
|
- ROUNDCUBEMAIL_SKIN=elastic
|
||||||
- ROUNDCUBEMAIL_DEFAULT_HOST=mailserver # IMAP
|
- ROUNDCUBEMAIL_DEFAULT_HOST=mailserver # IMAP
|
||||||
- ROUNDCUBEMAIL_SMTP_SERVER=mailserver # SMTP
|
- ROUNDCUBEMAIL_SMTP_SERVER=mailserver # SMTP
|
||||||
|
- ROUNDCUBEMAIL_COMPOSER_PLUGINS=roundcube/carddav
|
||||||
|
- ROUNDCUBEMAIL_PLUGINS=carddav
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mailserver-data:
|
mailserver-data:
|
||||||
mailserver-config:
|
mailserver-config:
|
||||||
mailserver-state:
|
mailserver-state:
|
||||||
lldap_data:
|
lldap_data:
|
||||||
roundcube_data:
|
roundcube_config:
|
||||||
|
roundcube_plugins:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 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
|
||||||
@@ -92,6 +92,9 @@ Enable the following options on the OPNsense configuration page for your LLDAP s
|
|||||||
- Synchronize groups: `Checked`
|
- Synchronize groups: `Checked`
|
||||||
- Automatic user creation: `Checked`
|
- Automatic user creation: `Checked`
|
||||||
|
|
||||||
|
### Constraint Groups
|
||||||
|
This limits the groups to prevent injection attacks. If you want to enable this feature, you need to add ou=groups,dc=example,dc=com to the Authentication Containers field. Be sure to separate with a semicolon. Otherwise disable this option.
|
||||||
|
|
||||||
### Create OPNsense Group
|
### Create OPNsense Group
|
||||||
|
|
||||||
Go to `System > Access > Groups` and create a new group with the **same** name as the LLDAP group used to authenticate users for OPNsense.
|
Go to `System > Access > Groups` and create a new group with the **same** name as the LLDAP group used to authenticate users for OPNsense.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user