Compare commits

..

32 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] e2d9d47623 Update group modification time when adding or removing users from groups
Co-authored-by: nitnelave <796633+nitnelave@users.noreply.github.com>
2025-08-27 19:20:21 +00:00
copilot-swe-agent[bot] edf22afda0 Update user modification time when changing password
Added modified_date update to OPAQUE password registration to ensure both password_modified_date and user modified_date are updated when passwords change. This provides better compatibility with LDAP clients that rely on modifyTimestamp for cache invalidation.

Co-authored-by: nitnelave <796633+nitnelave@users.noreply.github.com>
2025-08-27 19:00:49 +00:00
copilot-swe-agent[bot] 7e64e061d3 Fix clippy collapsible-if warnings in LDAP search code
Co-authored-by: nitnelave <796633+nitnelave@users.noreply.github.com>
2025-08-27 17:30:15 +00:00
copilot-swe-agent[bot] 233262efa6 Fix tests and formatting for modifyTimestamp implementation
Co-authored-by: nitnelave <796633+nitnelave@users.noreply.github.com>
2025-08-27 08:03:21 +00:00
copilot-swe-agent[bot] b8b48ebe24 Set modification timestamps for new users and groups during creation
Co-authored-by: nitnelave <796633+nitnelave@users.noreply.github.com>
2025-08-27 07:42:40 +00:00
copilot-swe-agent[bot] 8a8eb4157c Address review feedback: remove backup file, initialize timestamps with current time, move attributes to Public schema
Co-authored-by: nitnelave <796633+nitnelave@users.noreply.github.com>
2025-08-26 22:51:27 +00:00
copilot-swe-agent[bot] 1c92ae60d3 Complete modifyTimestamp implementation - fix remaining test compilation errors
Co-authored-by: nitnelave <796633+nitnelave@users.noreply.github.com>
2025-08-26 20:51:47 +00:00
copilot-swe-agent[bot] f7ab6ded36 Fix database migration default values for modify timestamps
Co-authored-by: nitnelave <796633+nitnelave@users.noreply.github.com>
2025-08-26 20:48:37 +00:00
copilot-swe-agent[bot] a90695a6ce Implement core modifyTimestamp functionality with database migration and backend support
Co-authored-by: nitnelave <796633+nitnelave@users.noreply.github.com>
2025-08-26 20:41:32 +00:00
copilot-swe-agent[bot] df49d827d0 Initial plan 2025-08-26 20:11:25 +00:00
Valentin Tolmer 267f08f479 github: Remove CODEOWNERS 2025-08-21 22:11:35 +02:00
copilot-swe-agent[bot] b370360130 Add memberOf attribute definition to LDAP schema 2025-08-21 22:07:02 +02:00
Valentin Tolmer 7438fe92cf github: pin the CI rust version to 1.85.0 2025-08-21 02:24:05 +02:00
copilot-swe-agent[bot] cd2694d7dc Add comprehensive GitHub Copilot instructions for LLDAP repository
Add copilot-setup-steps.yml for GitHub Copilot agent environment setup
2025-08-21 01:22:31 +02:00
Valentin Tolmer 5e83ed8eb0 release: v0.6.2 2025-08-18 00:06:44 +02:00
Kirill Zhuravlev c69957690e docs: avoid bad-sounding words in secrets example 2025-08-17 23:10:45 +02:00
Linus Astel 7ef2af8beb devcontainer: Bump Rust version 2025-08-14 22:38:45 +02:00
Toby 5c9897b156 ldap: Add missing subschema entries 2025-08-14 16:04:28 +02:00
ibizaman 0b720aa082 bootstrap: fine grained cleanup 2025-08-13 09:36:21 +02:00
dependabot[bot] 3e7277e77d build(deps): bump actions/checkout from 4.2.2 to 5.0.0
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.2.2 to 5.0.0.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4.2.2...v5.0.0)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-12 08:02:59 +02:00
ibizaman 5241626a3a bootstrap: make password_file a standard custom attribute
Otherwise the bootstrap script tries to create the password_file
as a custom attribute which fails since it's not in the schema.
And anyway, it shouldn't be in the schema.
2025-08-06 22:13:22 +02:00
Valentin Tolmer 363ef106e2 app: Fix attribute type parsing 2025-07-30 01:02:47 +02:00
ibizaman 3c7e4c3dec bootstrap: do not leak password in process list 2025-07-22 08:51:35 +02:00
Valentin Tolmer fa196a9fd9 docker: try several GPG server
Sometimes the docker build fails because the gpg server is intermittently unavailable
2025-07-22 01:10:25 +02:00
ibizaman f02b365478 bootstrap: do not fail if no user or group defined 2025-07-21 23:35:49 +02:00
Valentin Tolmer 0b0e6ae2cd github: Fix warnings about Dockerfile syntax 2025-07-21 23:23:37 +02:00
Valentin Tolmer da525fc99b app: simplify attribute_type handling, display creation time in user details
In the user table it's still only the date, but that makes sense for an overview
2025-07-21 23:15:46 +02:00
ibizaman 78337bce72 bootstrap: allow to give password from a file 2025-07-16 23:51:21 +02:00
selfhoster1312 87e9311a44 meta: Fix cargo clippy failures (format strings) 2025-07-16 23:23:08 +02:00
Hendrik Sievers 53e62ecf5a docs: move authelia configuration to markdown file (#1205) 2025-07-13 22:29:09 +02:00
core 10d33a7537 readme: fix broken Iink 2025-07-11 00:52:03 +02:00
copilot-swe-agent[bot] ada438398e set-password: load system certificates
Fixes #1206
2025-07-08 22:46:13 +02:00
71 changed files with 1027 additions and 403 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
FROM rust:1.74 FROM rust:1.85
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
View File
@@ -1 +0,0 @@
* @nitnelave
+159
View File
@@ -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.**
+26
View File
@@ -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
+13 -2
View File
@@ -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; \
+19 -5
View File
@@ -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; \
+4 -4
View File
@@ -87,7 +87,7 @@ 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@v5.0.0
- uses: actions/cache@v4 - uses: actions/cache@v4
with: with:
path: | path: |
@@ -132,7 +132,7 @@ 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@v5.0.0
- uses: actions/cache@v4 - uses: actions/cache@v4
with: with:
path: | path: |
@@ -300,7 +300,7 @@ jobs:
steps: steps:
- name: Checkout scripts - name: Checkout scripts
uses: actions/checkout@v4.2.2 uses: actions/checkout@v5.0.0
with: with:
sparse-checkout: 'scripts' sparse-checkout: 'scripts'
@@ -496,7 +496,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4.2.2 uses: actions/checkout@v5.0.0
- name: Download all artifacts - name: Download all artifacts
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
+26 -20
View File
@@ -8,6 +8,7 @@ on:
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
MSRV: 1.85.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@v5.0.0
- 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 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@v5.0.0
- 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@v5.0.0
- 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@v5.0.0
- 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
+55
View File
@@ -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.
Generated
+6 -3
View File
@@ -2506,7 +2506,7 @@ checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
[[package]] [[package]]
name = "lldap" name = "lldap"
version = "0.6.2-alpha" version = "0.6.2"
dependencies = [ dependencies = [
"actix", "actix",
"actix-files", "actix-files",
@@ -2532,7 +2532,7 @@ dependencies = [
"futures-util", "futures-util",
"graphql_client 0.11.0", "graphql_client 0.11.0",
"hmac 0.12.1", "hmac 0.12.1",
"http 1.1.0", "http 0.2.12",
"juniper", "juniper",
"jwt 0.16.0", "jwt 0.16.0",
"ldap3", "ldap3",
@@ -2599,11 +2599,12 @@ dependencies = [
[[package]] [[package]]
name = "lldap_app" name = "lldap_app"
version = "0.6.2-alpha" version = "0.6.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.13.1", "base64 0.13.1",
"chrono", "chrono",
"derive_more 1.0.0",
"gloo-console", "gloo-console",
"gloo-file", "gloo-file",
"gloo-net", "gloo-net",
@@ -2618,6 +2619,7 @@ dependencies = [
"rand 0.8.5", "rand 0.8.5",
"serde", "serde",
"serde_json", "serde_json",
"strum 0.25.0",
"url-escape", "url-escape",
"validator", "validator",
"validator_derive", "validator_derive",
@@ -2727,6 +2729,7 @@ dependencies = [
"lldap_domain_handlers", "lldap_domain_handlers",
"lldap_domain_model", "lldap_domain_model",
"lldap_ldap", "lldap_ldap",
"lldap_opaque_handler",
"lldap_sql_backend_handler", "lldap_sql_backend_handler",
"lldap_test_utils", "lldap_test_utils",
"lldap_validation", "lldap_validation",
+1 -1
View File
@@ -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)
+10 -1
View File
@@ -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"]
@@ -55,6 +55,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 +78,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"
+4 -6
View File
@@ -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}
/> />
} }
} }
+6 -7
View File
@@ -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>
+4 -6
View File
@@ -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 {
@@ -310,14 +308,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}
/> />
} }
} }
+6 -7
View File
@@ -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>
+5 -5
View File
@@ -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()} />
}; };
@@ -82,7 +82,7 @@ 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>,
} }
@@ -94,7 +94,7 @@ pub fn single_attribute_input(props: &SingleAttributeInputProps) -> Html {
<AttributeLabel name={props.name.clone()} /> <AttributeLabel name={props.name.clone()} />
<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,7 +105,7 @@ 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>,
} }
@@ -165,7 +165,7 @@ impl Component for ListAttributeInput {
{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
+3 -5
View File
@@ -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 {
+2 -3
View File
@@ -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()}
/> />
} }
+3 -5
View File
@@ -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>
+3 -5
View File
@@ -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 -3
View File
@@ -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>
} }
} }
+3 -5
View File
@@ -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>
+17 -2
View File
@@ -13,7 +13,12 @@ pub mod group {
"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,
@@ -60,7 +65,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,
+31 -55
View File
@@ -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);
}
}
+3
View File
@@ -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,
} }
} }
} }
+8
View File
@@ -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,
} }
} }
} }
+2 -2
View File
@@ -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> {
+27
View File
@@ -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 -1
View File
@@ -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()
} }
} }
@@ -546,6 +546,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 +561,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 +658,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 +668,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)]
+15 -11
View File
@@ -55,20 +55,24 @@ version = "1"
mockall = "0.11.4" mockall = "0.11.4"
pretty_assertions = "1" pretty_assertions = "1"
#[dev-dependencies.lldap_auth] [dev-dependencies.lldap_auth]
#path = "../auth" path = "../auth"
#features = ["test"] features = ["test"]
#
#[dev-dependencies.lldap_opaque_handler] [dev-dependencies.lldap_domain]
#path = "../opaque-handler" path = "../domain"
#features = ["test"] features = ["test"]
[dev-dependencies.lldap_opaque_handler]
path = "../opaque-handler"
features = ["test"]
[dev-dependencies.lldap_test_utils] [dev-dependencies.lldap_test_utils]
path = "../test-utils" path = "../test-utils"
#
#[dev-dependencies.lldap_sql_backend_handler] [dev-dependencies.lldap_sql_backend_handler]
#path = "../sql-backend-handler" path = "../sql-backend-handler"
#features = ["test"] features = ["test"]
[dev-dependencies.tokio] [dev-dependencies.tokio]
features = ["full"] features = ["full"]
+1 -1
View File
@@ -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::*;
+48
View File
@@ -716,6 +716,8 @@ impl<Handler: BackendHandler> AttributeValue<Handler> {
let value: Option<DomainAttributeValue> = match attribute_schema.name.as_str() { let value: Option<DomainAttributeValue> = match attribute_schema.name.as_str() {
"user_id" => Some(user.user_id.clone().into_string().into()), "user_id" => Some(user.user_id.clone().into_string().into()),
"creation_date" => Some(user.creation_date.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()), "mail" => Some(user.email.clone().into_string().into()),
"uuid" => Some(user.uuid.clone().into_string().into()), "uuid" => Some(user.uuid.clone().into_string().into()),
"display_name" => user.display_name.as_ref().map(|d| d.clone().into()), "display_name" => user.display_name.as_ref().map(|d| d.clone().into()),
@@ -760,6 +762,7 @@ impl<Handler: BackendHandler> AttributeValue<Handler> {
match attribute_schema.name.as_str() { match attribute_schema.name.as_str() {
"group_id" => (group.id.0 as i64).into(), "group_id" => (group.id.0 as i64).into(),
"creation_date" => group.creation_date.into(), "creation_date" => group.creation_date.into(),
"modified_date" => group.modified_date.into(),
"uuid" => group.uuid.clone().into_string().into(), "uuid" => group.uuid.clone().into_string().into(),
"display_name" => group.display_name.clone().into_string().into(), "display_name" => group.display_name.clone().into_string().into(),
_ => panic!("Unexpected hardcoded attribute: {}", attribute_schema.name), _ => panic!("Unexpected hardcoded attribute: {}", attribute_schema.name),
@@ -802,6 +805,7 @@ impl<Handler: BackendHandler> AttributeValue<Handler> {
match attribute_schema.name.as_str() { match attribute_schema.name.as_str() {
"group_id" => (group.group_id.0 as i64).into(), "group_id" => (group.group_id.0 as i64).into(),
"creation_date" => group.creation_date.into(), "creation_date" => group.creation_date.into(),
"modified_date" => group.modified_date.into(),
"uuid" => group.uuid.clone().into_string().into(), "uuid" => group.uuid.clone().into_string().into(),
"display_name" => group.display_name.clone().into_string().into(), "display_name" => group.display_name.clone().into_string().into(),
_ => panic!("Unexpected hardcoded attribute: {}", attribute_schema.name), _ => panic!("Unexpected hardcoded attribute: {}", attribute_schema.name),
@@ -958,6 +962,7 @@ mod tests {
name: "club_name".into(), name: "club_name".into(),
value: "Gang of Four".to_string().into(), value: "Gang of Four".to_string().into(),
}], }],
modified_date: chrono::Utc.timestamp_nanos(42).naive_utc(),
}); });
groups.insert(GroupDetails { groups.insert(GroupDetails {
group_id: GroupId(7), group_id: GroupId(7),
@@ -965,6 +970,7 @@ mod tests {
creation_date: chrono::Utc.timestamp_nanos(12).naive_utc(), creation_date: chrono::Utc.timestamp_nanos(12).naive_utc(),
uuid: lldap_domain::uuid!("b1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"), uuid: lldap_domain::uuid!("b1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
attributes: Vec::new(), attributes: Vec::new(),
modified_date: chrono::Utc.timestamp_nanos(12).naive_utc(),
}); });
mock.expect_get_user_groups() mock.expect_get_user_groups()
.with(eq(UserId::new("bob"))) .with(eq(UserId::new("bob")))
@@ -993,6 +999,14 @@ mod tests {
"name": "mail", "name": "mail",
"value": ["bob@bobbers.on"], "value": ["bob@bobbers.on"],
}, },
{
"name": "modified_date",
"value": ["1970-01-01T00:00:00+00:00"],
},
{
"name": "password_modified_date",
"value": ["1970-01-01T00:00:00+00:00"],
},
{ {
"name": "user_id", "name": "user_id",
"value": ["bob"], "value": ["bob"],
@@ -1026,6 +1040,10 @@ mod tests {
"name": "group_id", "name": "group_id",
"value": ["3"], "value": ["3"],
}, },
{
"name": "modified_date",
"value": ["1970-01-01T00:00:00.000000042+00:00"],
},
{ {
"name": "uuid", "name": "uuid",
"value": ["a1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8"], "value": ["a1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8"],
@@ -1053,6 +1071,10 @@ mod tests {
"name": "group_id", "name": "group_id",
"value": ["7"], "value": ["7"],
}, },
{
"name": "modified_date",
"value": ["1970-01-01T00:00:00.000000012+00:00"],
},
{ {
"name": "uuid", "name": "uuid",
"value": ["b1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8"], "value": ["b1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8"],
@@ -1246,6 +1268,22 @@ mod tests {
"isEditable": true, "isEditable": true,
"isHardcoded": true, "isHardcoded": true,
}, },
{
"name": "modified_date",
"attributeType": "DATE_TIME",
"isList": false,
"isVisible": true,
"isEditable": false,
"isHardcoded": true,
},
{
"name": "password_modified_date",
"attributeType": "DATE_TIME",
"isList": false,
"isVisible": true,
"isEditable": false,
"isHardcoded": true,
},
{ {
"name": "user_id", "name": "user_id",
"attributeType": "STRING", "attributeType": "STRING",
@@ -1291,6 +1329,14 @@ mod tests {
"isEditable": false, "isEditable": false,
"isHardcoded": true, "isHardcoded": true,
}, },
{
"name": "modified_date",
"attributeType": "DATE_TIME",
"isList": false,
"isVisible": true,
"isEditable": false,
"isHardcoded": true,
},
{ {
"name": "uuid", "name": "uuid",
"attributeType": "STRING", "attributeType": "STRING",
@@ -1365,6 +1411,8 @@ mod tests {
{"name": "creation_date"}, {"name": "creation_date"},
{"name": "display_name"}, {"name": "display_name"},
{"name": "mail"}, {"name": "mail"},
{"name": "modified_date"},
{"name": "password_modified_date"},
{"name": "user_id"}, {"name": "user_id"},
{"name": "uuid"}, {"name": "uuid"},
], ],
+2
View File
@@ -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;
+16 -8
View File
@@ -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
) )
} }
_ => { _ => {
@@ -211,7 +216,7 @@ fn convert_group_filter(
.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,
@@ -261,6 +266,10 @@ 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) => Ok(GroupRequestFilter::And(
@@ -290,15 +299,14 @@ fn convert_group_filter(
_ => 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 +326,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:#}"#),
}) })
} }
+16 -8
View File
@@ -93,6 +93,18 @@ pub fn get_user_attribute(
.to_rfc3339() .to_rfc3339()
.into_bytes(), .into_bytes(),
], ],
UserFieldType::PrimaryField(UserColumn::ModifiedDate) => vec![
chrono::Utc
.from_utc_datetime(&user.modified_date)
.to_rfc3339()
.into_bytes(),
],
UserFieldType::PrimaryField(UserColumn::PasswordModifiedDate) => vec![
chrono::Utc
.from_utc_datetime(&user.password_modified_date)
.to_rfc3339()
.into_bytes(),
],
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 +112,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
) )
} }
_ => { _ => {
@@ -298,10 +309,7 @@ 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::from(false)),
UserFieldType::PrimaryField(UserColumn::Email) => Ok(UserRequestFilter::SubString( UserFieldType::PrimaryField(UserColumn::Email) => Ok(UserRequestFilter::SubString(
@@ -316,7 +324,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 +349,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:#}"#),
}) })
} }
+29 -14
View File
@@ -66,10 +66,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 +104,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 +115,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 +239,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 +263,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 +279,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,
@@ -343,7 +348,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 +442,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 {
+5 -11
View File
@@ -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,
+7 -8
View File
@@ -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()
+5 -7
View File
@@ -100,10 +100,7 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
backend_handler, backend_handler,
ldap_info: LdapInfo { ldap_info: LdapInfo {
base_dn: parse_distinguished_name(&ldap_base_dn).unwrap_or_else(|_| { base_dn: parse_distinguished_name(&ldap_base_dn).unwrap_or_else(|_| {
panic!( panic!("Invalid value for ldap_base_dn in configuration: {ldap_base_dn}")
"Invalid value for ldap_base_dn in configuration: {}",
ldap_base_dn
)
}), }),
base_dn_str: ldap_base_dn, base_dn_str: ldap_base_dn,
ignored_user_attributes, ignored_user_attributes,
@@ -155,7 +152,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)),
@@ -224,7 +221,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 => {
@@ -343,7 +340,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 +398,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)
}); });
+6 -5
View File
@@ -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 {
+5 -4
View File
@@ -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")))
+132 -39
View File
@@ -202,25 +202,66 @@ pub fn make_ldap_subschema_entry(schema: PublicSchema) -> LdapOp {
LdapPartialAttribute { LdapPartialAttribute {
atype: "ldapSyntaxes".to_string(), atype: "ldapSyntaxes".to_string(),
vals: vec![ vals: vec![
b"( 1.3.6.1.1.16.1 DESC 'UUID' )".to_vec(),
b"( 1.3.6.1.4.1.1466.115.121.1.3 DESC 'Attribute Type Description' )".to_vec(),
b"( 1.3.6.1.4.1.1466.115.121.1.12 DESC 'Distinguished Name' )".to_vec(),
b"( 1.3.6.1.4.1.1466.115.121.1.15 DESC 'Directory String' )".to_vec(), b"( 1.3.6.1.4.1.1466.115.121.1.15 DESC 'Directory String' )".to_vec(),
b"( 1.3.6.1.4.1.1466.115.121.1.24 DESC 'Generalized Time' )".to_vec(), b"( 1.3.6.1.4.1.1466.115.121.1.24 DESC 'Generalized Time' )".to_vec(),
b"( 1.3.6.1.4.1.1466.115.121.1.27 DESC 'Integer' )".to_vec(), b"( 1.3.6.1.4.1.1466.115.121.1.27 DESC 'Integer' )".to_vec(),
b"( 1.3.6.1.4.1.1466.115.121.1.28 DESC 'JPEG' X-NOT-HUMAN-READABLE 'TRUE' )".to_vec(), b"( 1.3.6.1.4.1.1466.115.121.1.28 DESC 'JPEG' X-NOT-HUMAN-READABLE 'TRUE' )".to_vec(),
b"( 1.3.6.1.4.1.1466.115.121.1.34 DESC 'Name And Optional UID' )".to_vec(),
b"( 1.3.6.1.4.1.1466.115.121.1.37 DESC 'Object Class Description' )".to_vec(),
b"( 1.3.6.1.4.1.1466.115.121.1.38 DESC 'OID' )".to_vec(),
b"( 1.3.6.1.4.1.1466.115.121.1.54 DESC 'LDAP Syntax Description' )".to_vec(),
b"( 1.3.6.1.4.1.1466.115.121.1.58 DESC 'Substring Assertion' )".to_vec(),
],
},
LdapPartialAttribute {
atype: "matchingRules".to_string(),
vals: vec![
b"( 1.3.6.1.1.16.2 NAME 'UUIDMatch' SYNTAX 1.3.6.1.1.16.1 )".to_vec(),
b"( 1.3.6.1.1.16.3 NAME 'UUIDOrderingMatch' SYNTAX 1.3.6.1.1.16.1 )".to_vec(),
b"( 2.5.13.0 NAME 'objectIdentifierMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 )".to_vec(),
b"( 2.5.13.1 NAME 'distinguishedNameMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 )".to_vec(),
b"( 2.5.13.2 NAME 'caseIgnoreMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )".to_vec(),
b"( 2.5.13.4 NAME 'caseIgnoreSubstringsMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.58 )".to_vec(),
b"( 2.5.13.23 NAME 'uniqueMemberMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.34 )".to_vec(),
b"( 2.5.13.27 NAME 'generalizedTimeMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 )".to_vec(),
b"( 2.5.13.28 NAME 'generalizedTimeOrderingMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 )".to_vec(),
b"( 2.5.13.30 NAME 'objectIdentifierFirstComponentMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 )".to_vec(),
], ],
}, },
LdapPartialAttribute { LdapPartialAttribute {
atype: "attributeTypes".to_string(), atype: "attributeTypes".to_string(),
vals: { vals: {
let hardcoded_attributes = [ let hardcoded_attributes = [
b"( 2.0 NAME 'String' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )".to_vec(), b"( 0.9.2342.19200300.100.1.1 NAME ( 'uid' 'userid' 'user_id' ) DESC 'RFC4519: user identifier' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} SINGLE-VALUE NO-USER-MODIFICATION )".to_vec(),
b"( 2.1 NAME 'Integer' SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 )".to_vec(), b"( 1.2.840.113556.1.2.102 NAME 'memberOf' DESC 'Group that the entry belongs to' EQUALITY distinguishedNameMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 NO-USER-MODIFICATION USAGE dSAOperation X-ORIGIN 'iPlanet Delegated Administrator' )".to_vec(),
b"( 2.2 NAME 'JpegPhoto' SYNTAX 1.3.6.1.4.1.1466.115.121.1.28 )".to_vec(), b"( 1.3.6.1.1.16.4 NAME ( 'entryUUID' 'uuid' ) DESC 'UUID of the entry' EQUALITY UUIDMatch ORDERING UUIDOrderingMatch SYNTAX 1.3.6.1.1.16.1 SINGLE-VALUE NO-USER-MODIFICATION USAGE directoryOperation )".to_vec(),
b"( 2.3 NAME 'DateTime' SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 )".to_vec(), b"( 1.3.6.1.4.1.1466.101.120.16 NAME 'ldapSyntaxes' DESC 'RFC4512: LDAP syntaxes' EQUALITY objectIdentifierFirstComponentMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.54 USAGE directoryOperation )".to_vec(),
b"( 2.5.4.0 NAME 'objectClass' DESC 'RFC4512: object classes of the entity' EQUALITY objectIdentifierMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 )".to_vec(),
b"( 2.5.4.3 NAME ( 'cn' 'commonName' 'display_name' ) DESC 'RFC4519: common name(s) for which the entity is known by' SUP name SINGLE-VALUE )".to_vec(),
b"( 2.5.4.4 NAME ( 'sn' 'surname' 'last_name' ) DESC 'RFC2256: last (family) name(s) for which the entity is known by' SUP name SINGLE-VALUE )".to_vec(),
b"( 2.5.4.11 NAME ( 'ou' 'organizationalUnitName' ) DESC 'RFC2256: organizational unit this object belongs to' SUP name )".to_vec(),
b"( 2.5.4.41 NAME 'name' DESC 'RFC4519: common supertype of name attributes' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{32768} )".to_vec(),
b"( 2.5.4.49 NAME 'distinguishedName' DESC 'RFC4519: common supertype of DN attributes' EQUALITY distinguishedNameMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 )".to_vec(),
b"( 2.5.4.50 NAME ( 'uniqueMember' 'member' ) DESC 'RFC2256: unique member of a group' EQUALITY uniqueMemberMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.34 )".to_vec(),
b"( 2.5.18.1 NAME ( 'createTimestamp' 'creation_date' ) DESC 'RFC4512: time which object was created' EQUALITY generalizedTimeMatch ORDERING generalizedTimeOrderingMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 SINGLE-VALUE NO-USER-MODIFICATION USAGE directoryOperation )".to_vec(),
b"( 2.5.18.2 NAME 'modifyTimestamp' DESC 'RFC4512: time which object was last modified' EQUALITY generalizedTimeMatch ORDERING generalizedTimeOrderingMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 SINGLE-VALUE NO-USER-MODIFICATION USAGE directoryOperation )".to_vec(),
b"( 2.5.21.5 NAME 'attributeTypes' DESC 'RFC4512: attribute types' EQUALITY objectIdentifierFirstComponentMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.3 USAGE directoryOperation )".to_vec(),
b"( 2.5.21.6 NAME 'objectClasses' DESC 'RFC4512: object classes' EQUALITY objectIdentifierFirstComponentMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.37 USAGE directoryOperation )".to_vec(),
b"( 2.5.21.9 NAME 'structuralObjectClass' DESC 'RFC4512: structural object class of entry' EQUALITY objectIdentifierMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 SINGLE-VALUE NO-USER-MODIFICATION USAGE directoryOperation )".to_vec(),
b"( 10.0 NAME 'String' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )".to_vec(),
b"( 10.1 NAME 'Integer' SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 )".to_vec(),
b"( 10.2 NAME 'JpegPhoto' SYNTAX 1.3.6.1.4.1.1466.115.121.1.28 )".to_vec(),
b"( 10.3 NAME 'DateTime' SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 )".to_vec(),
]; ];
let num_hardcoded_attributes = hardcoded_attributes.len();
hardcoded_attributes.into_iter().chain( hardcoded_attributes.into_iter().chain(
ldap_schema_description ldap_schema_description
.formatted_attribute_list(num_hardcoded_attributes) .formatted_attribute_list(
4, // The number of hardcoded attributes starting with "10." (LLDAP custom range)
vec!["creation_date", "display_name", "last_name", "user_id", "uuid"]
)
).collect() ).collect()
} }
}, },
@@ -250,12 +291,12 @@ pub fn make_ldap_subschema_entry(schema: PublicSchema) -> LdapOp {
} }
pub(crate) fn is_root_dse_request(request: &LdapSearchRequest) -> bool { pub(crate) fn is_root_dse_request(request: &LdapSearchRequest) -> bool {
if request.base.is_empty() && request.scope == LdapSearchScope::Base { if request.base.is_empty()
if let LdapFilter::Present(attribute) = &request.filter { && request.scope == LdapSearchScope::Base
if attribute.eq_ignore_ascii_case("objectclass") { && let LdapFilter::Present(attribute) = &request.filter
return true; && attribute.eq_ignore_ascii_case("objectclass")
} {
} return true;
} }
false false
} }
@@ -369,7 +410,7 @@ pub async fn do_search(
) -> LdapResult<Vec<LdapOp>> { ) -> LdapResult<Vec<LdapOp>> {
let schema = PublicSchema::from(backend_handler.get_schema().await.map_err(|e| LdapError { let schema = PublicSchema::from(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:#}"),
})?); })?);
let search_results = do_search_internal(ldap_info, backend_handler, request, &schema).await?; let search_results = do_search_internal(ldap_info, backend_handler, request, &schema).await?;
let mut results = match search_results { let mut results = match search_results {
@@ -485,7 +526,7 @@ mod tests {
}; };
let attrs = &search_result_entry.attributes; let attrs = &search_result_entry.attributes;
assert_eq!(attrs.len(), 9); assert_eq!(attrs.len(), 10);
assert_eq!(search_result_entry.dn, "cn=Subschema".to_owned()); assert_eq!(search_result_entry.dn, "cn=Subschema".to_owned());
assert_eq!( assert_eq!(
@@ -530,58 +571,92 @@ mod tests {
LdapPartialAttribute { LdapPartialAttribute {
atype: "ldapSyntaxes".to_owned(), atype: "ldapSyntaxes".to_owned(),
vals: vec![ vals: vec![
b"( 1.3.6.1.1.16.1 DESC 'UUID' )".to_vec(),
b"( 1.3.6.1.4.1.1466.115.121.1.3 DESC 'Attribute Type Description' )".to_vec(),
b"( 1.3.6.1.4.1.1466.115.121.1.12 DESC 'Distinguished Name' )".to_vec(),
b"( 1.3.6.1.4.1.1466.115.121.1.15 DESC 'Directory String' )".to_vec(), b"( 1.3.6.1.4.1.1466.115.121.1.15 DESC 'Directory String' )".to_vec(),
b"( 1.3.6.1.4.1.1466.115.121.1.24 DESC 'Generalized Time' )".to_vec(), b"( 1.3.6.1.4.1.1466.115.121.1.24 DESC 'Generalized Time' )".to_vec(),
b"( 1.3.6.1.4.1.1466.115.121.1.27 DESC 'Integer' )".to_vec(), b"( 1.3.6.1.4.1.1466.115.121.1.27 DESC 'Integer' )".to_vec(),
b"( 1.3.6.1.4.1.1466.115.121.1.28 DESC 'JPEG' X-NOT-HUMAN-READABLE 'TRUE' )" b"( 1.3.6.1.4.1.1466.115.121.1.28 DESC 'JPEG' X-NOT-HUMAN-READABLE 'TRUE' )"
.to_vec() .to_vec(),
b"( 1.3.6.1.4.1.1466.115.121.1.34 DESC 'Name And Optional UID' )".to_vec(),
b"( 1.3.6.1.4.1.1466.115.121.1.37 DESC 'Object Class Description' )".to_vec(),
b"( 1.3.6.1.4.1.1466.115.121.1.38 DESC 'OID' )".to_vec(),
b"( 1.3.6.1.4.1.1466.115.121.1.54 DESC 'LDAP Syntax Description' )".to_vec(),
b"( 1.3.6.1.4.1.1466.115.121.1.58 DESC 'Substring Assertion' )".to_vec(),
] ]
} }
); );
assert_eq!( assert_eq!(
attrs[6], attrs[6],
LdapPartialAttribute {
atype: "matchingRules".to_string(),
vals: vec![
b"( 1.3.6.1.1.16.2 NAME 'UUIDMatch' SYNTAX 1.3.6.1.1.16.1 )".to_vec(),
b"( 1.3.6.1.1.16.3 NAME 'UUIDOrderingMatch' SYNTAX 1.3.6.1.1.16.1 )".to_vec(),
b"( 2.5.13.0 NAME 'objectIdentifierMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 )".to_vec(),
b"( 2.5.13.1 NAME 'distinguishedNameMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 )".to_vec(),
b"( 2.5.13.2 NAME 'caseIgnoreMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )".to_vec(),
b"( 2.5.13.4 NAME 'caseIgnoreSubstringsMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.58 )".to_vec(),
b"( 2.5.13.23 NAME 'uniqueMemberMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.34 )".to_vec(),
b"( 2.5.13.27 NAME 'generalizedTimeMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 )".to_vec(),
b"( 2.5.13.28 NAME 'generalizedTimeOrderingMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 )".to_vec(),
b"( 2.5.13.30 NAME 'objectIdentifierFirstComponentMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 )".to_vec(),
]
}
);
assert_eq!(
attrs[7],
LdapPartialAttribute { LdapPartialAttribute {
atype: "attributeTypes".to_owned(), atype: "attributeTypes".to_owned(),
vals: vec![ vals: vec![
b"( 2.0 NAME 'String' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )".to_vec(), b"( 0.9.2342.19200300.100.1.1 NAME ( 'uid' 'userid' 'user_id' ) DESC 'RFC4519: user identifier' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} SINGLE-VALUE NO-USER-MODIFICATION )".to_vec(),
b"( 2.1 NAME 'Integer' SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 )".to_vec(), b"( 1.2.840.113556.1.2.102 NAME 'memberOf' DESC 'Group that the entry belongs to' EQUALITY distinguishedNameMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 NO-USER-MODIFICATION USAGE dSAOperation X-ORIGIN 'iPlanet Delegated Administrator' )".to_vec(),
b"( 2.2 NAME 'JpegPhoto' SYNTAX 1.3.6.1.4.1.1466.115.121.1.28 )".to_vec(), b"( 1.3.6.1.1.16.4 NAME ( 'entryUUID' 'uuid' ) DESC 'UUID of the entry' EQUALITY UUIDMatch ORDERING UUIDOrderingMatch SYNTAX 1.3.6.1.1.16.1 SINGLE-VALUE NO-USER-MODIFICATION USAGE directoryOperation )".to_vec(),
b"( 2.3 NAME 'DateTime' SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 )".to_vec(), b"( 1.3.6.1.4.1.1466.101.120.16 NAME 'ldapSyntaxes' DESC 'RFC4512: LDAP syntaxes' EQUALITY objectIdentifierFirstComponentMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.54 USAGE directoryOperation )".to_vec(),
b"( 2.4 NAME 'avatar' DESC 'LLDAP: builtin attribute' SUP JpegPhoto )".to_vec(), b"( 2.5.4.0 NAME 'objectClass' DESC 'RFC4512: object classes of the entity' EQUALITY objectIdentifierMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 )".to_vec(),
b"( 2.5 NAME 'creation_date' DESC 'LLDAP: builtin attribute' SUP DateTime )" b"( 2.5.4.3 NAME ( 'cn' 'commonName' 'display_name' ) DESC 'RFC4519: common name(s) for which the entity is known by' SUP name SINGLE-VALUE )".to_vec(),
b"( 2.5.4.4 NAME ( 'sn' 'surname' 'last_name' ) DESC 'RFC2256: last (family) name(s) for which the entity is known by' SUP name SINGLE-VALUE )".to_vec(),
b"( 2.5.4.11 NAME ( 'ou' 'organizationalUnitName' ) DESC 'RFC2256: organizational unit this object belongs to' SUP name )".to_vec(),
b"( 2.5.4.41 NAME 'name' DESC 'RFC4519: common supertype of name attributes' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{32768} )".to_vec(),
b"( 2.5.4.49 NAME 'distinguishedName' DESC 'RFC4519: common supertype of DN attributes' EQUALITY distinguishedNameMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 )".to_vec(),
b"( 2.5.4.50 NAME ( 'uniqueMember' 'member' ) DESC 'RFC2256: unique member of a group' EQUALITY uniqueMemberMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.34 )".to_vec(),
b"( 2.5.18.1 NAME ( 'createTimestamp' 'creation_date' ) DESC 'RFC4512: time which object was created' EQUALITY generalizedTimeMatch ORDERING generalizedTimeOrderingMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 SINGLE-VALUE NO-USER-MODIFICATION USAGE directoryOperation )".to_vec(),
b"( 2.5.18.2 NAME 'modifyTimestamp' DESC 'RFC4512: time which object was last modified' EQUALITY generalizedTimeMatch ORDERING generalizedTimeOrderingMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 SINGLE-VALUE NO-USER-MODIFICATION USAGE directoryOperation )".to_vec(),
b"( 2.5.21.5 NAME 'attributeTypes' DESC 'RFC4512: attribute types' EQUALITY objectIdentifierFirstComponentMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.3 USAGE directoryOperation )".to_vec(),
b"( 2.5.21.6 NAME 'objectClasses' DESC 'RFC4512: object classes' EQUALITY objectIdentifierFirstComponentMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.37 USAGE directoryOperation )".to_vec(),
b"( 2.5.21.9 NAME 'structuralObjectClass' DESC 'RFC4512: structural object class of entry' EQUALITY objectIdentifierMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 SINGLE-VALUE NO-USER-MODIFICATION USAGE directoryOperation )".to_vec(),
b"( 10.0 NAME 'String' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )".to_vec(),
b"( 10.1 NAME 'Integer' SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 )".to_vec(),
b"( 10.2 NAME 'JpegPhoto' SYNTAX 1.3.6.1.4.1.1466.115.121.1.28 )".to_vec(),
b"( 10.3 NAME 'DateTime' SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 )".to_vec(),
b"( 10.4 NAME 'avatar' DESC 'LLDAP: builtin attribute' SUP JpegPhoto )".to_vec(),
b"( 10.5 NAME 'first_name' DESC 'LLDAP: builtin attribute' SUP String )"
.to_vec(), .to_vec(),
b"( 2.6 NAME 'display_name' DESC 'LLDAP: builtin attribute' SUP String )" b"( 10.6 NAME 'mail' DESC 'LLDAP: builtin attribute' SUP String )".to_vec(),
b"( 10.7 NAME 'modified_date' DESC 'LLDAP: builtin attribute' SUP DateTime )".to_vec(),
b"( 10.8 NAME 'password_modified_date' DESC 'LLDAP: builtin attribute' SUP DateTime )".to_vec(),
b"( 10.9 NAME 'group_id' DESC 'LLDAP: builtin attribute' SUP Integer )"
.to_vec(), .to_vec(),
b"( 2.7 NAME 'first_name' DESC 'LLDAP: builtin attribute' SUP String )" b"( 10.10 NAME 'modified_date' DESC 'LLDAP: builtin attribute' SUP DateTime )".to_vec(),
.to_vec(),
b"( 2.8 NAME 'last_name' DESC 'LLDAP: builtin attribute' SUP String )".to_vec(),
b"( 2.9 NAME 'mail' DESC 'LLDAP: builtin attribute' SUP String )".to_vec(),
b"( 2.10 NAME 'user_id' DESC 'LLDAP: builtin attribute' SUP String )".to_vec(),
b"( 2.11 NAME 'uuid' DESC 'LLDAP: builtin attribute' SUP String )".to_vec(),
b"( 2.12 NAME 'creation_date' DESC 'LLDAP: builtin attribute' SUP DateTime )"
.to_vec(),
b"( 2.13 NAME 'display_name' DESC 'LLDAP: builtin attribute' SUP String )"
.to_vec(),
b"( 2.14 NAME 'group_id' DESC 'LLDAP: builtin attribute' SUP Integer )"
.to_vec(),
b"( 2.15 NAME 'uuid' DESC 'LLDAP: builtin attribute' SUP String )".to_vec()
] ]
} }
); );
assert_eq!(attrs[7], assert_eq!(attrs[8],
LdapPartialAttribute { LdapPartialAttribute {
atype: "objectClasses".to_owned(), atype: "objectClasses".to_owned(),
vals: vec![ vals: vec![
b"( 3.0 NAME ( 'inetOrgPerson' 'posixAccount' 'mailAccount' 'person' 'customUserClass' ) DESC 'LLDAP builtin: a person' STRUCTURAL MUST ( mail $ user_id ) MAY ( avatar $ creation_date $ display_name $ first_name $ last_name $ uuid ) )".to_vec(), b"( 3.0 NAME ( 'inetOrgPerson' 'posixAccount' 'mailAccount' 'person' 'customUserClass' ) DESC 'LLDAP builtin: a person' STRUCTURAL MUST ( mail $ user_id ) MAY ( avatar $ creation_date $ display_name $ first_name $ last_name $ modified_date $ password_modified_date $ uuid ) )".to_vec(),
b"( 3.1 NAME ( 'groupOfUniqueNames' 'groupOfNames' ) DESC 'LLDAP builtin: a group' STRUCTURAL MUST ( display_name ) MAY ( creation_date $ group_id $ uuid ) )".to_vec(), b"( 3.1 NAME ( 'groupOfUniqueNames' 'groupOfNames' ) DESC 'LLDAP builtin: a group' STRUCTURAL MUST ( display_name ) MAY ( creation_date $ group_id $ modified_date $ uuid ) )".to_vec(),
] ]
} }
); );
assert_eq!( assert_eq!(
attrs[8], attrs[9],
LdapPartialAttribute { LdapPartialAttribute {
atype: "subschemaSubentry".to_owned(), atype: "subschemaSubentry".to_owned(),
vals: vec![b"cn=Subschema".to_vec()] vals: vec![b"cn=Subschema".to_vec()]
@@ -663,6 +738,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(),
}]), }]),
}]) }])
}); });
@@ -768,6 +844,14 @@ mod tests {
.with_ymd_and_hms(2014, 7, 8, 9, 10, 11) .with_ymd_and_hms(2014, 7, 8, 9, 10, 11)
.unwrap() .unwrap()
.naive_utc(), .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, groups: None,
}, },
@@ -902,6 +986,7 @@ mod tests {
users: vec![UserId::new("bob"), UserId::new("john")], users: vec![UserId::new("bob"), UserId::new("john")],
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(),
}, },
Group { Group {
id: GroupId(3), id: GroupId(3),
@@ -910,6 +995,7 @@ mod tests {
users: vec![UserId::new("john")], users: vec![UserId::new("john")],
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(),
}, },
]) ])
}); });
@@ -1000,6 +1086,7 @@ mod tests {
users: vec![UserId::new("bob"), UserId::new("john")], users: vec![UserId::new("bob"), UserId::new("john")],
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;
@@ -1050,6 +1137,7 @@ mod tests {
users: vec![], users: vec![],
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;
@@ -1121,6 +1209,7 @@ mod tests {
users: vec![], users: vec![],
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;
@@ -1172,6 +1261,7 @@ mod tests {
name: "Attr".into(), name: "Attr".into(),
value: "TEST".to_string().into(), value: "TEST".to_string().into(),
}], }],
modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
}]) }])
}); });
mock.expect_get_schema().returning(|| { mock.expect_get_schema().returning(|| {
@@ -1629,6 +1719,7 @@ mod tests {
users: vec![UserId::new("bob"), UserId::new("john")], users: vec![UserId::new("bob"), UserId::new("john")],
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;
@@ -1713,6 +1804,7 @@ mod tests {
users: vec![UserId::new("bob"), UserId::new("john")], users: vec![UserId::new("bob"), UserId::new("john")],
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;
@@ -1973,6 +2065,7 @@ mod tests {
name: "club_name".into(), name: "club_name".into(),
value: "Breakfast Club".to_string().into(), value: "Breakfast Club".to_string().into(),
}], }],
modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
}]) }])
}); });
mock.expect_get_schema().returning(|| { mock.expect_get_schema().returning(|| {
@@ -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,77 @@ 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?;
// Initialize existing users with modified_date and password_modified_date = now
let now = chrono::Utc::now().naive_utc();
transaction
.execute(
builder.build(
Query::update()
.table(Users::Table)
.value(Users::ModifiedDate, now)
.value(Users::PasswordModifiedDate, now),
),
)
.await?;
// Initialize existing groups with modified_date = now
transaction
.execute(
builder.build(
Query::update()
.table(Groups::Table)
.value(Groups::ModifiedDate, now),
),
)
.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 +1216,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?;
+1 -1
View File
@@ -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]);
@@ -190,11 +190,13 @@ impl SqlBackendHandler {
request: UpdateUserRequest, request: UpdateUserRequest,
) -> Result<()> { ) -> Result<()> {
let lower_email = request.email.as_ref().map(|s| s.as_str().to_lowercase()); 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 { let update_user = model::users::ActiveModel {
user_id: ActiveValue::Set(request.user_id.clone()), user_id: ActiveValue::Set(request.user_id.clone()),
email: request.email.map(ActiveValue::Set).unwrap_or_default(), email: request.email.map(ActiveValue::Set).unwrap_or_default(),
lowercase_email: lower_email.map(ActiveValue::Set).unwrap_or_default(), lowercase_email: lower_email.map(ActiveValue::Set).unwrap_or_default(),
display_name: to_value(&request.display_name), display_name: to_value(&request.display_name),
modified_date: ActiveValue::Set(now),
..Default::default() ..Default::default()
}; };
let mut update_user_attributes = Vec::new(); let mut update_user_attributes = Vec::new();
@@ -240,8 +242,7 @@ 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
))); )));
} }
} }
@@ -326,6 +327,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 +387,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 +395,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 new_membership = model::memberships::ActiveModel { let user_id_owned = user_id.clone();
user_id: ActiveValue::Set(user_id.clone()), self.sql_pool
group_id: ActiveValue::Set(group_id), .transaction::<_, _, sea_orm::DbErr>(|transaction| {
}; Box::pin(async move {
new_membership.insert(&self.sql_pool).await?; let new_membership = model::memberships::ActiveModel {
user_id: ActiveValue::Set(user_id_owned),
group_id: ActiveValue::Set(group_id),
};
new_membership.insert(transaction).await?;
// Update group modification time
let now = chrono::Utc::now().naive_utc();
let update_group = model::groups::ActiveModel {
group_id: Set(group_id),
modified_date: Set(now),
..Default::default()
};
update_group.update(transaction).await?;
Ok(())
})
})
.await?;
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 res = model::Membership::delete_by_id((user_id.clone(), group_id)) let user_id_owned = user_id.clone();
.exec(&self.sql_pool) self.sql_pool
.await?; .transaction::<_, _, sea_orm::DbErr>(|transaction| {
if res.rows_affected == 0 { Box::pin(async move {
return Err(DomainError::EntityNotFound(format!( let res = model::Membership::delete_by_id((user_id_owned.clone(), group_id))
"No such membership: '{}' -> {:?}", .exec(transaction)
user_id, group_id .await?;
))); if res.rows_affected == 0 {
} return Err(sea_orm::DbErr::Custom(format!(
"No such membership: '{user_id_owned}' -> {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(())
} }
} }
+2 -2
View File
@@ -55,8 +55,8 @@ Then you'll receive a JSON response with:
``` ```
{ {
"token": "eYbat...", "token": "Yh6RJV...",
"refreshToken": "3bCka...", "refreshToken": "dww5jwU...",
} }
``` ```
+1 -1
View File
@@ -6,7 +6,7 @@ configuration files:
- [Airsonic Advanced](airsonic-advanced.md) - [Airsonic Advanced](airsonic-advanced.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)
+39
View File
@@ -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
```
-35
View File
@@ -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))"
+3
View File
@@ -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
+2 -2
View File
@@ -56,7 +56,7 @@ FILTER = memberOf=cn=seafile_user,ou=groups,dc=example,dc=com
## Configuring Seafile to use LLDAP with Authelia as an intermediary ## Configuring Seafile to use LLDAP with Authelia as an intermediary
Authelia is an open-source authentication and authorization server that can use LLDAP as a backend and act as an OpenID Connect Provider. We're going to assume that you have already set up Authelia and configured it with LLDAP. Authelia is an open-source authentication and authorization server that can use LLDAP as a backend and act as an OpenID Connect Provider. We're going to assume that you have already set up Authelia and configured it with LLDAP.
If not, you can find an example configuration [here](authelia_config.yml). If not, you can find an example configuration [here](authelia.md).
1. Add the following to Authelia's `configuration.yml`: 1. Add the following to Authelia's `configuration.yml`:
``` ```
@@ -117,4 +117,4 @@ OAUTH_ATTRIBUTE_MAP = {
} }
``` ```
Restart both your Authelia and Seafile server. You should see a "Single Sign-On" button on Seafile's login page. Clicking it should redirect you to Authelia. If you use the [example config for Authelia](authelia_config.yml), you should be able to log in using your LLDAP User ID. Restart both your Authelia and Seafile server. You should see a "Single Sign-On" button on Seafile's login page. Clicking it should redirect you to Authelia. If you use the [example config for Authelia](authelia.md), you should be able to log in using your LLDAP User ID.
+23 -12
View File
@@ -12,6 +12,9 @@ USER_CONFIGS_DIR="${USER_CONFIGS_DIR:-/bootstrap/user-configs}"
GROUP_CONFIGS_DIR="${GROUP_CONFIGS_DIR:-/bootstrap/group-configs}" GROUP_CONFIGS_DIR="${GROUP_CONFIGS_DIR:-/bootstrap/group-configs}"
LLDAP_SET_PASSWORD_PATH="${LLDAP_SET_PASSWORD_PATH:-/app/lldap_set_password}" LLDAP_SET_PASSWORD_PATH="${LLDAP_SET_PASSWORD_PATH:-/app/lldap_set_password}"
DO_CLEANUP="${DO_CLEANUP:-false}" DO_CLEANUP="${DO_CLEANUP:-false}"
DO_CLEANUP_USERS="${DO_CLEANUP_USERS:-$DO_CLEANUP}"
DO_CLEANUP_GROUP_MEMBERSHIP="${DO_CLEANUP_GROUP_MEMBERSHIP:-$DO_CLEANUP}"
DO_CLEANUP_GROUPS="${DO_CLEANUP_GROUPS:-$DO_CLEANUP}"
# Fallback to support legacy defaults # Fallback to support legacy defaults
if [[ ! -d $USER_CONFIGS_DIR ]] && [[ -d "/user-configs" ]]; then if [[ ! -d $USER_CONFIGS_DIR ]] && [[ -d "/user-configs" ]]; then
@@ -440,7 +443,7 @@ extract_custom_group_attributes() {
} }
extract_custom_user_attributes() { extract_custom_user_attributes() {
extract_custom_attributes "$1" '"id","email","password","displayName","firstName","lastName","groups","avatar_file","avatar_url","gravatar_avatar","weserv_avatar"' extract_custom_attributes "$1" '"id","email","password","password_file","displayName","firstName","lastName","groups","avatar_file","avatar_url","gravatar_avatar","weserv_avatar"'
} }
extract_custom_attributes() { extract_custom_attributes() {
@@ -596,12 +599,18 @@ main() {
check_install_dependencies check_install_dependencies
check_required_env_vars check_required_env_vars
local user_config_files=("${USER_CONFIGS_DIR}"/*.json) local user_config_files=()
local group_config_files=("${GROUP_CONFIGS_DIR}"/*.json) local group_config_files=()
local user_schema_files=() local user_schema_files=()
local group_schema_files=() local group_schema_files=()
local file='' local file=''
[[ -d "$USER_CONFIGS_DIR" ]] && for file in "${USER_CONFIGS_DIR}"/*.json; do
user_config_files+=("$file")
done
[[ -d "$GROUP_CONFIGS_DIR" ]] && for file in "${GROUP_CONFIGS_DIR}"/*.json; do
group_config_files+=("$file")
done
[[ -d "$USER_SCHEMAS_DIR" ]] && for file in "${USER_SCHEMAS_DIR}"/*.json; do [[ -d "$USER_SCHEMAS_DIR" ]] && for file in "${USER_SCHEMAS_DIR}"/*.json; do
user_schema_files+=("$file") user_schema_files+=("$file")
done done
@@ -647,7 +656,7 @@ main() {
printf -- '\n--- groups ---\n' printf -- '\n--- groups ---\n'
local group_config='' local group_config=''
while read -r group_config; do [[ ${#group_config_files[@]} -gt 0 ]] && while read -r group_config; do
local group_name='' local group_name=''
group_name="$(printf '%s' "$group_config" | jq --raw-output '.name')" group_name="$(printf '%s' "$group_config" | jq --raw-output '.name')"
create_group "$group_name" create_group "$group_name"
@@ -675,7 +684,7 @@ main() {
else else
local group_name='' local group_name=''
while read -r group_name; do while read -r group_name; do
if [[ "$DO_CLEANUP" == 'true' ]]; then if [[ "$DO_CLEANUP_GROUPS" == 'true' ]]; then
delete_group "$group_name" delete_group "$group_name"
else else
printf '[WARNING] Group "%s" is not declared in config files\n' "$group_name" printf '[WARNING] Group "%s" is not declared in config files\n' "$group_name"
@@ -690,9 +699,9 @@ main() {
TMP_AVATAR_DIR="$(mktemp -d)" TMP_AVATAR_DIR="$(mktemp -d)"
local user_config='' local user_config=''
while read -r user_config; do [[ ${#user_config_files[@]} -gt 0 ]] && while read -r user_config; do
local field='' id='' email='' displayName='' firstName='' lastName='' avatar_file='' avatar_url='' gravatar_avatar='' weserv_avatar='' password='' local field='' id='' email='' displayName='' firstName='' lastName='' avatar_file='' avatar_url='' gravatar_avatar='' weserv_avatar='' password='' password_file=''
for field in 'id' 'email' 'displayName' 'firstName' 'lastName' 'avatar_file' 'avatar_url' 'gravatar_avatar' 'weserv_avatar' 'password'; do for field in 'id' 'email' 'displayName' 'firstName' 'lastName' 'avatar_file' 'avatar_url' 'gravatar_avatar' 'weserv_avatar' 'password' 'password_file'; do
declare "$field"="$(printf '%s' "$user_config" | jq --raw-output --arg field "$field" '.[$field]')" declare "$field"="$(printf '%s' "$user_config" | jq --raw-output --arg field "$field" '.[$field]')"
done done
printf -- '\n--- %s ---\n' "$id" printf -- '\n--- %s ---\n' "$id"
@@ -700,8 +709,10 @@ main() {
create_update_user "$id" "$email" "$displayName" "$firstName" "$lastName" "$avatar_file" "$avatar_url" "$gravatar_avatar" "$weserv_avatar" create_update_user "$id" "$email" "$displayName" "$firstName" "$lastName" "$avatar_file" "$avatar_url" "$gravatar_avatar" "$weserv_avatar"
redundant_users="$(printf '%s' "$redundant_users" | jq --compact-output --arg id "$id" '. - [$id]')" redundant_users="$(printf '%s' "$redundant_users" | jq --compact-output --arg id "$id" '. - [$id]')"
if [[ "$password" != 'null' ]] && [[ "$password" != '""' ]]; then if [[ "$password_file" != 'null' ]] && [[ "$password_file" != '""' ]]; then
"$LLDAP_SET_PASSWORD_PATH" --base-url "$LLDAP_URL" --token "$TOKEN" --username "$id" --password "$password" LLDAP_USER_PASSWORD="$(cat $password_file)" "$LLDAP_SET_PASSWORD_PATH" --base-url "$LLDAP_URL" --token "$TOKEN" --username "$id"
elif [[ "$password" != 'null' ]] && [[ "$password" != '""' ]]; then
LLDAP_USER_PASSWORD="$password" "$LLDAP_SET_PASSWORD_PATH" --base-url "$LLDAP_URL" --token "$TOKEN" --username "$id"
fi fi
# Process custom attributes # Process custom attributes
@@ -728,7 +739,7 @@ main() {
local user_group_name='' local user_group_name=''
while read -r user_group_name; do while read -r user_group_name; do
if [[ "$DO_CLEANUP" == 'true' ]]; then if [[ "$DO_CLEANUP_GROUP_MEMBERSHIP" == 'true' ]]; then
remove_user_from_group "$id" "$user_group_name" remove_user_from_group "$id" "$user_group_name"
else else
printf '[WARNING] User "%s" is not declared as member of the "%s" group in the config files\n' "$id" "$user_group_name" printf '[WARNING] User "%s" is not declared as member of the "%s" group in the config files\n' "$id" "$user_group_name"
@@ -745,7 +756,7 @@ main() {
else else
local id='' local id=''
while read -r id; do while read -r id; do
if [[ "$DO_CLEANUP" == 'true' ]]; then if [[ "$DO_CLEANUP_USERS" == 'true' ]]; then
delete_user "$id" delete_user "$id"
else else
printf '[WARNING] User "%s" is not declared in config files\n' "$id" printf '[WARNING] User "%s" is not declared in config files\n' "$id"
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "lldap" name = "lldap"
version = "0.6.2-alpha" version = "0.6.2"
description = "Super-simple and lightweight LDAP server" description = "Super-simple and lightweight LDAP server"
categories = ["authentication", "command-line-utilities"] categories = ["authentication", "command-line-utilities"]
edition.workspace = true edition.workspace = true
+5 -6
View File
@@ -199,8 +199,7 @@ where
warn!("Error sending email: {:#?}", e); warn!("Error sending email: {:#?}", e);
info!("Reset token: {}", token); info!("Reset token: {}", token);
return Err(TcpError::InternalServerError(format!( return Err(TcpError::InternalServerError(format!(
"Could not send email: {}", "Could not send email: {e}"
e
))); )));
} }
Ok(()) Ok(())
@@ -254,7 +253,7 @@ where
Cookie::build("token", token.as_str()) Cookie::build("token", token.as_str())
.max_age(5.minutes()) .max_age(5.minutes())
// Cookie is only valid to reset the password. // Cookie is only valid to reset the password.
.path(format!("{}auth", path)) .path(format!("{path}auth"))
.http_only(true) .http_only(true)
.same_site(SameSite::Strict) .same_site(SameSite::Strict)
.finish(), .finish(),
@@ -310,7 +309,7 @@ where
.cookie( .cookie(
Cookie::build("refresh_token", "") Cookie::build("refresh_token", "")
.max_age(0.days()) .max_age(0.days())
.path(format!("{}auth", path)) .path(format!("{path}auth"))
.http_only(true) .http_only(true)
.same_site(SameSite::Strict) .same_site(SameSite::Strict)
.finish(), .finish(),
@@ -381,7 +380,7 @@ where
.cookie( .cookie(
Cookie::build("refresh_token", refresh_token_plus_name.clone()) Cookie::build("refresh_token", refresh_token_plus_name.clone())
.max_age(max_age.num_days().days()) .max_age(max_age.num_days().days())
.path(format!("{}auth", path)) .path(format!("{path}auth"))
.http_only(true) .http_only(true)
.same_site(SameSite::Strict) .same_site(SameSite::Strict)
.finish(), .finish(),
@@ -475,7 +474,7 @@ where
inner_payload, inner_payload,
) )
.await .await
.map_err(|e| TcpError::BadRequest(format!("{:#?}", e)))? .map_err(|e| TcpError::BadRequest(format!("{e:#?}")))?
.into_inner(); .into_inner();
let user_id = &registration_start_request.username; let user_id = &registration_start_request.username;
let user_is_admin = data let user_is_admin = data
+8 -10
View File
@@ -299,14 +299,14 @@ impl PrivateKeyLocationOrFigment {
source: Some(figment::Source::Code(_)), source: Some(figment::Source::Code(_)),
.. ..
}) => PrivateKeyLocation::Default, }) => PrivateKeyLocation::Default,
other => panic!("Unexpected config location: {:?}", other), other => panic!("Unexpected config location: {other:?}"),
} }
} }
PrivateKeyLocationOrFigment::PrivateKeyLocation(PrivateKeyLocation::KeyFile( PrivateKeyLocationOrFigment::PrivateKeyLocation(PrivateKeyLocation::KeyFile(
config_location, config_location,
_, _,
)) => { )) => {
panic!("Unexpected location: {:?}", config_location) panic!("Unexpected location: {config_location:?}")
} }
PrivateKeyLocationOrFigment::PrivateKeyLocation(location) => location.clone(), PrivateKeyLocationOrFigment::PrivateKeyLocation(location) => location.clone(),
} }
@@ -334,11 +334,11 @@ impl PrivateKeyLocationOrFigment {
source: Some(figment::Source::Code(_)), source: Some(figment::Source::Code(_)),
.. ..
}) => PrivateKeyLocation::Default, }) => PrivateKeyLocation::Default,
other => panic!("Unexpected config location: {:?}", other), other => panic!("Unexpected config location: {other:?}"),
} }
} }
PrivateKeyLocationOrFigment::PrivateKeyLocation(PrivateKeyLocation::KeySeed(file)) => { PrivateKeyLocationOrFigment::PrivateKeyLocation(PrivateKeyLocation::KeySeed(file)) => {
panic!("Unexpected location: {:?}", file) panic!("Unexpected location: {file:?}")
} }
PrivateKeyLocationOrFigment::PrivateKeyLocation(location) => location.clone(), PrivateKeyLocationOrFigment::PrivateKeyLocation(location) => location.clone(),
} }
@@ -373,19 +373,17 @@ fn get_server_setup<L: Into<PrivateKeyLocationOrFigment>>(
private_key_location: private_key_location.for_key_seed(), private_key_location: private_key_location.for_key_seed(),
}) })
} else if path.exists() { } else if path.exists() {
let bytes = read(file_path).context(format!("Could not read key file `{}`", file_path))?; let bytes = read(file_path).context(format!("Could not read key file `{file_path}`"))?;
Ok(ServerSetupConfig { Ok(ServerSetupConfig {
server_setup: ServerSetup::deserialize(&bytes).context(format!( server_setup: ServerSetup::deserialize(&bytes).context(format!(
"while parsing the contents of the `{}` file", "while parsing the contents of the `{file_path}` file"
file_path
))?, ))?,
private_key_location: private_key_location.for_key_file(file_path), private_key_location: private_key_location.for_key_file(file_path),
}) })
} else { } else {
let server_setup = generate_random_private_key(); let server_setup = generate_random_private_key();
write_to_readonly_file(path, &server_setup.serialize()).context(format!( write_to_readonly_file(path, &server_setup.serialize()).context(format!(
"Could not write the generated server setup to file `{}`", "Could not write the generated server setup to file `{file_path}`",
file_path,
))?; ))?;
Ok(ServerSetupConfig { Ok(ServerSetupConfig {
server_setup, server_setup,
@@ -596,7 +594,7 @@ where
.iter() .iter()
.filter(|k| !expected_keys.contains(k.as_str())) .filter(|k| !expected_keys.contains(k.as_str()))
.for_each(|k| { .for_each(|k| {
eprintln!("WARNING: Unknown environment variable: LLDAP_{}", k); eprintln!("WARNING: Unknown environment variable: LLDAP_{k}");
}); });
} }
config.server_setup = Some(get_server_setup( config.server_setup = Some(get_server_setup(
+2 -2
View File
@@ -29,7 +29,7 @@ impl std::fmt::Debug for DatabaseUrl {
let mut url = self.0.clone(); let mut url = self.0.clone();
// It can fail for URLs that cannot have a password, like "mailto:bob@example". // It can fail for URLs that cannot have a password, like "mailto:bob@example".
let _ = url.set_password(Some("***PASSWORD***")); let _ = url.set_password(Some("***PASSWORD***"));
f.write_fmt(format_args!(r#""{}""#, url)) f.write_fmt(format_args!(r#""{url}""#))
} else { } else {
f.write_fmt(format_args!(r#""{}""#, self.0)) f.write_fmt(format_args!(r#""{}""#, self.0))
} }
@@ -44,7 +44,7 @@ mod tests {
fn test_database_url_debug() { fn test_database_url_debug() {
let url = DatabaseUrl::from("postgres://user:pass@localhost:5432/dbname"); let url = DatabaseUrl::from("postgres://user:pass@localhost:5432/dbname");
assert_eq!( assert_eq!(
format!("{:?}", url), format!("{url:?}"),
r#""postgres://user:***PASSWORD***@localhost:5432/dbname""# r#""postgres://user:***PASSWORD***@localhost:5432/dbname""#
); );
assert_eq!( assert_eq!(
+2 -2
View File
@@ -71,7 +71,7 @@ where
#[instrument(level = "info", err)] #[instrument(level = "info", err)]
pub async fn check_ldap(port: u16) -> Result<()> { pub async fn check_ldap(port: u16) -> Result<()> {
check_ldap_endpoint(TcpStream::connect(format!("localhost:{}", port)).await?).await check_ldap_endpoint(TcpStream::connect(format!("localhost:{port}")).await?).await
} }
fn get_root_certificates() -> rustls::RootCertStore { fn get_root_certificates() -> rustls::RootCertStore {
@@ -152,7 +152,7 @@ pub async fn check_ldaps(ldaps_options: &LdapsOptions) -> Result<()> {
#[instrument(level = "info", err)] #[instrument(level = "info", err)]
pub async fn check_api(port: u16) -> Result<()> { pub async fn check_api(port: u16) -> Result<()> {
reqwest::get(format!("http://localhost:{}/health", port)) reqwest::get(format!("http://localhost:{port}/health"))
.await? .await?
.error_for_status()?; .error_for_status()?;
info!("Success"); info!("Success");
+1 -4
View File
@@ -132,10 +132,7 @@ fn read_private_key(key_file: &str) -> Result<PrivateKey> {
.and_then(|keys| keys.into_iter().next().ok_or_else(|| anyhow!("No EC key"))) .and_then(|keys| keys.into_iter().next().ok_or_else(|| anyhow!("No EC key")))
}) })
.with_context(|| { .with_context(|| {
format!( format!("Cannot read either PKCS1, PKCS8 or EC private key from {key_file}")
"Cannot read either PKCS1, PKCS8 or EC private key from {}",
key_file
)
}) })
.map(rustls::PrivateKey) .map(rustls::PrivateKey)
} }
+3 -4
View File
@@ -93,15 +93,14 @@ pub async fn send_password_reset_email(
.unwrap() .unwrap()
.extend(["reset-password", "step2", token]); .extend(["reset-password", "step2", token]);
let body = format!( let body = format!(
"Hello {}, "Hello {username},
This email has been sent to you in order to validate your identity. This email has been sent to you in order to validate your identity.
If you did not initiate the process your credentials might have been If you did not initiate the process your credentials might have been
compromised. You should reset your password and contact an administrator. compromised. You should reset your password and contact an administrator.
To reset your password please visit the following URL: {} To reset your password please visit the following URL: {reset_url}
Please contact an administrator if you did not initiate the process.", Please contact an administrator if you did not initiate the process."
username, reset_url
); );
let res = send_email( let res = send_email(
to, to,
+2 -3
View File
@@ -55,8 +55,7 @@ async fn create_admin_user(handler: &SqlBackendHandler, config: &Configuration)
.len(); .len();
assert!( assert!(
pass_length >= 8, pass_length >= 8,
"Minimum password length is 8 characters, got {} characters", "Minimum password length is 8 characters, got {pass_length} characters"
pass_length
); );
handler handler
.create_user(CreateUserRequest { .create_user(CreateUserRequest {
@@ -97,7 +96,7 @@ async fn ensure_group_exists(handler: &SqlBackendHandler, group_name: &str) -> R
..Default::default() ..Default::default()
}) })
.await .await
.context(format!("while creating {} group", group_name))?; .context(format!("while creating {group_name} group"))?;
} }
Ok(()) Ok(())
} }
+1 -2
View File
@@ -169,8 +169,7 @@ impl TcpBackendHandler for SqlBackendHandler {
.await?; .await?;
if result.rows_affected == 0 { if result.rows_affected == 0 {
return Err(DomainError::EntityNotFound(format!( return Err(DomainError::EntityNotFound(format!(
"No such password reset token: '{}'", "No such password reset token: '{token}'"
token
))); )));
} }
Ok(()) Ok(())
+2 -2
View File
@@ -14,13 +14,13 @@ pub fn database_url() -> String {
pub fn ldap_url() -> String { pub fn ldap_url() -> String {
let port = var("LLDAP_LDAP_PORT").ok(); let port = var("LLDAP_LDAP_PORT").ok();
let port = port.unwrap_or("3890".to_string()); let port = port.unwrap_or("3890".to_string());
format!("ldap://localhost:{}", port) format!("ldap://localhost:{port}")
} }
pub fn http_url() -> String { pub fn http_url() -> String {
let port = var("LLDAP_HTTP_PORT").ok(); let port = var("LLDAP_HTTP_PORT").ok();
let port = port.unwrap_or("17170".to_string()); let port = port.unwrap_or("17170".to_string());
format!("http://localhost:{}", port) format!("http://localhost:{port}")
} }
pub fn admin_dn() -> String { pub fn admin_dn() -> String {
+7 -10
View File
@@ -102,7 +102,7 @@ impl LLDAPFixture {
create_user::Variables { create_user::Variables {
user: create_user::CreateUserInput { user: create_user::CreateUserInput {
id: user.clone(), id: user.clone(),
email: Some(format!("{}@lldap.test", user)), email: Some(format!("{user}@lldap.test")),
avatar: None, avatar: None,
display_name: None, display_name: None,
first_name: None, first_name: None,
@@ -181,11 +181,11 @@ impl Drop for LLDAPFixture {
Signal::SIGTERM, Signal::SIGTERM,
); );
if let Err(err) = result { if let Err(err) = result {
println!("Failed to send kill signal: {:?}", err); println!("Failed to send kill signal: {err:?}");
let _ = self let _ = self
.child .child
.kill() .kill()
.map_err(|err| println!("Failed to kill LLDAP: {:?}", err)); .map_err(|err| println!("Failed to kill LLDAP: {err:?}"));
return; return;
} }
@@ -193,10 +193,7 @@ impl Drop for LLDAPFixture {
let status = self.child.try_wait(); let status = self.child.try_wait();
match status { match status {
Err(e) => { Err(e) => {
println!( println!("Failed to get status while waiting for graceful exit: {e}");
"Failed to get status while waiting for graceful exit: {}",
e
);
break; break;
} }
Ok(None) => { Ok(None) => {
@@ -204,7 +201,7 @@ impl Drop for LLDAPFixture {
} }
Ok(Some(status)) => { Ok(Some(status)) => {
if !status.success() { if !status.success() {
println!("LLDAP exited with status {}", status) println!("LLDAP exited with status {status}")
} }
return; return;
} }
@@ -215,7 +212,7 @@ impl Drop for LLDAPFixture {
let _ = self let _ = self
.child .child
.kill() .kill()
.map_err(|err| println!("Failed to kill LLDAP: {:?}", err)); .map_err(|err| println!("Failed to kill LLDAP: {err:?}"));
} }
} }
@@ -223,7 +220,7 @@ pub fn new_id(prefix: Option<&str>) -> String {
let id = Uuid::new_v4(); let id = Uuid::new_v4();
let id = format!("{}-lldap-test", id.simple()); let id = format!("{}-lldap-test", id.simple());
match prefix { match prefix {
Some(prefix) => format!("{}{}", prefix, id), Some(prefix) => format!("{prefix}{id}"),
None => id, None => id,
} }
} }
+1 -1
View File
@@ -103,7 +103,7 @@ where
}) })
}; };
let url = env::http_url() + "/api/graphql"; let url = env::http_url() + "/api/graphql";
let auth_header = format!("Bearer {}", token); let auth_header = format!("Bearer {token}");
client client
.post(url) .post(url)
.header(reqwest::header::AUTHORIZATION, auth_header) .header(reqwest::header::AUTHORIZATION, auth_header)
+2 -2
View File
@@ -31,13 +31,13 @@ fn gitea() {
ldap.simple_bind(bind_dn.as_str(), env::admin_password().as_str()) ldap.simple_bind(bind_dn.as_str(), env::admin_password().as_str())
.expect("failed to bind to ldap"); .expect("failed to bind to ldap");
let user_base = format!("ou=people,{}", base_dn); let user_base = format!("ou=people,{base_dn}");
let attrs = vec!["uid", "givenName", "sn", "mail", "jpegPhoto"]; let attrs = vec!["uid", "givenName", "sn", "mail", "jpegPhoto"];
let results = ldap let results = ldap
.search( .search(
user_base.as_str(), user_base.as_str(),
Scope::Subtree, Scope::Subtree,
format!("(memberof=cn={},ou=groups,{})", gitea_user_group, base_dn).as_str(), format!("(memberof=cn={gitea_user_group},ou=groups,{base_dn})").as_str(),
attrs, attrs,
) )
.expect("failed to find gitea users") .expect("failed to find gitea users")
+2 -2
View File
@@ -86,7 +86,7 @@ fn admin_search() {
ldap.search( ldap.search(
env::base_dn().as_str(), env::base_dn().as_str(),
Scope::Subtree, Scope::Subtree,
format!("(&(objectclass=person)(uid={}))", admin_name).as_str(), format!("(&(objectclass=person)(uid={admin_name}))").as_str(),
attrs, attrs,
) )
.expect("failed to find admin"), .expect("failed to find admin"),
@@ -97,7 +97,7 @@ fn admin_search() {
found_users found_users
.get(&admin_name) .get(&admin_name)
.unwrap() .unwrap()
.contains(format!("cn={},ou=groups,{}", admin_group_name, base_dn).as_str()) .contains(format!("cn={admin_group_name},ou=groups,{base_dn}").as_str())
); );
ldap.unbind().expect("failed to unbind ldap connection"); ldap.unbind().expect("failed to unbind ldap connection");
} }
+12 -4
View File
@@ -1,3 +1,5 @@
use std::env;
use anyhow::{Context, Result, bail, ensure}; use anyhow::{Context, Result, bail, ensure};
use clap::Parser; use clap::Parser;
use lldap_auth::{opaque, registration}; use lldap_auth::{opaque, registration};
@@ -27,9 +29,9 @@ pub struct CliOpts {
#[clap(short, long)] #[clap(short, long)]
pub username: String, pub username: String,
/// New password for the user. /// New password for the user. Can also be passed as the environment variable LLDAP_USER_PASSWORD.
#[clap(short, long)] #[clap(short, long)]
pub password: String, pub password: Option<String>,
/// Bypass password requirements such as minimum length. Unsafe. /// Bypass password requirements such as minimum length. Unsafe.
#[clap(long)] #[clap(long)]
@@ -100,8 +102,14 @@ pub fn register_finish(
fn main() -> Result<()> { fn main() -> Result<()> {
let opts = CliOpts::parse(); let opts = CliOpts::parse();
let password = match opts.password {
Some(v) => v,
None => env::var("LLDAP_USER_PASSWORD").unwrap_or_default(),
};
ensure!( ensure!(
opts.bypass_password_policy || opts.password.len() >= 8, opts.bypass_password_policy || password.len() >= 8,
"New password is too short, expected at least 8 characters" "New password is too short, expected at least 8 characters"
); );
ensure!( ensure!(
@@ -118,7 +126,7 @@ fn main() -> Result<()> {
let mut rng = rand::rngs::OsRng; let mut rng = rand::rngs::OsRng;
let registration_start_request = let registration_start_request =
opaque::client::registration::start_registration(opts.password.as_bytes(), &mut rng) opaque::client::registration::start_registration(password.as_bytes(), &mut rng)
.context("Could not initiate password change")?; .context("Could not initiate password change")?;
let start_request = registration::ClientRegistrationStartRequest { let start_request = registration::ClientRegistrationStartRequest {
username: opts.username.clone().into(), username: opts.username.clone().into(),