Compare commits
3 Commits
v0.6.0
...
user-attri
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b817980a9 | ||
|
|
66097f1880 | ||
|
|
adf3577f0e |
5
.github/workflows/Dockerfile.ci.alpine-base
vendored
5
.github/workflows/Dockerfile.ci.alpine-base
vendored
@@ -59,12 +59,12 @@ RUN set -x \
|
||||
&& for file in $(cat /lldap/app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
|
||||
&& chmod a+r -R .
|
||||
|
||||
FROM alpine:3.19
|
||||
FROM alpine:3.16
|
||||
WORKDIR /app
|
||||
ENV UID=1000
|
||||
ENV GID=1000
|
||||
ENV USER=lldap
|
||||
RUN apk add --no-cache tini ca-certificates bash tzdata jq curl jo && \
|
||||
RUN apk add --no-cache tini ca-certificates bash tzdata && \
|
||||
addgroup -g $GID $USER && \
|
||||
adduser \
|
||||
--disabled-password \
|
||||
@@ -80,6 +80,5 @@ COPY --from=lldap --chown=$USER:$USER /lldap /app
|
||||
VOLUME ["/data"]
|
||||
HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]
|
||||
WORKDIR /app
|
||||
COPY scripts/bootstrap.sh ./
|
||||
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
|
||||
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
||||
|
||||
3
.github/workflows/Dockerfile.ci.debian-base
vendored
3
.github/workflows/Dockerfile.ci.debian-base
vendored
@@ -65,7 +65,7 @@ ENV UID=1000
|
||||
ENV GID=1000
|
||||
ENV USER=lldap
|
||||
RUN apt update && \
|
||||
apt install -y --no-install-recommends tini openssl ca-certificates tzdata jq curl jo && \
|
||||
apt install -y --no-install-recommends tini openssl ca-certificates tzdata && \
|
||||
apt clean && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
groupadd -g $GID $USER && useradd --system -m -g $USER --uid $UID $USER && \
|
||||
@@ -74,7 +74,6 @@ COPY --from=lldap --chown=$USER:$USER /lldap /app
|
||||
COPY --from=lldap --chown=$USER:$USER /docker-entrypoint.sh /docker-entrypoint.sh
|
||||
VOLUME ["/data"]
|
||||
WORKDIR /app
|
||||
COPY scripts/bootstrap.sh ./
|
||||
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
|
||||
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
||||
HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]
|
||||
|
||||
5
.github/workflows/Dockerfile.dev
vendored
5
.github/workflows/Dockerfile.dev
vendored
@@ -1,5 +1,5 @@
|
||||
# Keep tracking base image
|
||||
FROM rust:1.81-slim-bookworm
|
||||
FROM rust:1.74-slim-bookworm
|
||||
|
||||
# Set needed env path
|
||||
ENV PATH="/opt/armv7l-linux-musleabihf-cross/:/opt/armv7l-linux-musleabihf-cross/bin/:/opt/aarch64-linux-musl-cross/:/opt/aarch64-linux-musl-cross/bin/:/opt/x86_64-linux-musl-cross/:/opt/x86_64-linux-musl-cross/bin/:$PATH"
|
||||
@@ -34,8 +34,7 @@ RUN wget -c https://musl.cc/x86_64-linux-musl-cross.tgz && \
|
||||
### Add musl target
|
||||
RUN rustup target add x86_64-unknown-linux-musl && \
|
||||
rustup target add aarch64-unknown-linux-musl && \
|
||||
rustup target add armv7-unknown-linux-musleabihf && \
|
||||
rustup target add x86_64-unknown-freebsd
|
||||
rustup target add armv7-unknown-linux-musleabihf
|
||||
|
||||
|
||||
CMD ["bash"]
|
||||
|
||||
20
.github/workflows/docker-build-static.yml
vendored
20
.github/workflows/docker-build-static.yml
vendored
@@ -39,7 +39,7 @@ env:
|
||||
# GitHub actions randomly timeout when downloading musl-gcc, using custom dev image #
|
||||
# Look into .github/workflows/Dockerfile.dev for development image details #
|
||||
# Using lldap dev image based on https://hub.docker.com/_/rust and musl-gcc bundled #
|
||||
# lldap/rust-dev #
|
||||
# lldap/rust-dev:latest #
|
||||
#######################################################################################
|
||||
# Cargo build
|
||||
### armv7, aarch64 and amd64 is musl based
|
||||
@@ -84,10 +84,10 @@ jobs:
|
||||
needs: pre_job
|
||||
if: ${{ needs.pre_job.outputs.should_skip != 'true' || github.event_name == 'release' }}
|
||||
container:
|
||||
image: lldap/rust-dev:v81
|
||||
image: lldap/rust-dev:latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4.2.2
|
||||
uses: actions/checkout@v4.1.1
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
@@ -125,14 +125,14 @@ jobs:
|
||||
matrix:
|
||||
target: [armv7-unknown-linux-musleabihf, aarch64-unknown-linux-musl, x86_64-unknown-linux-musl]
|
||||
container:
|
||||
image: lldap/rust-dev:v81
|
||||
image: lldap/rust-dev:latest
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -Ctarget-feature=+crt-static
|
||||
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4.2.2
|
||||
uses: actions/checkout@v4.1.1
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
@@ -294,7 +294,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@v4.2.2
|
||||
uses: actions/checkout@v4.1.1
|
||||
with:
|
||||
sparse-checkout: 'scripts'
|
||||
|
||||
@@ -482,7 +482,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4.2.2
|
||||
uses: actions/checkout@v4.1.1
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -512,7 +512,7 @@ jobs:
|
||||
tags: ${{ matrix.container }}-base
|
||||
|
||||
- name: Build ${{ matrix.container }} Base Docker Image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
# On PR will fail, force fully uncomment push: true, or docker image will fail for next steps
|
||||
@@ -613,7 +613,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build ${{ matrix.container }}-rootless Docker Image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
@@ -627,7 +627,7 @@ jobs:
|
||||
|
||||
### This docker build always the last, due :latest tag pushed multiple times, for whatever variants may added in future add docker build above this
|
||||
- name: Build ${{ matrix.container }} Docker Image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
20
.github/workflows/rust.yml
vendored
20
.github/workflows/rust.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v4.2.2
|
||||
uses: actions/checkout@v4.1.1
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Build
|
||||
run: cargo build --verbose --workspace
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v4.2.2
|
||||
uses: actions/checkout@v4.1.1
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v4.2.2
|
||||
uses: actions/checkout@v4.1.1
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v4.2.2
|
||||
uses: actions/checkout@v4.1.1
|
||||
|
||||
- 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
|
||||
@@ -101,10 +101,16 @@ jobs:
|
||||
run: cargo llvm-cov --workspace --no-report
|
||||
- name: Aggregate reports
|
||||
run: cargo llvm-cov --no-run --lcov --output-path lcov.info
|
||||
- name: Upload coverage to Codecov (main)
|
||||
uses: codecov/codecov-action@v4
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
if: github.ref != 'refs/heads/main' || github.event_name != 'push'
|
||||
with:
|
||||
files: lcov.info
|
||||
fail_ci_if_error: true
|
||||
- name: Upload coverage to Codecov (main)
|
||||
uses: codecov/codecov-action@v3
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
with:
|
||||
files: lcov.info
|
||||
fail_ci_if_error: true
|
||||
codecov_yml_path: .github/codecov.yml
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
82
CHANGELOG.md
82
CHANGELOG.md
@@ -5,86 +5,6 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.6.0] 2024-11-09
|
||||
|
||||
### Breaking
|
||||
|
||||
- The endpoint `/auth/reset/step1` is now `POST` instead of `GET` (#704)
|
||||
|
||||
### Added
|
||||
|
||||
- Custom attributes are now supported (#67) ! You can add new fields (string, integers, JPEG or dates) to users and query them. That unlocks many integrations with other services, and allows for a deeper/more customized integration. Special thanks to @pixelrazor and @bojidar-bg for their help with the UI.
|
||||
- Custom object classes (for all users/groups) can now be added (#833)
|
||||
- Barebones support for Paged Results Control (no paging, no respect for windows, but a correct response with all the results) (#698)
|
||||
- A daily docker image is tagged and released. (#613)
|
||||
- A bootstrap script allows reading the list of users/groups from a file and making sure the server contains exactly the same thing. (#654)
|
||||
- Make it possible to serve lldap behind a sub-path in (#752)
|
||||
- LLDAP can now be found on a custom package repository for opensuse, fedora, ubuntu, debian and centos ([Repository link](https://software.opensuse.org//download.html?project=home%3AMasgalor%3ALLDAP&package=lldap)). Thanks @Masgalor for setting it up and maintaining it.
|
||||
- There's now an option to force reset the admin password (#748) optionally on every restart (#959)
|
||||
- There's a rootless docker container (#755)
|
||||
- entryDN is now supported (#780)
|
||||
- Unknown LDAP controls are now detected and ignored (#787, #799)
|
||||
- A community-developed CLI for scripting (#793)
|
||||
- Added a way to print raw logs to debug long-running sessions (#992)
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
- The official docker repository is now `lldap/lldap`
|
||||
- Removed password length limitation in lldap_set_password tool
|
||||
- Group names and emails are now case insensitive, but keep their casing (#666)
|
||||
- Better error messages (and exit code (#745)) when changing the private key (#778, #1008), using the wrong SMTP port (#970), using the wrong env variables (#972)
|
||||
- Allow `member=` filters with plain user names (not full DNs) (#949)
|
||||
- Correctly detect and refuse anonymous binds (#974)
|
||||
- Clearer logging (#971, #981, #982)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Logging out applies globally, not just in the local browser. (#721)
|
||||
- It's no longer possible to create the same user twice (#745)
|
||||
- Fix wide substring filters (#738)
|
||||
- Don't log the database password if provided in the connection URL (#735)
|
||||
- Fix a panic when postgres uses a different collation (#821)
|
||||
- The UI now defaults to the user ID for users with no display names (#843)
|
||||
- Fix searching for users with more than one `memberOf` filter (#872)
|
||||
- Fix compilation on Windows (#932) and Illumos (#964)
|
||||
- The UI now correctly detects whether password resets are enabled. (#753)
|
||||
- Fix a missing lowercasing of username when changing passwords through LDAP (#1012)
|
||||
- Fix SQLite writers erroring when racing (#1021)
|
||||
- LDAP sessions no longer buffer their logs until unbind, causing memory leaks (#1025)
|
||||
|
||||
### Performance
|
||||
|
||||
- Only expand attributes once per query, not per result (#687)
|
||||
|
||||
### Security
|
||||
|
||||
- When asked to send a password reset to an unknown email, sleep for 3 seconds and don't print the email in the error (#887)
|
||||
|
||||
### New services
|
||||
|
||||
Linux user accounts can now be managed by LLDAP, using PAM and nslcd.
|
||||
|
||||
- Apereo CAS server
|
||||
- Carpal
|
||||
- Gitlab
|
||||
- Grocy
|
||||
- Harbor
|
||||
- Home Assistant
|
||||
- Jenkins
|
||||
- Kasm
|
||||
- Maddy
|
||||
- Mastodon
|
||||
- Metabase
|
||||
- MegaRAC-BMC
|
||||
- Netbox
|
||||
- OCIS
|
||||
- Prosody
|
||||
- Radicale
|
||||
- SonarQube
|
||||
- Traccar
|
||||
- Zitadel
|
||||
|
||||
## [0.5.0] 2023-09-14
|
||||
|
||||
### Breaking
|
||||
@@ -151,7 +71,7 @@ systems, including PAM authentication.
|
||||
## [0.4.3] 2023-04-11
|
||||
|
||||
The repository has changed from `nitnelave/lldap` to `lldap/lldap`, both on GitHub
|
||||
and on DockerHub (although we will keep publishing the images to
|
||||
and on DockerHub (although we will keep publishing the images to
|
||||
`nitnelave/lldap` for the foreseeable future). All data on GitHub has been
|
||||
migrated, and the new docker images are available both on DockerHub and on the
|
||||
GHCR under `lldap/lldap`.
|
||||
|
||||
2324
Cargo.lock
generated
2324
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -41,7 +41,7 @@ RUN cargo build --release -p lldap -p lldap_migration_tool -p lldap_set_password
|
||||
&& ./app/build.sh
|
||||
|
||||
# Final image
|
||||
FROM alpine:3.19
|
||||
FROM alpine:3.16
|
||||
|
||||
ENV GOSU_VERSION 1.14
|
||||
# Fetch gosu from git
|
||||
@@ -80,7 +80,6 @@ COPY --from=builder /app/app/static app/static
|
||||
COPY --from=builder /app/app/pkg app/pkg
|
||||
COPY --from=builder /app/target/release/lldap /app/target/release/lldap_migration_tool /app/target/release/lldap_set_password ./
|
||||
COPY docker-entrypoint.sh lldap_config.docker_template.toml ./
|
||||
COPY scripts/bootstrap.sh ./
|
||||
|
||||
RUN set -x \
|
||||
&& apk add --no-cache bash tzdata \
|
||||
|
||||
272
README.md
272
README.md
@@ -38,7 +38,6 @@
|
||||
- [With Docker](#with-docker)
|
||||
- [With Kubernetes](#with-kubernetes)
|
||||
- [From a package repository](#from-a-package-repository)
|
||||
- [With FreeBSD](#with-freebsd)
|
||||
- [From source](#from-source)
|
||||
- [Backend](#backend)
|
||||
- [Frontend](#frontend)
|
||||
@@ -48,7 +47,6 @@
|
||||
- [Client configuration](#client-configuration)
|
||||
- [Compatible services](#compatible-services)
|
||||
- [General configuration guide](#general-configuration-guide)
|
||||
- [Integration with OS's](#integration-with-oss)
|
||||
- [Sample client configurations](#sample-client-configurations)
|
||||
- [Incompatible services](#incompatible-services)
|
||||
- [Migrating from SQLite](#migrating-from-sqlite)
|
||||
@@ -163,15 +161,6 @@ services:
|
||||
# You can also set a different database:
|
||||
# - LLDAP_DATABASE_URL=mysql://mysql-user:password@mysql-server/my-database
|
||||
# - LLDAP_DATABASE_URL=postgres://postgres-user:password@postgres-server/my-database
|
||||
# If using SMTP, set the following variables
|
||||
# - LLDAP_SMTP_OPTIONS__ENABLE_PASSWORD_RESET=true
|
||||
# - LLDAP_SMTP_OPTIONS__SERVER=smtp.example.com
|
||||
# - LLDAP_SMTP_OPTIONS__PORT=465 # Check your smtp providor's documentation for this setting
|
||||
# - LLDAP_SMTP_OPTIONS__SMTP_ENCRYPTION=TLS # How the connection is encrypted, either "NONE" (no encryption, port 25), "TLS" (sometimes called SSL, port 465) or "STARTTLS" (sometimes called TLS, port 587).
|
||||
# - LLDAP_SMTP_OPTIONS__USER=no-reply@example.com # The SMTP user, usually your email address
|
||||
# - LLDAP_SMTP_OPTIONS__PASSWORD=PasswordGoesHere # The SMTP password
|
||||
# - LLDAP_SMTP_OPTIONS__FROM=no-reply <no-reply@example.com> # The header field, optional: how the sender appears in the email. The first is a free-form name, followed by an email between <>.
|
||||
# - LLDAP_SMTP_OPTIONS__TO=admin <admin@example.com> # Same for reply-to, optional.
|
||||
```
|
||||
|
||||
Then the service will listen on two ports, one for LDAP and one for the web
|
||||
@@ -194,225 +183,30 @@ Depending on the distribution you use, it might be possible to install lldap
|
||||
from a package repository, officially supported by the distribution or
|
||||
community contributed.
|
||||
|
||||
Each package offers a [systemd service](https://wiki.archlinux.org/title/systemd#Using_units) `lldap.service` to (auto-)start and stop lldap.<br>
|
||||
When using the distributed packages, the default login is `admin/password`. You can change that from the web UI after starting the service.
|
||||
#### Debian, CentOS Fedora, OpenSUSE, Ubuntu
|
||||
|
||||
<details>
|
||||
<summary><b>Arch</b></summary>
|
||||
<br>
|
||||
Arch Linux offers unofficial support through the <a href="https://wiki.archlinux.org/title/Arch_User_Repository">Arch User Repository (AUR)</a>.<br>
|
||||
The package descriptions can be used <a href="https://wiki.archlinux.org/title/Arch_User_Repository#Getting_started">to create and install packages</a>.<br><br>
|
||||
Maintainer: <a href="https://github.com/Zepmann">@Zepmann</a><br>
|
||||
Support: <a href="https://github.com/lldap/lldap/discussions">Discussions</a><br>
|
||||
Package repository: <a href="https://aur.archlinux.org/packages">Arch user repository</a><br>
|
||||
<table>
|
||||
<tr>
|
||||
<td>Available packages:</td>
|
||||
<td><a href="https://aur.archlinux.org/packages/lldap">lldap</a></td>
|
||||
<td>Builds the latest stable version.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td><a href="https://aur.archlinux.org/packages/lldap-bin">lldap-bin</a></td>
|
||||
<td>Uses the latest pre-compiled binaries from the <a href="https://aur.archlinux.org/packages/lldap-bin">releases in this repository</a>.<br>
|
||||
This package is recommended if you want to run lldap on a system with limited resources.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td><a href="https://aur.archlinux.org/packages/lldap-git">lldap-git</a></td>
|
||||
<td>Builds the latest main branch code.</td>
|
||||
</tr>
|
||||
</table>
|
||||
LLDPA configuration file: /etc/lldap.toml<br>
|
||||
</details>
|
||||
<details>
|
||||
<summary><b>Debian</b></summary>
|
||||
<br>
|
||||
Unofficial Debian support is offered through the <a href="https://build.opensuse.org/">openSUSE Build Service</a>.<br><br>
|
||||
Maintainer: <a href="https://github.com/Masgalor">@Masgalor</a><br>
|
||||
Support: <a href="https://codeberg.org/Masgalor/LLDAP-Packaging/issues">Codeberg</a>, <a href="https://github.com/lldap/lldap/discussions">Discussions</a><br>
|
||||
Package repository: <a href="https://software.opensuse.org//download.html?project=home%3AMasgalor%3ALLDAP&package=lldap">SUSE openBuildService</a><br>
|
||||
<table>
|
||||
<tr>
|
||||
<td>Available packages:</td>
|
||||
<td>lldap</td>
|
||||
<td>Light LDAP server for authentication.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>lldap-extras</td>
|
||||
<td>Meta-Package for LLDAP and its tools and extensions.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>lldap-migration-tool</td>
|
||||
<td>CLI migration tool to go from OpenLDAP to LLDAP.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>lldap-set-password</td>
|
||||
<td>CLI tool to set a user password in LLDAP.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>lldap-cli</td>
|
||||
<td>LLDAP-CLI is an unofficial command line interface for LLDAP.</td>
|
||||
</tr>
|
||||
</table>
|
||||
LLDPA configuration file: /etc/lldap/lldap_config.toml<br>
|
||||
</details>
|
||||
<details>
|
||||
<summary><b>CentOS</b></summary>
|
||||
<br>
|
||||
Unofficial CentOS support is offered through the <a href="https://build.opensuse.org/">openSUSE Build Service</a>.<br><br>
|
||||
Maintainer: <a href="https://github.com/Masgalor">@Masgalor</a><br>
|
||||
Support: <a href="https://codeberg.org/Masgalor/LLDAP-Packaging/issues">Codeberg</a>, <a href="https://github.com/lldap/lldap/discussions">Discussions</a><br>
|
||||
Package repository: <a href="https://software.opensuse.org//download.html?project=home%3AMasgalor%3ALLDAP&package=lldap">SUSE openBuildService</a><br>
|
||||
<table>
|
||||
<tr>
|
||||
<td>Available packages:</td>
|
||||
<td>lldap</td>
|
||||
<td>Light LDAP server for authentication.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>lldap-extras</td>
|
||||
<td>Meta-Package for LLDAP and its tools and extensions.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>lldap-migration-tool</td>
|
||||
<td>CLI migration tool to go from OpenLDAP to LLDAP.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>lldap-set-password</td>
|
||||
<td>CLI tool to set a user password in LLDAP.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>lldap-cli</td>
|
||||
<td>LLDAP-CLI is an unofficial command line interface for LLDAP.</td>
|
||||
</tr>
|
||||
</table>
|
||||
LLDPA configuration file: /etc/lldap/lldap_config.toml<br>
|
||||
</details>
|
||||
<details>
|
||||
<summary><b>Fedora</b></summary>
|
||||
<br>
|
||||
Unofficial Fedora support is offered through the <a href="https://build.opensuse.org/">openSUSE Build Service</a>.<br><br>
|
||||
Maintainer: <a href="https://github.com/Masgalor">@Masgalor</a><br>
|
||||
Support: <a href="https://codeberg.org/Masgalor/LLDAP-Packaging/issues">Codeberg</a>, <a href="https://github.com/lldap/lldap/discussions">Discussions</a><br>
|
||||
Package repository: <a href="https://software.opensuse.org//download.html?project=home%3AMasgalor%3ALLDAP&package=lldap">SUSE openBuildService</a><br>
|
||||
<table>
|
||||
<tr>
|
||||
<td>Available packages:</td>
|
||||
<td>lldap</td>
|
||||
<td>Light LDAP server for authentication.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>lldap-extras</td>
|
||||
<td>Meta-Package for LLDAP and its tools and extensions.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>lldap-migration-tool</td>
|
||||
<td>CLI migration tool to go from OpenLDAP to LLDAP.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>lldap-set-password</td>
|
||||
<td>CLI tool to set a user password in LLDAP.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>lldap-cli</td>
|
||||
<td>LLDAP-CLI is an unofficial command line interface for LLDAP.</td>
|
||||
</tr>
|
||||
</table>
|
||||
LLDPA configuration file: /etc/lldap/lldap_config.toml<br>
|
||||
</details>
|
||||
<details>
|
||||
<summary><b>OpenSUSE</b></summary>
|
||||
<br>
|
||||
Unofficial OpenSUSE support is offered through the <a href="https://build.opensuse.org/">openSUSE Build Service</a>.<br><br>
|
||||
Maintainer: <a href="https://github.com/Masgalor">@Masgalor</a><br>
|
||||
Support: <a href="https://codeberg.org/Masgalor/LLDAP-Packaging/issues">Codeberg</a>, <a href="https://github.com/lldap/lldap/discussions">Discussions</a><br>
|
||||
Package repository: <a href="https://software.opensuse.org//download.html?project=home%3AMasgalor%3ALLDAP&package=lldap">SUSE openBuildService</a><br>
|
||||
<table>
|
||||
<tr>
|
||||
<td>Available packages:</td>
|
||||
<td>lldap</td>
|
||||
<td>Light LDAP server for authentication.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>lldap-extras</td>
|
||||
<td>Meta-Package for LLDAP and its tools and extensions.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>lldap-migration-tool</td>
|
||||
<td>CLI migration tool to go from OpenLDAP to LLDAP.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>lldap-set-password</td>
|
||||
<td>CLI tool to set a user password in LLDAP.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>lldap-cli</td>
|
||||
<td>LLDAP-CLI is an unofficial command line interface for LLDAP.</td>
|
||||
</tr>
|
||||
</table>
|
||||
LLDPA configuration file: /etc/lldap/lldap_config.toml<br>
|
||||
</details>
|
||||
<details>
|
||||
<summary><b>Ubuntu</b></summary>
|
||||
<br>
|
||||
Unofficial Ubuntu support is offered through the <a href="https://build.opensuse.org/">openSUSE Build Service</a>.<br><br>
|
||||
Maintainer: <a href="https://github.com/Masgalor">@Masgalor</a><br>
|
||||
Support: <a href="https://codeberg.org/Masgalor/LLDAP-Packaging/issues">Codeberg</a>, <a href="https://github.com/lldap/lldap/discussions">Discussions</a><br>
|
||||
Package repository: <a href="https://software.opensuse.org//download.html?project=home%3AMasgalor%3ALLDAP&package=lldap">SUSE openBuildService</a><br>
|
||||
<table>
|
||||
<tr>
|
||||
<td>Available packages:</td>
|
||||
<td>lldap</td>
|
||||
<td>Light LDAP server for authentication.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>lldap-extras</td>
|
||||
<td>Meta-Package for LLDAP and its tools and extensions.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>lldap-migration-tool</td>
|
||||
<td>CLI migration tool to go from OpenLDAP to LLDAP.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>lldap-set-password</td>
|
||||
<td>CLI tool to set a user password in LLDAP.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>lldap-cli</td>
|
||||
<td>LLDAP-CLI is an unofficial command line interface for LLDAP.</td>
|
||||
</tr>
|
||||
</table>
|
||||
LLDPA configuration file: /etc/lldap/lldap_config.toml<br>
|
||||
</details>
|
||||
The package for these distributions can be found at [LLDAP OBS](https://software.opensuse.org//download.html?project=home%3AMasgalor%3ALLDAP&package=lldap).
|
||||
- When using the distributed package, the default login is `admin/password`. You can change that from the web UI after starting the service.
|
||||
|
||||
### With FreeBSD
|
||||
#### Arch Linux
|
||||
|
||||
You can also install it as a rc.d service in FreeBSD, see
|
||||
[FreeBSD-install.md](example_configs/freebsd/freebsd-install.md).
|
||||
Arch Linux offers unofficial support through the [Arch User Repository
|
||||
(AUR)](https://wiki.archlinux.org/title/Arch_User_Repository).
|
||||
Available package descriptions in AUR are:
|
||||
|
||||
The rc.d script file
|
||||
[rc.d_lldap](example_configs/freebsd/rc.d_lldap).
|
||||
- [lldap](https://aur.archlinux.org/packages/lldap) - Builds the latest stable version.
|
||||
- [lldap-bin](https://aur.archlinux.org/packages/lldap-bin) - Uses the latest
|
||||
pre-compiled binaries from the [releases in this repository](https://github.com/lldap/lldap/releases).
|
||||
This package is recommended if you want to run lldap on a system with
|
||||
limited resources.
|
||||
- [lldap-git](https://aur.archlinux.org/packages/lldap-git) - Builds the
|
||||
latest main branch code.
|
||||
|
||||
The package descriptions can be used
|
||||
[to create and install packages](https://wiki.archlinux.org/title/Arch_User_Repository#Getting_started).
|
||||
Each package places lldap's configuration file at `/etc/lldap.toml` and offers
|
||||
[systemd service](https://wiki.archlinux.org/title/systemd#Using_units)
|
||||
`lldap.service` to (auto-)start and stop lldap.
|
||||
|
||||
### From source
|
||||
|
||||
@@ -483,13 +277,10 @@ create users, set passwords, add them to groups and so on. Users can also
|
||||
connect to the web UI and change their information, or request a password reset
|
||||
link (if you configured the SMTP client).
|
||||
|
||||
You can create and manage custom attributes through the Web UI, or through the
|
||||
community-contributed CLI frontend (
|
||||
[Zepmann/lldap-cli](https://github.com/Zepmann/lldap-cli)). This is necessary
|
||||
for some service integrations.
|
||||
|
||||
The [bootstrap.sh](scripts/bootstrap.sh) script can enforce a list of
|
||||
users/groups/attributes from a given file, reflecting it on the server.
|
||||
Creating and managing custom attributes is currently in Beta. It's not
|
||||
supported in the Web UI. The recommended way is to use
|
||||
[Zepmann/lldap-cli](https://github.com/Zepmann/lldap-cli), a
|
||||
community-contributed CLI frontend.
|
||||
|
||||
LLDAP is also very scriptable, through its GraphQL API. See the
|
||||
[Scripting](docs/scripting.md) docs for more info.
|
||||
@@ -551,13 +342,6 @@ admin rights in the Web UI. Most LDAP integrations should instead use a user in
|
||||
the `lldap_strict_readonly` or `lldap_password_manager` group, to avoid granting full
|
||||
administration access to many services.
|
||||
|
||||
### Integration with OS's
|
||||
|
||||
Integration with Linux accounts is possible, through PAM and nslcd. See [PAM
|
||||
configuration guide](example_configs/pam/README.md).
|
||||
|
||||
Integration with Windows (e.g. Samba) is WIP.
|
||||
|
||||
### Sample client configurations
|
||||
|
||||
Some specific clients have been tested to work and come with sample
|
||||
@@ -571,7 +355,6 @@ folder for help with:
|
||||
- [Authentik](example_configs/authentik.md)
|
||||
- [Bookstack](example_configs/bookstack.env.example)
|
||||
- [Calibre-Web](example_configs/calibre_web.md)
|
||||
- [Carpal](example_configs/carpal.md)
|
||||
- [Dell iDRAC](example_configs/dell_idrac.md)
|
||||
- [Dex](example_configs/dex_config.yml)
|
||||
- [Dokuwiki](example_configs/dokuwiki.md)
|
||||
@@ -583,7 +366,6 @@ folder for help with:
|
||||
- [GitLab](example_configs/gitlab.md)
|
||||
- [Grafana](example_configs/grafana_ldap_config.toml)
|
||||
- [Grocy](example_configs/grocy.md)
|
||||
- [Harbor](example_configs/harbor.md)
|
||||
- [Hedgedoc](example_configs/hedgedoc.md)
|
||||
- [Home Assistant](example_configs/home-assistant.md)
|
||||
- [Jellyfin](example_configs/jellyfin.md)
|
||||
@@ -596,23 +378,17 @@ folder for help with:
|
||||
- [Mastodon](example_configs/mastodon.env.example)
|
||||
- [Matrix](example_configs/matrix_synapse.yml)
|
||||
- [Mealie](example_configs/mealie.md)
|
||||
- [Metabase](example_configs/metabase.md)
|
||||
- [MegaRAC-BMC](example_configs/MegaRAC-SP-X-BMC.md)
|
||||
- [MinIO](example_configs/minio.md)
|
||||
- [Netbox](example_configs/netbox.md)
|
||||
- [Nextcloud](example_configs/nextcloud.md)
|
||||
- [Nexus](example_configs/nexus.md)
|
||||
- [OCIS (OwnCloud Infinite Scale)](example_configs/ocis.md)
|
||||
- [Organizr](example_configs/Organizr.md)
|
||||
- [Portainer](example_configs/portainer.md)
|
||||
- [PowerDNS Admin](example_configs/powerdns_admin.md)
|
||||
- [Prosody](example_configs/prosody.md)
|
||||
- [Proxmox VE](example_configs/proxmox.md)
|
||||
- [Radicale](example_configs/radicale.md)
|
||||
- [Rancher](example_configs/rancher.md)
|
||||
- [Seafile](example_configs/seafile.md)
|
||||
- [Shaarli](example_configs/shaarli.md)
|
||||
- [SonarQube](example_configs/sonarqube.md)
|
||||
- [Squid](example_configs/squid.md)
|
||||
- [Syncthing](example_configs/syncthing.md)
|
||||
- [TheLounge](example_configs/thelounge.md)
|
||||
|
||||
@@ -6,7 +6,7 @@ homepage = "https://github.com/lldap/lldap"
|
||||
license = "GPL-3.0-only"
|
||||
name = "lldap_app"
|
||||
repository = "https://github.com/lldap/lldap"
|
||||
version = "0.6.0"
|
||||
version = "0.5.1-alpha"
|
||||
include = ["src/**/*", "queries/**/*", "Cargo.toml", "../schema.graphql"]
|
||||
|
||||
[dependencies]
|
||||
@@ -22,8 +22,8 @@ rand = "0.8"
|
||||
serde = "1"
|
||||
serde_json = "1"
|
||||
url-escape = "0.1.1"
|
||||
validator = "0.14"
|
||||
validator_derive = "0.14"
|
||||
validator = "=0.14"
|
||||
validator_derive = "*"
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "*"
|
||||
yew = "0.19.3"
|
||||
@@ -75,10 +75,3 @@ rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[package.metadata.wasm-pack.profile.dev]
|
||||
wasm-opt = ['--enable-bulk-memory']
|
||||
[package.metadata.wasm-pack.profile.profiling]
|
||||
wasm-opt = ['--enable-bulk-memory']
|
||||
[package.metadata.wasm-pack.profile.release]
|
||||
wasm-opt = ['--enable-bulk-memory']
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
mutation CreateGroup($group: CreateGroupInput!) {
|
||||
createGroupWithDetails(request: $group) {
|
||||
mutation CreateGroup($name: String!) {
|
||||
createGroup(name: $name) {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ query GetGroupAttributesSchema {
|
||||
isList
|
||||
isVisible
|
||||
isHardcoded
|
||||
isReadonly
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,22 +8,5 @@ query GetGroupDetails($id: Int!) {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
attributes {
|
||||
name
|
||||
value
|
||||
}
|
||||
}
|
||||
schema {
|
||||
groupSchema {
|
||||
attributes {
|
||||
name
|
||||
attributeType
|
||||
isList
|
||||
isVisible
|
||||
isEditable
|
||||
isHardcoded
|
||||
isReadonly
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ query GetUserAttributesSchema {
|
||||
isVisible
|
||||
isEditable
|
||||
isHardcoded
|
||||
isReadonly
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ query GetUserDetails($id: String!) {
|
||||
user(userId: $id) {
|
||||
id
|
||||
email
|
||||
avatar
|
||||
displayName
|
||||
firstName
|
||||
lastName
|
||||
avatar
|
||||
creationDate
|
||||
uuid
|
||||
groups {
|
||||
@@ -13,18 +15,13 @@ query GetUserDetails($id: String!) {
|
||||
attributes {
|
||||
name
|
||||
value
|
||||
}
|
||||
}
|
||||
schema {
|
||||
userSchema {
|
||||
attributes {
|
||||
schema {
|
||||
name
|
||||
attributeType
|
||||
isList
|
||||
isVisible
|
||||
isEditable
|
||||
isHardcoded
|
||||
isReadonly
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
mutation UpdateGroup($group: UpdateGroupInput!) {
|
||||
updateGroup(group: $group) {
|
||||
ok
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,13 +155,8 @@ impl Component for AddGroupMemberComponent {
|
||||
let to_add_user_list = self.get_selectable_user_list(ctx, user_list);
|
||||
#[allow(unused_braces)]
|
||||
let make_select_option = |user: User| {
|
||||
let name = if user.display_name.is_empty() {
|
||||
user.id.clone()
|
||||
} else {
|
||||
user.display_name.clone()
|
||||
};
|
||||
html_nested! {
|
||||
<SelectOption value={user.id.clone()} text={name} key={user.id} />
|
||||
<SelectOption value={user.id.clone()} text={user.display_name.clone()} key={user.id} />
|
||||
}
|
||||
};
|
||||
html! {
|
||||
|
||||
@@ -234,7 +234,7 @@ impl App {
|
||||
<ListGroupSchema />
|
||||
},
|
||||
AppRoute::GroupDetails { group_id } => html! {
|
||||
<GroupDetails group_id={*group_id} is_admin={is_admin} />
|
||||
<GroupDetails group_id={*group_id} />
|
||||
},
|
||||
AppRoute::UserDetails { user_id } => html! {
|
||||
<UserDetails username={user_id.clone()} is_admin={is_admin} />
|
||||
|
||||
@@ -6,7 +6,6 @@ use yew::{function_component, html, virtual_dom::AttrValue, Properties};
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/get_user_details.graphql",
|
||||
variables_derives = "Clone,PartialEq,Eq",
|
||||
response_derives = "Debug, Hash, PartialEq, Eq, Clone",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
|
||||
@@ -1,23 +1,11 @@
|
||||
use crate::{
|
||||
components::{
|
||||
form::{
|
||||
attribute_input::{ListAttributeInput, SingleAttributeInput},
|
||||
field::Field,
|
||||
submit::Submit,
|
||||
},
|
||||
form::{field::Field, submit::Submit},
|
||||
router::AppRoute,
|
||||
},
|
||||
convert_attribute_type,
|
||||
infra::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
form_utils::{
|
||||
read_all_form_attributes, AttributeValue, EmailIsRequired, GraphQlAttributeSchema,
|
||||
IsAdmin,
|
||||
},
|
||||
schema::AttributeType,
|
||||
},
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
};
|
||||
use anyhow::{ensure, Result};
|
||||
use anyhow::{bail, Result};
|
||||
use gloo_console::log;
|
||||
use graphql_client::GraphQLQuery;
|
||||
use validator_derive::Validate;
|
||||
@@ -25,33 +13,6 @@ use yew::prelude::*;
|
||||
use yew_form_derive::Model;
|
||||
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/get_group_attributes_schema.graphql",
|
||||
response_derives = "Debug,Clone,PartialEq,Eq",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
pub struct GetGroupAttributesSchema;
|
||||
|
||||
use get_group_attributes_schema::ResponseData;
|
||||
|
||||
pub type Attribute =
|
||||
get_group_attributes_schema::GetGroupAttributesSchemaSchemaGroupSchemaAttributes;
|
||||
|
||||
convert_attribute_type!(get_group_attributes_schema::AttributeType);
|
||||
|
||||
impl From<&Attribute> for GraphQlAttributeSchema {
|
||||
fn from(attr: &Attribute) -> Self {
|
||||
Self {
|
||||
name: attr.name.clone(),
|
||||
is_list: attr.is_list,
|
||||
is_readonly: attr.is_readonly,
|
||||
is_editable: false, // Need to be admin to edit it.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
@@ -64,8 +25,6 @@ pub struct CreateGroup;
|
||||
pub struct CreateGroupForm {
|
||||
common: CommonComponentParts<Self>,
|
||||
form: yew_form::Form<CreateGroupModel>,
|
||||
attributes_schema: Option<Vec<Attribute>>,
|
||||
form_ref: NodeRef,
|
||||
}
|
||||
|
||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||
@@ -76,7 +35,6 @@ pub struct CreateGroupModel {
|
||||
|
||||
pub enum Msg {
|
||||
Update,
|
||||
ListAttributesResponse(Result<ResponseData>),
|
||||
SubmitForm,
|
||||
CreateGroupResponse(Result<create_group::ResponseData>),
|
||||
}
|
||||
@@ -90,33 +48,12 @@ impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::SubmitForm => {
|
||||
ensure!(self.form.validate(), "Check the form for errors");
|
||||
|
||||
let all_values = read_all_form_attributes(
|
||||
self.attributes_schema.iter().flatten(),
|
||||
&self.form_ref,
|
||||
IsAdmin(true),
|
||||
EmailIsRequired(false),
|
||||
)?;
|
||||
let attributes = Some(
|
||||
all_values
|
||||
.into_iter()
|
||||
.filter(|a| !a.values.is_empty())
|
||||
.map(
|
||||
|AttributeValue { name, values }| create_group::AttributeValueInput {
|
||||
name,
|
||||
value: values,
|
||||
},
|
||||
)
|
||||
.collect(),
|
||||
);
|
||||
|
||||
if !self.form.validate() {
|
||||
bail!("Check the form for errors");
|
||||
}
|
||||
let model = self.form.model();
|
||||
let req = create_group::Variables {
|
||||
group: create_group::CreateGroupInput {
|
||||
displayName: model.groupname,
|
||||
attributes,
|
||||
},
|
||||
name: model.groupname,
|
||||
};
|
||||
self.common.call_graphql::<CreateGroup, _>(
|
||||
ctx,
|
||||
@@ -129,16 +66,11 @@ impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
||||
Msg::CreateGroupResponse(response) => {
|
||||
log!(&format!(
|
||||
"Created group '{}'",
|
||||
&response?.create_group_with_details.display_name
|
||||
&response?.create_group.display_name
|
||||
));
|
||||
ctx.link().history().unwrap().push(AppRoute::ListGroups);
|
||||
Ok(true)
|
||||
}
|
||||
Msg::ListAttributesResponse(schema) => {
|
||||
self.attributes_schema =
|
||||
Some(schema?.schema.group_schema.attributes.into_iter().collect());
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,22 +83,11 @@ impl Component for CreateGroupForm {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut component = Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: yew_form::Form::<CreateGroupModel>::new(CreateGroupModel::default()),
|
||||
attributes_schema: None,
|
||||
form_ref: NodeRef::default(),
|
||||
};
|
||||
component
|
||||
.common
|
||||
.call_graphql::<GetGroupAttributesSchema, _>(
|
||||
ctx,
|
||||
get_group_attributes_schema::Variables {},
|
||||
Msg::ListAttributesResponse,
|
||||
"Error trying to fetch group schema",
|
||||
);
|
||||
component
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
@@ -177,8 +98,7 @@ impl Component for CreateGroupForm {
|
||||
let link = ctx.link();
|
||||
html! {
|
||||
<div class="row justify-content-center">
|
||||
<form class="form py-3" style="max-width: 636px"
|
||||
ref={self.form_ref.clone()}>
|
||||
<form class="form py-3" style="max-width: 636px">
|
||||
<div class="row mb-3">
|
||||
<h5 class="fw-bold">{"Create a group"}</h5>
|
||||
</div>
|
||||
@@ -188,14 +108,6 @@ impl Component for CreateGroupForm {
|
||||
label="Group name"
|
||||
field_name="groupname"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
{
|
||||
self.attributes_schema
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|a| !a.is_readonly && a.name != "display_name")
|
||||
.map(get_custom_attribute_input)
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
<Submit
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})} />
|
||||
@@ -212,21 +124,3 @@ impl Component for CreateGroupForm {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_custom_attribute_input(attribute_schema: &Attribute) -> Html {
|
||||
if attribute_schema.is_list {
|
||||
html! {
|
||||
<ListAttributeInput
|
||||
name={attribute_schema.name.clone()}
|
||||
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||
/>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<SingleAttributeInput
|
||||
name={attribute_schema.name.clone()}
|
||||
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
use crate::{
|
||||
components::{
|
||||
form::{
|
||||
attribute_input::{ListAttributeInput, SingleAttributeInput},
|
||||
field::Field,
|
||||
submit::Submit,
|
||||
},
|
||||
form::{field::Field, submit::Submit},
|
||||
router::AppRoute,
|
||||
},
|
||||
convert_attribute_type,
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
form_utils::{
|
||||
read_all_form_attributes, AttributeValue, EmailIsRequired, GraphQlAttributeSchema,
|
||||
IsAdmin,
|
||||
},
|
||||
schema::AttributeType,
|
||||
},
|
||||
};
|
||||
use anyhow::{ensure, Result};
|
||||
use anyhow::{bail, Result};
|
||||
use gloo_console::log;
|
||||
use graphql_client::GraphQLQuery;
|
||||
use lldap_auth::{opaque, registration};
|
||||
@@ -27,32 +17,6 @@ use yew::prelude::*;
|
||||
use yew_form_derive::Model;
|
||||
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/get_user_attributes_schema.graphql",
|
||||
response_derives = "Debug,Clone,PartialEq,Eq",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
pub struct GetUserAttributesSchema;
|
||||
|
||||
use get_user_attributes_schema::ResponseData;
|
||||
|
||||
pub type Attribute = get_user_attributes_schema::GetUserAttributesSchemaSchemaUserSchemaAttributes;
|
||||
|
||||
convert_attribute_type!(get_user_attributes_schema::AttributeType);
|
||||
|
||||
impl From<&Attribute> for GraphQlAttributeSchema {
|
||||
fn from(attr: &Attribute) -> Self {
|
||||
Self {
|
||||
name: attr.name.clone(),
|
||||
is_list: attr.is_list,
|
||||
is_readonly: attr.is_readonly,
|
||||
is_editable: attr.is_editable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
@@ -65,14 +29,17 @@ pub struct CreateUser;
|
||||
pub struct CreateUserForm {
|
||||
common: CommonComponentParts<Self>,
|
||||
form: yew_form::Form<CreateUserModel>,
|
||||
attributes_schema: Option<Vec<Attribute>>,
|
||||
form_ref: NodeRef,
|
||||
}
|
||||
|
||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||
pub struct CreateUserModel {
|
||||
#[validate(length(min = 1, message = "Username is required"))]
|
||||
username: String,
|
||||
#[validate(email(message = "A valid email is required"))]
|
||||
email: String,
|
||||
display_name: String,
|
||||
first_name: String,
|
||||
last_name: String,
|
||||
#[validate(custom(
|
||||
function = "empty_or_long",
|
||||
message = "Password should be longer than 8 characters (or left empty)"
|
||||
@@ -92,7 +59,6 @@ fn empty_or_long(value: &str) -> Result<(), validator::ValidationError> {
|
||||
|
||||
pub enum Msg {
|
||||
Update,
|
||||
ListAttributesResponse(Result<ResponseData>),
|
||||
SubmitForm,
|
||||
CreateUserResponse(Result<create_user::ResponseData>),
|
||||
SuccessfulCreation,
|
||||
@@ -113,43 +79,21 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::ListAttributesResponse(schema) => {
|
||||
self.attributes_schema =
|
||||
Some(schema?.schema.user_schema.attributes.into_iter().collect());
|
||||
Ok(true)
|
||||
}
|
||||
Msg::SubmitForm => {
|
||||
ensure!(self.form.validate(), "Check the form for errors");
|
||||
|
||||
let all_values = read_all_form_attributes(
|
||||
self.attributes_schema.iter().flatten(),
|
||||
&self.form_ref,
|
||||
IsAdmin(true),
|
||||
EmailIsRequired(true),
|
||||
)?;
|
||||
let attributes = Some(
|
||||
all_values
|
||||
.into_iter()
|
||||
.filter(|a| !a.values.is_empty())
|
||||
.map(
|
||||
|AttributeValue { name, values }| create_user::AttributeValueInput {
|
||||
name,
|
||||
value: values,
|
||||
},
|
||||
)
|
||||
.collect(),
|
||||
);
|
||||
|
||||
if !self.form.validate() {
|
||||
bail!("Check the form for errors");
|
||||
}
|
||||
let model = self.form.model();
|
||||
let to_option = |s: String| if s.is_empty() { None } else { Some(s) };
|
||||
let req = create_user::Variables {
|
||||
user: create_user::CreateUserInput {
|
||||
id: model.username,
|
||||
email: None,
|
||||
displayName: None,
|
||||
firstName: None,
|
||||
lastName: None,
|
||||
email: model.email,
|
||||
displayName: to_option(model.display_name),
|
||||
firstName: to_option(model.first_name),
|
||||
lastName: to_option(model.last_name),
|
||||
avatar: None,
|
||||
attributes,
|
||||
attributes: None,
|
||||
},
|
||||
};
|
||||
self.common.call_graphql::<CreateUser, _>(
|
||||
@@ -233,20 +177,11 @@ impl Component for CreateUserForm {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut component = Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: yew_form::Form::<CreateUserModel>::new(CreateUserModel::default()),
|
||||
attributes_schema: None,
|
||||
form_ref: NodeRef::default(),
|
||||
};
|
||||
component.common.call_graphql::<GetUserAttributesSchema, _>(
|
||||
ctx,
|
||||
get_user_attributes_schema::Variables {},
|
||||
Msg::ListAttributesResponse,
|
||||
"Error trying to fetch user schema",
|
||||
);
|
||||
component
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
@@ -257,22 +192,38 @@ impl Component for CreateUserForm {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<div class="row justify-content-center">
|
||||
<form class="form py-3"
|
||||
ref={self.form_ref.clone()}>
|
||||
<form class="form py-3" style="max-width: 636px">
|
||||
<Field<CreateUserModel>
|
||||
form={&self.form}
|
||||
required=true
|
||||
label="User name"
|
||||
field_name="username"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
{
|
||||
self.attributes_schema
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|a| !a.is_readonly)
|
||||
.map(get_custom_attribute_input)
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
<Field<CreateUserModel>
|
||||
form={&self.form}
|
||||
required=true
|
||||
label="Email"
|
||||
field_name="email"
|
||||
input_type="email"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Field<CreateUserModel>
|
||||
form={&self.form}
|
||||
label="Display name"
|
||||
field_name="display_name"
|
||||
autocomplete="name"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Field<CreateUserModel>
|
||||
form={&self.form}
|
||||
label="First name"
|
||||
field_name="first_name"
|
||||
autocomplete="given-name"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Field<CreateUserModel>
|
||||
form={&self.form}
|
||||
label="Last name"
|
||||
field_name="last_name"
|
||||
autocomplete="family-name"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Field<CreateUserModel>
|
||||
form={&self.form}
|
||||
label="Password"
|
||||
@@ -304,21 +255,3 @@ impl Component for CreateUserForm {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_custom_attribute_input(attribute_schema: &Attribute) -> Html {
|
||||
if attribute_schema.is_list {
|
||||
html! {
|
||||
<ListAttributeInput
|
||||
name={attribute_schema.name.clone()}
|
||||
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||
/>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<SingleAttributeInput
|
||||
name={attribute_schema.name.clone()}
|
||||
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
use crate::{
|
||||
components::form::{date_input::DateTimeInput, file_input::JpegFileInput},
|
||||
infra::{schema::AttributeType, tooltip::Tooltip},
|
||||
};
|
||||
use web_sys::Element;
|
||||
use crate::infra::schema::AttributeType;
|
||||
use yew::{
|
||||
function_component, html, use_effect_with_deps, use_node_ref, virtual_dom::AttrValue,
|
||||
Component, Context, Html, Properties,
|
||||
function_component, html, virtual_dom::AttrValue, Callback, InputEvent, NodeRef, Properties,
|
||||
};
|
||||
|
||||
/*
|
||||
<input
|
||||
ref={&ctx.props().input_ref}
|
||||
type="text"
|
||||
class="input-component"
|
||||
placeholder={placeholder}
|
||||
onmouseover={ctx.link().callback(|_| Msg::Hover)}
|
||||
/>
|
||||
*/
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
struct AttributeInputProps {
|
||||
name: AttrValue,
|
||||
@@ -21,64 +26,23 @@ fn attribute_input(props: &AttributeInputProps) -> Html {
|
||||
let input_type = match props.attribute_type {
|
||||
AttributeType::String => "text",
|
||||
AttributeType::Integer => "number",
|
||||
AttributeType::DateTime => {
|
||||
return html! {
|
||||
<DateTimeInput name={props.name.clone()} value={props.value.clone()} />
|
||||
}
|
||||
}
|
||||
AttributeType::Jpeg => {
|
||||
return html! {
|
||||
<JpegFileInput name={props.name.clone()} value={props.value.clone()} />
|
||||
}
|
||||
}
|
||||
AttributeType::DateTime => "datetime-local",
|
||||
AttributeType::Jpeg => "file",
|
||||
};
|
||||
let accept = match props.attribute_type {
|
||||
AttributeType::Jpeg => Some("image/jpeg"),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
html! {
|
||||
<input
|
||||
type={input_type}
|
||||
accept={accept}
|
||||
name={props.name.clone()}
|
||||
class="form-control"
|
||||
value={props.value.clone()} />
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
struct AttributeLabelProps {
|
||||
pub name: String,
|
||||
}
|
||||
#[function_component(AttributeLabel)]
|
||||
fn attribute_label(props: &AttributeLabelProps) -> Html {
|
||||
let tooltip_ref = use_node_ref();
|
||||
|
||||
use_effect_with_deps(
|
||||
move |tooltip_ref| {
|
||||
Tooltip::new(
|
||||
tooltip_ref
|
||||
.cast::<Element>()
|
||||
.expect("Tooltip element should exist"),
|
||||
);
|
||||
|| {}
|
||||
},
|
||||
tooltip_ref.clone(),
|
||||
);
|
||||
|
||||
html! {
|
||||
<label for={props.name.clone()}
|
||||
class="form-label col-4 col-form-label"
|
||||
>
|
||||
{props.name[0..1].to_uppercase() + &props.name[1..].replace('_', " ")}{":"}
|
||||
<button
|
||||
class="btn btn-sm btn-link"
|
||||
type="button"
|
||||
data-bs-placement="right"
|
||||
title={props.name.clone()}
|
||||
ref={tooltip_ref}>
|
||||
<i class="bi bi-info-circle" aria-label="Info" />
|
||||
</button>
|
||||
</label>
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct SingleAttributeInputProps {
|
||||
pub name: String,
|
||||
@@ -91,7 +55,10 @@ pub struct SingleAttributeInputProps {
|
||||
pub fn single_attribute_input(props: &SingleAttributeInputProps) -> Html {
|
||||
html! {
|
||||
<div class="row mb-3">
|
||||
<AttributeLabel name={props.name.clone()} />
|
||||
<label for={props.name.clone()}
|
||||
class="form-label col-4 col-form-label">
|
||||
{&props.name}{":"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<AttributeInput
|
||||
attribute_type={props.attribute_type.clone()}
|
||||
@@ -101,90 +68,3 @@ pub fn single_attribute_input(props: &SingleAttributeInputProps) -> Html {
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct ListAttributeInputProps {
|
||||
pub name: String,
|
||||
pub attribute_type: AttributeType,
|
||||
#[prop_or(vec!())]
|
||||
pub values: Vec<String>,
|
||||
}
|
||||
|
||||
pub enum ListAttributeInputMsg {
|
||||
Remove(usize),
|
||||
Append,
|
||||
}
|
||||
|
||||
pub struct ListAttributeInput {
|
||||
indices: Vec<usize>,
|
||||
next_index: usize,
|
||||
values: Vec<String>,
|
||||
}
|
||||
impl Component for ListAttributeInput {
|
||||
type Message = ListAttributeInputMsg;
|
||||
type Properties = ListAttributeInputProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let values = ctx.props().values.clone();
|
||||
Self {
|
||||
indices: (0..values.len()).collect(),
|
||||
next_index: values.len(),
|
||||
values,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
ListAttributeInputMsg::Remove(removed) => {
|
||||
self.indices.retain_mut(|x| *x != removed);
|
||||
}
|
||||
ListAttributeInputMsg::Append => {
|
||||
self.indices.push(self.next_index);
|
||||
self.next_index += 1;
|
||||
}
|
||||
};
|
||||
true
|
||||
}
|
||||
|
||||
fn changed(&mut self, ctx: &Context<Self>) -> bool {
|
||||
if ctx.props().values != self.values {
|
||||
self.values.clone_from(&ctx.props().values);
|
||||
self.indices = (0..self.values.len()).collect();
|
||||
self.next_index = self.values.len();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let props = &ctx.props();
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<div class="row mb-3">
|
||||
<AttributeLabel name={props.name.clone()} />
|
||||
<div class="col-8">
|
||||
{self.indices.iter().map(|&i| html! {
|
||||
<div class="input-group mb-2" key={i}>
|
||||
<AttributeInput
|
||||
attribute_type={props.attribute_type.clone()}
|
||||
name={props.name.clone()}
|
||||
value={props.values.get(i).cloned().unwrap_or_default()} />
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
type="button"
|
||||
onclick={link.callback(move |_| ListAttributeInputMsg::Remove(i))}>
|
||||
<i class="bi-x-circle-fill" aria-label="Remove value" />
|
||||
</button>
|
||||
</div>
|
||||
}).collect::<Html>()}
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
type="button"
|
||||
onclick={link.callback(|_| ListAttributeInputMsg::Append)}>
|
||||
<i class="bi-plus-circle me-2"></i>
|
||||
{"Add value"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::HtmlInputElement;
|
||||
use yew::{function_component, html, use_state, virtual_dom::AttrValue, Event, Properties};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct DateTimeInputProps {
|
||||
pub name: AttrValue,
|
||||
pub value: Option<String>,
|
||||
}
|
||||
|
||||
#[function_component(DateTimeInput)]
|
||||
pub fn date_time_input(props: &DateTimeInputProps) -> Html {
|
||||
let value = use_state(|| {
|
||||
props
|
||||
.value
|
||||
.as_ref()
|
||||
.and_then(|x| DateTime::<Utc>::from_str(x).ok())
|
||||
});
|
||||
|
||||
html! {
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="hidden"
|
||||
name={props.name.clone()}
|
||||
value={value.as_ref().map(|v: &DateTime<Utc>| v.to_rfc3339())} />
|
||||
<input
|
||||
type="datetime-local"
|
||||
step="1"
|
||||
class="form-control"
|
||||
value={value.as_ref().map(|v: &DateTime<Utc>| v.naive_utc().to_string())}
|
||||
onchange={move |e: Event| {
|
||||
let string_val =
|
||||
e.target()
|
||||
.expect("Event should have target")
|
||||
.unchecked_into::<HtmlInputElement>()
|
||||
.value();
|
||||
value.set(
|
||||
NaiveDateTime::from_str(&string_val)
|
||||
.ok()
|
||||
.map(|x| DateTime::from_naive_utc_and_offset(x, Utc))
|
||||
)
|
||||
}} />
|
||||
<span class="input-group-text">{"UTC"}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
use std::{fmt::Display, str::FromStr};
|
||||
|
||||
use anyhow::{bail, Error, Ok, Result};
|
||||
use gloo_file::{
|
||||
callbacks::{read_as_bytes, FileReader},
|
||||
File,
|
||||
};
|
||||
use web_sys::{FileList, HtmlInputElement, InputEvent};
|
||||
use yew::Properties;
|
||||
use yew::{prelude::*, virtual_dom::AttrValue};
|
||||
|
||||
#[derive(Default)]
|
||||
struct JsFile {
|
||||
file: Option<File>,
|
||||
contents: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Display for JsFile {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
self.file.as_ref().map(File::name).unwrap_or_default()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for JsFile {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self> {
|
||||
if s.is_empty() {
|
||||
Ok(JsFile::default())
|
||||
} else {
|
||||
bail!("Building file from non-empty string")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn to_base64(file: &JsFile) -> Result<String> {
|
||||
match file {
|
||||
JsFile {
|
||||
file: None,
|
||||
contents: None,
|
||||
} => Ok(String::new()),
|
||||
JsFile {
|
||||
file: Some(_),
|
||||
contents: None,
|
||||
} => bail!("Image file hasn't finished loading, try again"),
|
||||
JsFile {
|
||||
file: Some(_),
|
||||
contents: Some(data),
|
||||
} => {
|
||||
if !is_valid_jpeg(data.as_slice()) {
|
||||
bail!("Chosen image is not a valid JPEG");
|
||||
}
|
||||
Ok(base64::encode(data))
|
||||
}
|
||||
JsFile {
|
||||
file: None,
|
||||
contents: Some(data),
|
||||
} => Ok(base64::encode(data)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A [yew::Component] to display the user details, with a form allowing to edit them.
|
||||
pub struct JpegFileInput {
|
||||
// None means that the avatar hasn't changed.
|
||||
avatar: Option<JsFile>,
|
||||
reader: Option<FileReader>,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
Update,
|
||||
/// A new file was selected.
|
||||
FileSelected(File),
|
||||
/// The "Clear" button for the avatar was clicked.
|
||||
ClearClicked,
|
||||
/// A picked file finished loading.
|
||||
FileLoaded(String, Result<Vec<u8>>),
|
||||
}
|
||||
|
||||
#[derive(Properties, Clone, PartialEq, Eq)]
|
||||
pub struct Props {
|
||||
pub name: AttrValue,
|
||||
pub value: Option<String>,
|
||||
}
|
||||
|
||||
impl Component for JpegFileInput {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
Self {
|
||||
avatar: Some(JsFile {
|
||||
file: None,
|
||||
contents: ctx
|
||||
.props()
|
||||
.value
|
||||
.as_ref()
|
||||
.and_then(|x| base64::decode(x).ok()),
|
||||
}),
|
||||
reader: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn changed(&mut self, ctx: &Context<Self>) -> bool {
|
||||
self.avatar = Some(JsFile {
|
||||
file: None,
|
||||
contents: ctx
|
||||
.props()
|
||||
.value
|
||||
.as_ref()
|
||||
.and_then(|x| base64::decode(x).ok()),
|
||||
});
|
||||
self.reader = None;
|
||||
true
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
Msg::Update => true,
|
||||
Msg::FileSelected(new_avatar) => {
|
||||
if self
|
||||
.avatar
|
||||
.as_ref()
|
||||
.and_then(|f| f.file.as_ref().map(|f| f.name()))
|
||||
!= Some(new_avatar.name())
|
||||
{
|
||||
let file_name = new_avatar.name();
|
||||
let link = ctx.link().clone();
|
||||
self.reader = Some(read_as_bytes(&new_avatar, move |res| {
|
||||
link.send_message(Msg::FileLoaded(
|
||||
file_name,
|
||||
res.map_err(|e| anyhow::anyhow!("{:#}", e)),
|
||||
))
|
||||
}));
|
||||
self.avatar = Some(JsFile {
|
||||
file: Some(new_avatar),
|
||||
contents: None,
|
||||
});
|
||||
}
|
||||
true
|
||||
}
|
||||
Msg::ClearClicked => {
|
||||
self.avatar = Some(JsFile::default());
|
||||
true
|
||||
}
|
||||
Msg::FileLoaded(file_name, data) => {
|
||||
if let Some(avatar) = &mut self.avatar {
|
||||
if let Some(file) = &avatar.file {
|
||||
if file.name() == file_name {
|
||||
if let Result::Ok(data) = data {
|
||||
if !is_valid_jpeg(data.as_slice()) {
|
||||
// Clear the selection.
|
||||
self.avatar = Some(JsFile::default());
|
||||
// TODO: bail!("Chosen image is not a valid JPEG");
|
||||
} else {
|
||||
avatar.contents = Some(data);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.reader = None;
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
|
||||
let avatar_string = match &self.avatar {
|
||||
Some(avatar) => {
|
||||
let avatar_base64 = to_base64(avatar);
|
||||
avatar_base64.as_deref().unwrap_or("").to_owned()
|
||||
}
|
||||
None => String::new(),
|
||||
};
|
||||
html! {
|
||||
<div class="row align-items-center">
|
||||
<div class="col-5">
|
||||
<input type="hidden" name={ctx.props().name.clone()} value={avatar_string.clone()} />
|
||||
<input
|
||||
class="form-control"
|
||||
id="avatarInput"
|
||||
type="file"
|
||||
accept="image/jpeg"
|
||||
oninput={link.callback(|e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
Self::upload_files(input.files())
|
||||
})} />
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<button
|
||||
class="btn btn-secondary col-auto"
|
||||
id="avatarClear"
|
||||
type="button"
|
||||
onclick={link.callback(|_| {Msg::ClearClicked})}>
|
||||
{"Clear"}
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
{
|
||||
if !avatar_string.is_empty() {
|
||||
html!{
|
||||
<img
|
||||
id="avatarDisplay"
|
||||
src={format!("data:image/jpeg;base64, {}", avatar_string)}
|
||||
style="max-height:128px;max-width:128px;height:auto;width:auto;"
|
||||
alt="Avatar" />
|
||||
}
|
||||
} else { html! {} }
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JpegFileInput {
|
||||
fn upload_files(files: Option<FileList>) -> Msg {
|
||||
match files {
|
||||
Some(files) if files.length() > 0 => {
|
||||
Msg::FileSelected(File::from(files.item(0).unwrap()))
|
||||
}
|
||||
Some(_) | None => Msg::Update,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_valid_jpeg(bytes: &[u8]) -> bool {
|
||||
image::io::Reader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg)
|
||||
.decode()
|
||||
.is_ok()
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
pub mod attribute_input;
|
||||
pub mod checkbox;
|
||||
pub mod date_input;
|
||||
pub mod field;
|
||||
pub mod file_input;
|
||||
pub mod select;
|
||||
pub mod static_value;
|
||||
pub mod submit;
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
use crate::{
|
||||
components::{
|
||||
add_group_member::{self, AddGroupMemberComponent},
|
||||
group_details_form::GroupDetailsForm,
|
||||
remove_user_from_group::RemoveUserFromGroupComponent,
|
||||
router::{AppRoute, Link},
|
||||
},
|
||||
convert_attribute_type,
|
||||
infra::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
form_utils::GraphQlAttributeSchema,
|
||||
},
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
};
|
||||
use anyhow::{bail, Error, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
@@ -27,28 +22,12 @@ pub struct GetGroupDetails;
|
||||
pub type Group = get_group_details::GetGroupDetailsGroup;
|
||||
pub type User = get_group_details::GetGroupDetailsGroupUsers;
|
||||
pub type AddGroupMemberUser = add_group_member::User;
|
||||
pub type Attribute = get_group_details::GetGroupDetailsGroupAttributes;
|
||||
pub type AttributeSchema = get_group_details::GetGroupDetailsSchemaGroupSchemaAttributes;
|
||||
pub type AttributeType = get_group_details::AttributeType;
|
||||
|
||||
convert_attribute_type!(AttributeType);
|
||||
|
||||
impl From<&AttributeSchema> for GraphQlAttributeSchema {
|
||||
fn from(attr: &AttributeSchema) -> Self {
|
||||
Self {
|
||||
name: attr.name.clone(),
|
||||
is_list: attr.is_list,
|
||||
is_readonly: attr.is_readonly,
|
||||
is_editable: attr.is_editable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GroupDetails {
|
||||
common: CommonComponentParts<Self>,
|
||||
/// The group info. If none, the error is in `error`. If `error` is None, then we haven't
|
||||
/// received the server response yet.
|
||||
group_and_schema: Option<(Group, Vec<AttributeSchema>)>,
|
||||
group: Option<Group>,
|
||||
}
|
||||
|
||||
/// State machine describing the possible transitions of the component state.
|
||||
@@ -59,13 +38,11 @@ pub enum Msg {
|
||||
OnError(Error),
|
||||
OnUserAddedToGroup(AddGroupMemberUser),
|
||||
OnUserRemovedFromGroup((String, i64)),
|
||||
DisplayNameUpdated,
|
||||
}
|
||||
|
||||
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
||||
pub struct Props {
|
||||
pub group_id: i64,
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
impl GroupDetails {
|
||||
@@ -92,16 +69,41 @@ impl GroupDetails {
|
||||
}
|
||||
}
|
||||
|
||||
fn view_details(&self, ctx: &Context<Self>, g: &Group, schema: Vec<AttributeSchema>) -> Html {
|
||||
fn view_details(&self, g: &Group) -> Html {
|
||||
html! {
|
||||
<>
|
||||
<h3>{g.display_name.to_string()}</h3>
|
||||
<GroupDetailsForm
|
||||
group={g.clone()}
|
||||
group_attributes_schema={schema}
|
||||
is_admin={ctx.props().is_admin}
|
||||
on_display_name_updated={ctx.link().callback(|_| Msg::DisplayNameUpdated)}
|
||||
/>
|
||||
<div class="py-3">
|
||||
<form class="form">
|
||||
<div class="form-group row mb-3">
|
||||
<label for="displayName"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Group: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id="groupId" class="form-constrol-static">{g.display_name.to_string()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="creationDate"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Creation date: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id="creationDate" class="form-constrol-static">{g.creation_date.naive_local().date()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="uuid"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"UUID: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id="uuid" class="form-constrol-static">{g.uuid.to_string()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
}
|
||||
@@ -180,38 +182,29 @@ impl GroupDetails {
|
||||
}
|
||||
|
||||
impl CommonComponent<GroupDetails> for GroupDetails {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::GroupDetailsResponse(response) => match response {
|
||||
Ok(group) => {
|
||||
self.group_and_schema =
|
||||
Some((group.group, group.schema.group_schema.attributes))
|
||||
}
|
||||
Ok(group) => self.group = Some(group.group),
|
||||
Err(e) => {
|
||||
self.group_and_schema = None;
|
||||
self.group = None;
|
||||
bail!("Error getting user details: {}", e);
|
||||
}
|
||||
},
|
||||
Msg::OnError(e) => return Err(e),
|
||||
Msg::OnUserAddedToGroup(user) => {
|
||||
self.group_and_schema.as_mut().unwrap().0.users.push(User {
|
||||
self.group.as_mut().unwrap().users.push(User {
|
||||
id: user.id,
|
||||
display_name: user.display_name,
|
||||
});
|
||||
}
|
||||
Msg::OnUserRemovedFromGroup((user_id, _)) => {
|
||||
self.group_and_schema
|
||||
self.group
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.0
|
||||
.users
|
||||
.retain(|u| u.id != user_id);
|
||||
}
|
||||
Msg::DisplayNameUpdated => self.get_group_details(ctx),
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
@@ -228,7 +221,7 @@ impl Component for GroupDetails {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut table = Self {
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
group_and_schema: None,
|
||||
group: None,
|
||||
};
|
||||
table.get_group_details(ctx);
|
||||
table
|
||||
@@ -239,15 +232,15 @@ impl Component for GroupDetails {
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
match (&self.group_and_schema, &self.common.error) {
|
||||
match (&self.group, &self.common.error) {
|
||||
(None, None) => html! {{"Loading..."}},
|
||||
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||
(Some((group, schema)), error) => {
|
||||
(Some(u), error) => {
|
||||
html! {
|
||||
<div>
|
||||
{self.view_details(ctx, group, schema.clone())}
|
||||
{self.view_user_list(ctx, group)}
|
||||
{self.view_add_user_button(ctx, group)}
|
||||
{self.view_details(u)}
|
||||
{self.view_user_list(ctx, u)}
|
||||
{self.view_add_user_button(ctx, u)}
|
||||
{self.view_messages(error)}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
use crate::{
|
||||
components::{
|
||||
form::{
|
||||
attribute_input::{ListAttributeInput, SingleAttributeInput},
|
||||
static_value::StaticValue,
|
||||
submit::Submit,
|
||||
},
|
||||
group_details::{Attribute, AttributeSchema, Group},
|
||||
},
|
||||
infra::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
form_utils::{read_all_form_attributes, AttributeValue, EmailIsRequired, IsAdmin},
|
||||
schema::AttributeType,
|
||||
},
|
||||
};
|
||||
use anyhow::{Ok, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::prelude::*;
|
||||
|
||||
/// The GraphQL query sent to the server to update the group details.
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/update_group.graphql",
|
||||
response_derives = "Debug",
|
||||
variables_derives = "Clone,PartialEq,Eq",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
pub struct UpdateGroup;
|
||||
|
||||
/// A [yew::Component] to display the group details, with a form allowing to edit them.
|
||||
pub struct GroupDetailsForm {
|
||||
common: CommonComponentParts<Self>,
|
||||
/// True if we just successfully updated the group, to display a success message.
|
||||
just_updated: bool,
|
||||
updated_group_name: bool,
|
||||
group: Group,
|
||||
form_ref: NodeRef,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
/// A form field changed.
|
||||
Update,
|
||||
/// The "Submit" button was clicked.
|
||||
SubmitClicked,
|
||||
/// We got the response from the server about our update message.
|
||||
GroupUpdated(Result<update_group::ResponseData>),
|
||||
}
|
||||
|
||||
#[derive(yew::Properties, Clone, PartialEq)]
|
||||
pub struct Props {
|
||||
/// The current group details.
|
||||
pub group: Group,
|
||||
pub group_attributes_schema: Vec<AttributeSchema>,
|
||||
pub is_admin: bool,
|
||||
pub on_display_name_updated: Callback<()>,
|
||||
}
|
||||
|
||||
impl CommonComponent<GroupDetailsForm> for GroupDetailsForm {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::SubmitClicked => self.submit_group_update_form(ctx),
|
||||
Msg::GroupUpdated(Err(e)) => Err(e),
|
||||
Msg::GroupUpdated(Result::Ok(_)) => {
|
||||
self.just_updated = true;
|
||||
if self.updated_group_name {
|
||||
self.updated_group_name = false;
|
||||
ctx.props().on_display_name_updated.emit(());
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for GroupDetailsForm {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
just_updated: false,
|
||||
updated_group_name: false,
|
||||
group: ctx.props().group.clone(),
|
||||
form_ref: NodeRef::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
self.just_updated = false;
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
|
||||
let can_edit =
|
||||
|a: &AttributeSchema| (ctx.props().is_admin || a.is_editable) && !a.is_readonly;
|
||||
let display_field = |a: &AttributeSchema| {
|
||||
if can_edit(a) {
|
||||
get_custom_attribute_input(a, &self.group.attributes)
|
||||
} else {
|
||||
get_custom_attribute_static(a, &self.group.attributes)
|
||||
}
|
||||
};
|
||||
html! {
|
||||
<div class="py-3">
|
||||
<form
|
||||
class="form"
|
||||
ref={self.form_ref.clone()}>
|
||||
<StaticValue label="Group ID" id="groupId">
|
||||
<i>{&self.group.id}</i>
|
||||
</StaticValue>
|
||||
{
|
||||
ctx
|
||||
.props()
|
||||
.group_attributes_schema
|
||||
.iter()
|
||||
.filter(|a| a.is_hardcoded && a.name != "group_id")
|
||||
.map(display_field)
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
{
|
||||
ctx
|
||||
.props()
|
||||
.group_attributes_schema
|
||||
.iter()
|
||||
.filter(|a| !a.is_hardcoded)
|
||||
.map(display_field)
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
<Submit
|
||||
text="Save changes"
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})} />
|
||||
</form>
|
||||
{
|
||||
if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
<div class="alert alert-danger">
|
||||
{e.to_string() }
|
||||
</div>
|
||||
}
|
||||
} else { html! {} }
|
||||
}
|
||||
<div hidden={!self.just_updated}>
|
||||
<div class="alert alert-success mt-4">{"Group successfully updated!"}</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_custom_attribute_input(
|
||||
attribute_schema: &AttributeSchema,
|
||||
group_attributes: &[Attribute],
|
||||
) -> Html {
|
||||
let values = group_attributes
|
||||
.iter()
|
||||
.find(|a| a.name == attribute_schema.name)
|
||||
.map(|attribute| attribute.value.clone())
|
||||
.unwrap_or_default();
|
||||
if attribute_schema.is_list {
|
||||
html! {
|
||||
<ListAttributeInput
|
||||
name={attribute_schema.name.clone()}
|
||||
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||
values={values}
|
||||
/>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<SingleAttributeInput
|
||||
name={attribute_schema.name.clone()}
|
||||
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||
value={values.first().cloned().unwrap_or_default()}
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_custom_attribute_static(
|
||||
attribute_schema: &AttributeSchema,
|
||||
group_attributes: &[Attribute],
|
||||
) -> Html {
|
||||
let values = group_attributes
|
||||
.iter()
|
||||
.find(|a| a.name == attribute_schema.name)
|
||||
.map(|attribute| attribute.value.clone())
|
||||
.unwrap_or_default();
|
||||
html! {
|
||||
<StaticValue label={attribute_schema.name.clone()} id={attribute_schema.name.clone()}>
|
||||
{values.into_iter().map(|x| html!{<div>{x}</div>}).collect::<Vec<_>>()}
|
||||
</StaticValue>
|
||||
}
|
||||
}
|
||||
|
||||
impl GroupDetailsForm {
|
||||
fn submit_group_update_form(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
||||
let mut all_values = read_all_form_attributes(
|
||||
ctx.props().group_attributes_schema.iter(),
|
||||
&self.form_ref,
|
||||
IsAdmin(ctx.props().is_admin),
|
||||
EmailIsRequired(false),
|
||||
)?;
|
||||
let base_attributes = &self.group.attributes;
|
||||
all_values.retain(|a| {
|
||||
let base_val = base_attributes
|
||||
.iter()
|
||||
.find(|base_val| base_val.name == a.name);
|
||||
base_val
|
||||
.map(|v| v.value != a.values)
|
||||
.unwrap_or(!a.values.is_empty())
|
||||
});
|
||||
if all_values.iter().any(|a| a.name == "display_name") {
|
||||
self.updated_group_name = true;
|
||||
}
|
||||
let remove_attributes: Option<Vec<String>> = if all_values.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(all_values.iter().map(|a| a.name.clone()).collect())
|
||||
};
|
||||
let insert_attributes: Option<Vec<update_group::AttributeValueInput>> =
|
||||
if remove_attributes.is_none() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
all_values
|
||||
.into_iter()
|
||||
.filter(|a| !a.values.is_empty())
|
||||
.map(
|
||||
|AttributeValue { name, values }| update_group::AttributeValueInput {
|
||||
name,
|
||||
value: values,
|
||||
},
|
||||
)
|
||||
.collect(),
|
||||
)
|
||||
};
|
||||
let mut group_input = update_group::UpdateGroupInput {
|
||||
id: self.group.id,
|
||||
displayName: None,
|
||||
removeAttributes: None,
|
||||
insertAttributes: None,
|
||||
};
|
||||
let default_group_input = group_input.clone();
|
||||
group_input.removeAttributes = remove_attributes;
|
||||
group_input.insertAttributes = insert_attributes;
|
||||
// Nothing changed.
|
||||
if group_input == default_group_input {
|
||||
return Ok(false);
|
||||
}
|
||||
let req = update_group::Variables { group: group_input };
|
||||
self.common.call_graphql::<UpdateGroup, _>(
|
||||
ctx,
|
||||
req,
|
||||
Msg::GroupUpdated,
|
||||
"Error trying to update group",
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ pub mod delete_user;
|
||||
pub mod delete_user_attribute;
|
||||
pub mod form;
|
||||
pub mod group_details;
|
||||
pub mod group_details_form;
|
||||
pub mod group_schema_table;
|
||||
pub mod group_table;
|
||||
pub mod login;
|
||||
|
||||
@@ -104,11 +104,7 @@ impl Component for ResetPasswordStep1Form {
|
||||
</div>
|
||||
{ if self.just_succeeded {
|
||||
html! {
|
||||
{"If a user with this username or email exists, a password reset email will \
|
||||
be sent to the associated email address. Please check your email and \
|
||||
follow the instructions. If you don't receive an email, please check \
|
||||
your spam folder. If you still don't receive an email, please contact \
|
||||
your administrator."}
|
||||
{"A reset token has been sent to your email."}
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
|
||||
@@ -4,12 +4,8 @@ use crate::{
|
||||
remove_user_from_group::RemoveUserFromGroupComponent,
|
||||
router::{AppRoute, Link},
|
||||
user_details_form::UserDetailsForm,
|
||||
},
|
||||
convert_attribute_type,
|
||||
infra::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
form_utils::GraphQlAttributeSchema,
|
||||
},
|
||||
}, infra::{schema::AttributeType, common_component::{CommonComponent, CommonComponentParts}},
|
||||
convert_attribute_type
|
||||
};
|
||||
use anyhow::{bail, Error, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
@@ -27,33 +23,15 @@ pub struct GetUserDetails;
|
||||
pub type User = get_user_details::GetUserDetailsUser;
|
||||
pub type Group = get_user_details::GetUserDetailsUserGroups;
|
||||
pub type Attribute = get_user_details::GetUserDetailsUserAttributes;
|
||||
pub type AttributeSchema = get_user_details::GetUserDetailsSchemaUserSchemaAttributes;
|
||||
pub type AttributeType = get_user_details::AttributeType;
|
||||
pub type AttributeSchema = get_user_details::GetUserDetailsUserAttributesSchema;
|
||||
|
||||
convert_attribute_type!(AttributeType);
|
||||
|
||||
impl From<&AttributeSchema> for GraphQlAttributeSchema {
|
||||
fn from(attr: &AttributeSchema) -> Self {
|
||||
Self {
|
||||
name: attr.name.clone(),
|
||||
is_list: attr.is_list,
|
||||
is_readonly: attr.is_readonly,
|
||||
is_editable: attr.is_editable,
|
||||
}
|
||||
}
|
||||
}
|
||||
convert_attribute_type!(get_user_details::AttributeType);
|
||||
|
||||
pub struct UserDetails {
|
||||
common: CommonComponentParts<Self>,
|
||||
/// The user info. If none, the error is in `error`. If `error` is None, then we haven't
|
||||
/// received the server response yet.
|
||||
user_and_schema: Option<(User, Vec<AttributeSchema>)>,
|
||||
}
|
||||
|
||||
impl UserDetails {
|
||||
fn mut_groups(&mut self) -> &mut Vec<Group> {
|
||||
&mut self.user_and_schema.as_mut().unwrap().0.groups
|
||||
}
|
||||
user: Option<User>,
|
||||
}
|
||||
|
||||
/// State machine describing the possible transitions of the component state.
|
||||
@@ -76,20 +54,22 @@ impl CommonComponent<UserDetails> for UserDetails {
|
||||
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::UserDetailsResponse(response) => match response {
|
||||
Ok(user) => {
|
||||
self.user_and_schema = Some((user.user, user.schema.user_schema.attributes))
|
||||
}
|
||||
Ok(user) => self.user = Some(user.user),
|
||||
Err(e) => {
|
||||
self.user_and_schema = None;
|
||||
self.user = None;
|
||||
bail!("Error getting user details: {}", e);
|
||||
}
|
||||
},
|
||||
Msg::OnError(e) => return Err(e),
|
||||
Msg::OnUserAddedToGroup(group) => {
|
||||
self.mut_groups().push(group);
|
||||
self.user.as_mut().unwrap().groups.push(group);
|
||||
}
|
||||
Msg::OnUserRemovedFromGroup((_, group_id)) => {
|
||||
self.mut_groups().retain(|g| g.id != group_id);
|
||||
self.user
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.groups
|
||||
.retain(|g| g.id != group_id);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
@@ -202,7 +182,7 @@ impl Component for UserDetails {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut table = Self {
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
user_and_schema: None,
|
||||
user: None,
|
||||
};
|
||||
table.get_user_details(ctx);
|
||||
table
|
||||
@@ -213,8 +193,10 @@ impl Component for UserDetails {
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
match (&self.user_and_schema, &self.common.error) {
|
||||
(Some((u, schema)), error) => {
|
||||
match (&self.user, &self.common.error) {
|
||||
(None, None) => html! {{"Loading..."}},
|
||||
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||
(Some(u), error) => {
|
||||
html! {
|
||||
<>
|
||||
<h3>{u.id.to_string()}</h3>
|
||||
@@ -229,20 +211,13 @@ impl Component for UserDetails {
|
||||
<div>
|
||||
<h5 class="row m-3 fw-bold">{"User details"}</h5>
|
||||
</div>
|
||||
<UserDetailsForm
|
||||
user={u.clone()}
|
||||
user_attributes_schema={schema.clone()}
|
||||
is_admin={ctx.props().is_admin}
|
||||
is_edited_user_admin={u.groups.iter().any(|g| g.display_name == "lldap_admin")}
|
||||
/>
|
||||
<UserDetailsForm user={u.clone()} />
|
||||
{self.view_group_memberships(ctx, u)}
|
||||
{self.view_add_group_button(ctx, u)}
|
||||
{self.view_messages(error)}
|
||||
</>
|
||||
}
|
||||
}
|
||||
(None, None) => html! {{"Loading..."}},
|
||||
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,59 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::{
|
||||
components::{
|
||||
form::{
|
||||
attribute_input::{ListAttributeInput, SingleAttributeInput},
|
||||
static_value::StaticValue,
|
||||
submit::Submit,
|
||||
},
|
||||
user_details::{Attribute, AttributeSchema, User},
|
||||
},
|
||||
infra::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
form_utils::{read_all_form_attributes, AttributeValue, EmailIsRequired, IsAdmin},
|
||||
schema::AttributeType,
|
||||
},
|
||||
form::{attribute_input::SingleAttributeInput, field::Field, static_value::StaticValue, submit::Submit},
|
||||
user_details::{AttributeSchema, User},
|
||||
}, convert_attribute_type, infra::{common_component::{CommonComponent, CommonComponentParts}, schema::AttributeType}
|
||||
};
|
||||
use anyhow::{anyhow, bail, Error, Ok, Result};
|
||||
use gloo_console::log;
|
||||
use gloo_file::{
|
||||
callbacks::{read_as_bytes, FileReader},
|
||||
File,
|
||||
};
|
||||
use anyhow::{Ok, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use validator::HasLen;
|
||||
use validator_derive::Validate;
|
||||
use web_sys::{FileList, FormData, HtmlFormElement, HtmlInputElement, InputEvent};
|
||||
use yew::prelude::*;
|
||||
use yew_form_derive::Model;
|
||||
|
||||
use super::user_details::Attribute;
|
||||
|
||||
#[derive(Default)]
|
||||
struct JsFile {
|
||||
file: Option<File>,
|
||||
contents: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl ToString for JsFile {
|
||||
fn to_string(&self) -> String {
|
||||
self.file.as_ref().map(File::name).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for JsFile {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self> {
|
||||
if s.is_empty() {
|
||||
Ok(JsFile::default())
|
||||
} else {
|
||||
bail!("Building file from non-empty string")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The fields of the form, with the editable details and the constraints.
|
||||
#[derive(Model, Validate, PartialEq, Eq, Clone)]
|
||||
pub struct UserModel {
|
||||
#[validate(email)]
|
||||
email: String,
|
||||
display_name: String,
|
||||
first_name: String,
|
||||
last_name: String,
|
||||
}
|
||||
|
||||
/// The GraphQL query sent to the server to update the user details.
|
||||
#[derive(GraphQLQuery)]
|
||||
@@ -31,6 +69,10 @@ pub struct UpdateUser;
|
||||
/// A [yew::Component] to display the user details, with a form allowing to edit them.
|
||||
pub struct UserDetailsForm {
|
||||
common: CommonComponentParts<Self>,
|
||||
form: yew_form::Form<UserModel>,
|
||||
// None means that the avatar hasn't changed.
|
||||
avatar: Option<JsFile>,
|
||||
reader: Option<FileReader>,
|
||||
/// True if we just successfully updated the user, to display a success message.
|
||||
just_updated: bool,
|
||||
user: User,
|
||||
@@ -40,8 +82,14 @@ pub struct UserDetailsForm {
|
||||
pub enum Msg {
|
||||
/// A form field changed.
|
||||
Update,
|
||||
/// A new file was selected.
|
||||
FileSelected(File),
|
||||
/// The "Submit" button was clicked.
|
||||
SubmitClicked,
|
||||
/// The "Clear" button for the avatar was clicked.
|
||||
ClearAvatarClicked,
|
||||
/// A picked file finished loading.
|
||||
FileLoaded(String, Result<Vec<u8>>),
|
||||
/// We got the response from the server about our update message.
|
||||
UserUpdated(Result<update_user::ResponseData>),
|
||||
}
|
||||
@@ -50,9 +98,6 @@ pub enum Msg {
|
||||
pub struct Props {
|
||||
/// The current user details.
|
||||
pub user: User,
|
||||
pub user_attributes_schema: Vec<AttributeSchema>,
|
||||
pub is_admin: bool,
|
||||
pub is_edited_user_admin: bool,
|
||||
}
|
||||
|
||||
impl CommonComponent<UserDetailsForm> for UserDetailsForm {
|
||||
@@ -63,12 +108,60 @@ impl CommonComponent<UserDetailsForm> for UserDetailsForm {
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::SubmitClicked => self.submit_user_update_form(ctx),
|
||||
Msg::UserUpdated(Err(e)) => Err(e),
|
||||
Msg::UserUpdated(Result::Ok(_)) => {
|
||||
self.just_updated = true;
|
||||
Msg::FileSelected(new_avatar) => {
|
||||
if self
|
||||
.avatar
|
||||
.as_ref()
|
||||
.and_then(|f| f.file.as_ref().map(|f| f.name()))
|
||||
!= Some(new_avatar.name())
|
||||
{
|
||||
let file_name = new_avatar.name();
|
||||
let link = ctx.link().clone();
|
||||
self.reader = Some(read_as_bytes(&new_avatar, move |res| {
|
||||
link.send_message(Msg::FileLoaded(
|
||||
file_name,
|
||||
res.map_err(|e| anyhow::anyhow!("{:#}", e)),
|
||||
))
|
||||
}));
|
||||
self.avatar = Some(JsFile {
|
||||
file: Some(new_avatar),
|
||||
contents: None,
|
||||
});
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
Msg::SubmitClicked => self.submit_user_update_form(ctx),
|
||||
Msg::ClearAvatarClicked => {
|
||||
self.avatar = Some(JsFile::default());
|
||||
Ok(true)
|
||||
}
|
||||
Msg::UserUpdated(response) => self.user_update_finished(response),
|
||||
Msg::FileLoaded(file_name, data) => {
|
||||
if let Some(avatar) = &mut self.avatar {
|
||||
if let Some(file) = &avatar.file {
|
||||
if file.name() == file_name {
|
||||
let data = data?;
|
||||
if !is_valid_jpeg(data.as_slice()) {
|
||||
// Clear the selection.
|
||||
self.avatar = None;
|
||||
bail!("Chosen image is not a valid JPEG");
|
||||
} else {
|
||||
avatar.contents = Some(data);
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.reader = None;
|
||||
Ok(false)
|
||||
} // Msg::OnSubmit(e) => {
|
||||
// e.prevent_default();
|
||||
// let form: HtmlFormElement = e.target_unchecked_into();
|
||||
// let data = FormData::new_with_form(&form).unwrap();
|
||||
// log!(format!("form data{:#?}", data));
|
||||
// log!(format!("form data data{:#?}", *data));
|
||||
// Ok(true)
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,9 +175,18 @@ impl Component for UserDetailsForm {
|
||||
type Properties = Props;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let model = UserModel {
|
||||
email: ctx.props().user.email.clone(),
|
||||
display_name: ctx.props().user.display_name.clone(),
|
||||
first_name: ctx.props().user.first_name.clone(),
|
||||
last_name: ctx.props().user.last_name.clone(),
|
||||
};
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: yew_form::Form::new(model),
|
||||
avatar: None,
|
||||
just_updated: false,
|
||||
reader: None,
|
||||
user: ctx.props().user.clone(),
|
||||
form_ref: NodeRef::default(),
|
||||
}
|
||||
@@ -98,45 +200,98 @@ impl Component for UserDetailsForm {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
|
||||
let can_edit =
|
||||
|a: &AttributeSchema| (ctx.props().is_admin || a.is_editable) && !a.is_readonly;
|
||||
let display_field = |a: &AttributeSchema| {
|
||||
if can_edit(a) {
|
||||
get_custom_attribute_input(a, &self.user.attributes)
|
||||
} else {
|
||||
get_custom_attribute_static(a, &self.user.attributes)
|
||||
let avatar_string = match &self.avatar {
|
||||
Some(avatar) => {
|
||||
let avatar_base64 = to_base64(avatar);
|
||||
avatar_base64.as_deref().unwrap_or("").to_owned()
|
||||
}
|
||||
None => self.user.avatar.as_deref().unwrap_or("").to_owned(),
|
||||
};
|
||||
html! {
|
||||
<div class="py-3">
|
||||
<form
|
||||
class="form"
|
||||
ref={self.form_ref.clone()}>
|
||||
<form class="form">
|
||||
<StaticValue label="User ID" id="userId">
|
||||
<i>{&self.user.id}</i>
|
||||
</StaticValue>
|
||||
{
|
||||
ctx
|
||||
.props()
|
||||
.user_attributes_schema
|
||||
.iter()
|
||||
.filter(|a| a.is_hardcoded && a.name != "user_id")
|
||||
.map(display_field)
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
{
|
||||
ctx
|
||||
.props()
|
||||
.user_attributes_schema
|
||||
.iter()
|
||||
.filter(|a| !a.is_hardcoded)
|
||||
.map(display_field)
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
<StaticValue label="Creation date" id="creationDate">
|
||||
{&self.user.creation_date.naive_local().date()}
|
||||
</StaticValue>
|
||||
<StaticValue label="UUID" id="uuid">
|
||||
{&self.user.uuid}
|
||||
</StaticValue>
|
||||
<Field<UserModel>
|
||||
form={&self.form}
|
||||
required=true
|
||||
label="Email"
|
||||
field_name="email"
|
||||
input_type="email"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Field<UserModel>
|
||||
form={&self.form}
|
||||
label="Display name"
|
||||
field_name="display_name"
|
||||
autocomplete="name"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Field<UserModel>
|
||||
form={&self.form}
|
||||
label="First name"
|
||||
field_name="first_name"
|
||||
autocomplete="given-name"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Field<UserModel>
|
||||
form={&self.form}
|
||||
label="Last name"
|
||||
field_name="last_name"
|
||||
autocomplete="family-name"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="form-group row align-items-center mb-3">
|
||||
<label for="avatar"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Avatar: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-5">
|
||||
<input
|
||||
class="form-control"
|
||||
id="avatarInput"
|
||||
type="file"
|
||||
accept="image/jpeg"
|
||||
oninput={link.callback(|e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
Self::upload_files(input.files())
|
||||
})} />
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<button
|
||||
class="btn btn-secondary col-auto"
|
||||
id="avatarClear"
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::ClearAvatarClicked})}>
|
||||
{"Clear"}
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
{
|
||||
if !avatar_string.is_empty() {
|
||||
html!{
|
||||
<img
|
||||
id="avatarDisplay"
|
||||
src={format!("data:image/jpeg;base64, {}", avatar_string)}
|
||||
style="max-height:128px;max-width:128px;height:auto;width:auto;"
|
||||
alt="Avatar" />
|
||||
}
|
||||
} else { html! {} }
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{self.user.attributes.iter().map(get_custom_attribute_input).collect::<Vec<_>>()}
|
||||
<Submit
|
||||
text="Save changes"
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})} />
|
||||
onclick={link.callback(|e: MouseEvent| {Msg::SubmitClicked})} />
|
||||
</form>
|
||||
{
|
||||
if let Some(e) = &self.common.error {
|
||||
@@ -155,97 +310,91 @@ impl Component for UserDetailsForm {
|
||||
}
|
||||
}
|
||||
|
||||
fn get_custom_attribute_input(
|
||||
attribute_schema: &AttributeSchema,
|
||||
user_attributes: &[Attribute],
|
||||
) -> Html {
|
||||
let values = user_attributes
|
||||
.iter()
|
||||
.find(|a| a.name == attribute_schema.name)
|
||||
.map(|attribute| attribute.value.clone())
|
||||
.unwrap_or_default();
|
||||
if attribute_schema.is_list {
|
||||
html! {
|
||||
<ListAttributeInput
|
||||
name={attribute_schema.name.clone()}
|
||||
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||
values={values}
|
||||
/>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<SingleAttributeInput
|
||||
name={attribute_schema.name.clone()}
|
||||
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||
value={values.first().cloned().unwrap_or_default()}
|
||||
/>
|
||||
}
|
||||
}
|
||||
type AttributeValue = (String, Vec<String>);
|
||||
|
||||
fn get_values_from_form_data(
|
||||
schema: Vec<AttributeSchema>,
|
||||
form: &FormData,
|
||||
) -> Result<Vec<AttributeValue>> {
|
||||
schema
|
||||
.into_iter()
|
||||
.map(|attr| -> Result<AttributeValue> {
|
||||
let val = form
|
||||
.get_all(attr.name.as_str())
|
||||
.iter()
|
||||
.map(|js_val| js_val.as_string().unwrap())
|
||||
.filter(|val| !val.is_empty())
|
||||
.collect::<Vec<String>>();
|
||||
if val.length() > 1 && !attr.is_list {
|
||||
return Err(anyhow!(
|
||||
"Multiple values supplied for non-list attribute {}",
|
||||
attr.name
|
||||
));
|
||||
}
|
||||
Ok((attr.name.clone(), val))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn get_custom_attribute_static(
|
||||
attribute_schema: &AttributeSchema,
|
||||
user_attributes: &[Attribute],
|
||||
) -> Html {
|
||||
let values = user_attributes
|
||||
.iter()
|
||||
.find(|a| a.name == attribute_schema.name)
|
||||
.map(|attribute| attribute.value.clone())
|
||||
.unwrap_or_default();
|
||||
html! {
|
||||
<StaticValue label={attribute_schema.name.clone()} id={attribute_schema.name.clone()}>
|
||||
{values.into_iter().map(|x| html!{<div>{x}</div>}).collect::<Vec<_>>()}
|
||||
</StaticValue>
|
||||
fn get_custom_attribute_input(attribute: &Attribute) -> Html {
|
||||
if attribute.schema.is_list {
|
||||
html!{<p>{"list attr"}</p>}
|
||||
} else {
|
||||
let value = if attribute.value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(attribute.value[0].clone())
|
||||
};
|
||||
html!{<SingleAttributeInput name={attribute.name.clone()} attribute_type={Into::<AttributeType>::into(attribute.schema.attribute_type.clone())} value={value}/>}
|
||||
}
|
||||
}
|
||||
|
||||
impl UserDetailsForm {
|
||||
fn submit_user_update_form(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
||||
// TODO: Handle unloaded files.
|
||||
// if let Some(JsFile {
|
||||
// file: Some(_),
|
||||
// contents: None,
|
||||
// }) = &self.avatar
|
||||
// {
|
||||
// bail!("Image file hasn't finished loading, try again");
|
||||
// }
|
||||
let mut all_values = read_all_form_attributes(
|
||||
ctx.props().user_attributes_schema.iter(),
|
||||
&self.form_ref,
|
||||
IsAdmin(ctx.props().is_admin),
|
||||
EmailIsRequired(!ctx.props().is_edited_user_admin),
|
||||
)?;
|
||||
let base_attributes = &self.user.attributes;
|
||||
all_values.retain(|a| {
|
||||
let base_val = base_attributes
|
||||
if !self.form.validate() {
|
||||
bail!("Invalid inputs");
|
||||
}
|
||||
if let Some(JsFile {
|
||||
file: Some(_),
|
||||
contents: None,
|
||||
}) = &self.avatar
|
||||
{
|
||||
bail!("Image file hasn't finished loading, try again");
|
||||
}
|
||||
let form = self.form_ref.cast::<HtmlFormElement>().unwrap();
|
||||
let form_data = FormData::new_with_form(&form)
|
||||
.map_err(|e| anyhow!("Failed to get FormData: {:#?}", e.as_string()))?;
|
||||
let mut all_values = get_values_from_form_data(
|
||||
self.user
|
||||
.attributes
|
||||
.iter()
|
||||
.find(|base_val| base_val.name == a.name);
|
||||
base_val
|
||||
.map(|v| v.value != a.values)
|
||||
.unwrap_or(!a.values.is_empty())
|
||||
.map(|attr| attr.schema.clone())
|
||||
.filter(|attr| !attr.is_hardcoded)
|
||||
.filter(|attr| attr.is_editable)
|
||||
.collect(),
|
||||
&form_data,
|
||||
)?;
|
||||
let base_user = &self.user;
|
||||
let base_attrs = &self.user.attributes;
|
||||
all_values.retain(|(name, val)| {
|
||||
let name = name.clone();
|
||||
let base_val = base_attrs
|
||||
.into_iter()
|
||||
.find(|base_val| base_val.name == name)
|
||||
.unwrap();
|
||||
let new_values = val.clone();
|
||||
base_val.value != new_values
|
||||
});
|
||||
let remove_attributes: Option<Vec<String>> = if all_values.is_empty() {
|
||||
let remove_names: Option<Vec<String>> = if all_values.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(all_values.iter().map(|a| a.name.clone()).collect())
|
||||
Some(all_values.iter().map(|(name, _)| name.clone()).collect())
|
||||
};
|
||||
let insert_attrs: Option<Vec<update_user::AttributeValueInput>> = if remove_names.is_none() {
|
||||
None
|
||||
} else {
|
||||
Some(all_values.into_iter().map(|(name, value)| update_user::AttributeValueInput{name, value}).collect())
|
||||
};
|
||||
let insert_attributes: Option<Vec<update_user::AttributeValueInput>> =
|
||||
if remove_attributes.is_none() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
all_values
|
||||
.into_iter()
|
||||
.filter(|a| !a.values.is_empty())
|
||||
.map(
|
||||
|AttributeValue { name, values }| update_user::AttributeValueInput {
|
||||
name,
|
||||
value: values,
|
||||
},
|
||||
)
|
||||
.collect(),
|
||||
)
|
||||
};
|
||||
let mut user_input = update_user::UpdateUserInput {
|
||||
id: self.user.id.clone(),
|
||||
email: None,
|
||||
@@ -253,12 +402,27 @@ impl UserDetailsForm {
|
||||
firstName: None,
|
||||
lastName: None,
|
||||
avatar: None,
|
||||
removeAttributes: None,
|
||||
insertAttributes: None,
|
||||
removeAttributes: remove_names,
|
||||
insertAttributes: insert_attrs,
|
||||
};
|
||||
let default_user_input = user_input.clone();
|
||||
user_input.removeAttributes = remove_attributes;
|
||||
user_input.insertAttributes = insert_attributes;
|
||||
let model = self.form.model();
|
||||
let email = model.email;
|
||||
if base_user.email != email {
|
||||
user_input.email = Some(email);
|
||||
}
|
||||
if base_user.display_name != model.display_name {
|
||||
user_input.displayName = Some(model.display_name);
|
||||
}
|
||||
if base_user.first_name != model.first_name {
|
||||
user_input.firstName = Some(model.first_name);
|
||||
}
|
||||
if base_user.last_name != model.last_name {
|
||||
user_input.lastName = Some(model.last_name);
|
||||
}
|
||||
if let Some(avatar) = &self.avatar {
|
||||
user_input.avatar = Some(to_base64(avatar)?);
|
||||
}
|
||||
// Nothing changed.
|
||||
if user_input == default_user_input {
|
||||
return Ok(false);
|
||||
@@ -272,4 +436,58 @@ impl UserDetailsForm {
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn user_update_finished(&mut self, r: Result<update_user::ResponseData>) -> Result<bool> {
|
||||
r?;
|
||||
let model = self.form.model();
|
||||
self.user.email = model.email;
|
||||
self.user.display_name = model.display_name;
|
||||
self.user.first_name = model.first_name;
|
||||
self.user.last_name = model.last_name;
|
||||
if let Some(avatar) = &self.avatar {
|
||||
self.user.avatar = Some(to_base64(avatar)?);
|
||||
}
|
||||
self.just_updated = true;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn upload_files(files: Option<FileList>) -> Msg {
|
||||
if let Some(files) = files {
|
||||
if files.length() > 0 {
|
||||
Msg::FileSelected(File::from(files.item(0).unwrap()))
|
||||
} else {
|
||||
Msg::Update
|
||||
}
|
||||
} else {
|
||||
Msg::Update
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_valid_jpeg(bytes: &[u8]) -> bool {
|
||||
image::io::Reader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg)
|
||||
.decode()
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn to_base64(file: &JsFile) -> Result<String> {
|
||||
match file {
|
||||
JsFile {
|
||||
file: None,
|
||||
contents: _,
|
||||
} => Ok(String::new()),
|
||||
JsFile {
|
||||
file: Some(_),
|
||||
contents: None,
|
||||
} => bail!("Image file hasn't finished loading, try again"),
|
||||
JsFile {
|
||||
file: Some(_),
|
||||
contents: Some(data),
|
||||
} => {
|
||||
if !is_valid_jpeg(data.as_slice()) {
|
||||
bail!("Chosen image is not a valid JPEG");
|
||||
}
|
||||
Ok(base64::encode(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::cookies::set_cookie;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use gloo_net::http::{Method, RequestBuilder};
|
||||
use gloo_net::http::{Method, Request};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use lldap_auth::{login, registration, JWTClaims};
|
||||
|
||||
@@ -32,16 +32,14 @@ async fn call_server<Body: Serialize>(
|
||||
body: RequestType<Body>,
|
||||
error_message: &'static str,
|
||||
) -> Result<String> {
|
||||
let request_builder = RequestBuilder::new(url)
|
||||
let mut request = Request::new(url)
|
||||
.header("Content-Type", "application/json")
|
||||
.credentials(RequestCredentials::SameOrigin);
|
||||
let request = if let RequestType::Post(b) = body {
|
||||
request_builder
|
||||
.method(Method::POST)
|
||||
.body(serde_json::to_string(&b)?)?
|
||||
} else {
|
||||
request_builder.build()?
|
||||
};
|
||||
if let RequestType::Post(b) = body {
|
||||
request = request
|
||||
.body(serde_json::to_string(&b)?)
|
||||
.method(Method::POST);
|
||||
}
|
||||
let response = request.send().await?;
|
||||
if response.ok() {
|
||||
Ok(response.text().await?)
|
||||
@@ -204,7 +202,7 @@ impl HostService {
|
||||
}
|
||||
|
||||
pub async fn probe_password_reset() -> Result<bool> {
|
||||
Ok(gloo_net::http::Request::post(
|
||||
Ok(gloo_net::http::Request::get(
|
||||
&(base_url() + "/auth/reset/step1/lldap_unlikely_very_long_user_name"),
|
||||
)
|
||||
.header("Content-Type", "application/json")
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
use anyhow::{anyhow, ensure, Result};
|
||||
use validator::validate_email;
|
||||
use web_sys::{FormData, HtmlFormElement};
|
||||
use yew::NodeRef;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AttributeValue {
|
||||
pub name: String,
|
||||
pub values: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct GraphQlAttributeSchema {
|
||||
pub name: String,
|
||||
pub is_list: bool,
|
||||
pub is_readonly: bool,
|
||||
pub is_editable: bool,
|
||||
}
|
||||
|
||||
fn validate_attributes(
|
||||
all_values: &[AttributeValue],
|
||||
email_is_required: EmailIsRequired,
|
||||
) -> Result<()> {
|
||||
let maybe_email_values = all_values.iter().find(|a| a.name == "mail");
|
||||
if email_is_required.0 || maybe_email_values.is_some() {
|
||||
let email_values = &maybe_email_values
|
||||
.ok_or_else(|| anyhow!("Email is required"))?
|
||||
.values;
|
||||
ensure!(email_values.len() == 1, "Email is required");
|
||||
ensure!(validate_email(&email_values[0]), "Email is not valid");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct IsAdmin(pub bool);
|
||||
pub struct EmailIsRequired(pub bool);
|
||||
|
||||
pub fn read_all_form_attributes(
|
||||
schema: impl IntoIterator<Item = impl Into<GraphQlAttributeSchema>>,
|
||||
form_ref: &NodeRef,
|
||||
is_admin: IsAdmin,
|
||||
email_is_required: EmailIsRequired,
|
||||
) -> Result<Vec<AttributeValue>> {
|
||||
let form = form_ref.cast::<HtmlFormElement>().unwrap();
|
||||
let form_data = FormData::new_with_form(&form)
|
||||
.map_err(|e| anyhow!("Failed to get FormData: {:#?}", e.as_string()))?;
|
||||
let all_values = schema
|
||||
.into_iter()
|
||||
.map(Into::<GraphQlAttributeSchema>::into)
|
||||
.filter(|attr| !attr.is_readonly && (is_admin.0 || attr.is_editable))
|
||||
.map(|attr| -> Result<AttributeValue> {
|
||||
let val = form_data
|
||||
.get_all(attr.name.as_str())
|
||||
.iter()
|
||||
.map(|js_val| js_val.as_string().unwrap_or_default())
|
||||
.filter(|val| !val.is_empty())
|
||||
.collect::<Vec<String>>();
|
||||
ensure!(
|
||||
val.len() <= 1 || attr.is_list,
|
||||
"Multiple values supplied for non-list attribute {}",
|
||||
attr.name
|
||||
);
|
||||
Ok(AttributeValue {
|
||||
name: attr.name.clone(),
|
||||
values: val,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
validate_attributes(&all_values, email_is_required)?;
|
||||
Ok(all_values)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ use crate::infra::api::HostService;
|
||||
use anyhow::Result;
|
||||
use graphql_client::GraphQLQuery;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use yew::{use_effect_with_deps, use_state_eq, UseStateHandle};
|
||||
use yew::{use_effect, use_state_eq, UseStateHandle};
|
||||
|
||||
// Enum to represent a result that is fetched asynchronously.
|
||||
#[derive(Debug)]
|
||||
@@ -31,29 +31,22 @@ pub fn use_graphql_call<QueryType>(
|
||||
) -> UseStateHandle<LoadableResult<QueryType::ResponseData>>
|
||||
where
|
||||
QueryType: GraphQLQuery + 'static,
|
||||
<QueryType as graphql_client::GraphQLQuery>::Variables: std::cmp::PartialEq + Clone,
|
||||
<QueryType as graphql_client::GraphQLQuery>::ResponseData: std::cmp::PartialEq,
|
||||
{
|
||||
let loadable_result: UseStateHandle<LoadableResult<QueryType::ResponseData>> =
|
||||
use_state_eq(|| LoadableResult::Loading);
|
||||
{
|
||||
let loadable_result = loadable_result.clone();
|
||||
use_effect_with_deps(
|
||||
move |variables| {
|
||||
let task = HostService::graphql_query::<QueryType>(
|
||||
variables.clone(),
|
||||
"Failed graphql query",
|
||||
);
|
||||
use_effect(move || {
|
||||
let task = HostService::graphql_query::<QueryType>(variables, "Failed graphql query");
|
||||
|
||||
spawn_local(async move {
|
||||
let response = task.await;
|
||||
loadable_result.set(LoadableResult::Loaded(response));
|
||||
});
|
||||
spawn_local(async move {
|
||||
let response = task.await;
|
||||
loadable_result.set(LoadableResult::Loaded(response));
|
||||
});
|
||||
|
||||
|| ()
|
||||
},
|
||||
variables,
|
||||
)
|
||||
|| ()
|
||||
})
|
||||
}
|
||||
loadable_result.clone()
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
pub mod api;
|
||||
pub mod common_component;
|
||||
pub mod cookies;
|
||||
pub mod form_utils;
|
||||
pub mod functional;
|
||||
pub mod graphql;
|
||||
pub mod modal;
|
||||
pub mod schema;
|
||||
pub mod tooltip;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
#![allow(clippy::empty_docs)]
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen]
|
||||
|
||||
@@ -34,25 +34,25 @@ impl FromStr for AttributeType {
|
||||
#[macro_export]
|
||||
macro_rules! convert_attribute_type {
|
||||
($source_type:ty) => {
|
||||
impl From<$source_type> for $crate::infra::schema::AttributeType {
|
||||
impl From<$source_type> for 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,
|
||||
<$source_type>::STRING => AttributeType::String,
|
||||
<$source_type>::INTEGER => AttributeType::Integer,
|
||||
<$source_type>::DATE_TIME => AttributeType::DateTime,
|
||||
<$source_type>::JPEG_PHOTO => AttributeType::Jpeg,
|
||||
_ => panic!("Unknown attribute type"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<$crate::infra::schema::AttributeType> for $source_type {
|
||||
fn from(value: $crate::infra::schema::AttributeType) -> Self {
|
||||
impl From<AttributeType> for $source_type {
|
||||
fn from(value: 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,
|
||||
AttributeType::String => <$source_type>::STRING,
|
||||
AttributeType::Integer => <$source_type>::INTEGER,
|
||||
AttributeType::DateTime => <$source_type>::DATE_TIME,
|
||||
AttributeType::Jpeg => <$source_type>::JPEG_PHOTO,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
#![allow(clippy::empty_docs)]
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_namespace = bootstrap)]
|
||||
pub type Tooltip;
|
||||
|
||||
#[wasm_bindgen(constructor, js_namespace = bootstrap)]
|
||||
pub fn new(e: web_sys::Element) -> Tooltip;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ homepage = "https://github.com/lldap/lldap"
|
||||
license = "GPL-3.0-only"
|
||||
name = "lldap_auth"
|
||||
repository = "https://github.com/lldap/lldap"
|
||||
version = "0.6.0"
|
||||
version = "0.4.0"
|
||||
|
||||
[features]
|
||||
default = ["opaque_server", "opaque_client"]
|
||||
@@ -25,11 +25,6 @@ serde = "*"
|
||||
sha2 = "0.9"
|
||||
thiserror = "*"
|
||||
|
||||
[dependencies.derive_more]
|
||||
features = ["debug", "display"]
|
||||
default-features = false
|
||||
version = "1"
|
||||
|
||||
[dependencies.opaque-ke]
|
||||
version = "0.6"
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ pub mod types {
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[cfg(feature = "sea_orm")]
|
||||
use sea_orm::{DbErr, DeriveValueType, TryFromU64, Value};
|
||||
use sea_orm::{DbErr, DeriveValueType, QueryResult, TryFromU64, Value};
|
||||
|
||||
#[derive(
|
||||
PartialEq, Eq, PartialOrd, Ord, Clone, Debug, Default, Hash, Serialize, Deserialize,
|
||||
@@ -151,22 +151,10 @@ pub mod types {
|
||||
}
|
||||
|
||||
#[derive(
|
||||
PartialEq,
|
||||
Eq,
|
||||
PartialOrd,
|
||||
Ord,
|
||||
Clone,
|
||||
Default,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
derive_more::Debug,
|
||||
derive_more::Display,
|
||||
PartialEq, Eq, PartialOrd, Ord, Clone, Debug, Default, Hash, Serialize, Deserialize,
|
||||
)]
|
||||
#[cfg_attr(feature = "sea_orm", derive(DeriveValueType))]
|
||||
#[serde(from = "CaseInsensitiveString")]
|
||||
#[debug(r#""{}""#, _0.as_str())]
|
||||
#[display("{}", _0.as_str())]
|
||||
pub struct UserId(CaseInsensitiveString);
|
||||
|
||||
impl UserId {
|
||||
@@ -188,6 +176,11 @@ pub mod types {
|
||||
Self(s.into())
|
||||
}
|
||||
}
|
||||
impl std::fmt::Display for UserId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "sea_orm")]
|
||||
impl From<&UserId> for Value {
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
# MegaRAC SP-X BMC IPMI LDAP Setup
|
||||
|
||||
The MegaRAC SP-X BMC is a service processor firmware stack designed by American Megatrends Inc. (AMI), aimed at providing out-of-band management for servers and computing systems.
|
||||
It's part of the MegaRAC family of management solutions, offering remote server management capabilities, including monitoring, control, and maintenance functionalities, independent of the operating system or system state.
|
||||
This enables administrators to manage systems remotely for tasks such as updates, troubleshooting, and recovery.
|
||||
|
||||
## Setting up LLDAP with MegaRAC SP-X BMC IPMI
|
||||
|
||||
### Pre-requisites
|
||||
- Create and assign the `ipmi` group in LLDAP to a (test) user.
|
||||
- Bind User: It is recommended that you create a separate user account (e.g, `bind_user`) instead of admin for sharing Bind credentials with other services. The bind_user should be a member of the lldap_strict_readonly group to limit access to your LDAP configuration in LLDAP.
|
||||
- Bind Password: password of the user specified above
|
||||
|
||||
### Configuration Steps
|
||||
|
||||
1. **Navigate**: Go to `Settings > External User Settings > LDAP/E-Directory Settings > General Settings`.
|
||||
|
||||
2. **General LDAP Settings**:
|
||||
- **Encryption Type**: `SSL` (or No Encryption if preferred)
|
||||
- **Common Name Type**: `FQDN` (or IP if you use a plain IP address to connect to lldap)
|
||||
- **Server Address**: `fqdn.lldap.tld`
|
||||
- **Port**: `6360` (default for SSL, adjust if necessary to default non ssl `3890`)
|
||||
|
||||
3. **Authentication** (use read-only bind user):
|
||||
- **Bind DN**: `uid=bind_user,ou=people,dc=example,dc=com`
|
||||
- **Password**: `change_bind_user_password`
|
||||
|
||||
4. **Search Configuration**:
|
||||
- **Search Base**: `ou=people,dc=example,dc=com`
|
||||
- **Attribute of User Login**: `uid`
|
||||
|
||||

|
||||
|
||||
5. **Navigate**: Go to `Settings > External User Settings > LDAP/E-Directory Settings > Role groups`.
|
||||
|
||||
6. **Click on empty role group in order to assign a new one**
|
||||
|
||||
7. **Role Group - Group Details**:
|
||||
- **Group Name**: `ipmi`
|
||||
- **Group Domain**: `cn=ipmi,ou=groups,dc=example,dc=com`
|
||||
- **Group Privilege**: `Administrator`
|
||||
|
||||
8. **Group Permissions**:
|
||||
- KVM Access: Enabled (adjust as needed)
|
||||
- VMedia Access: Enabled (adjust as needed)
|
||||
|
||||

|
||||
|
||||
@@ -15,7 +15,7 @@ authentication_backend:
|
||||
implementation: custom
|
||||
# Pattern is ldap://HOSTNAME-OR-IP:PORT
|
||||
# Normal ldap port is 389, standard in LLDAP is 3890
|
||||
address: ldap://lldap:3890
|
||||
url: ldap://lldap:3890
|
||||
# The dial timeout for LDAP.
|
||||
timeout: 5s
|
||||
# Use StartTLS with the LDAP connection, TLS not supported right now
|
||||
@@ -25,6 +25,7 @@ authentication_backend:
|
||||
# minimum_version: TLS1.2
|
||||
# Set base dn, like dc=google,dc.com
|
||||
base_dn: dc=example,dc=com
|
||||
username_attribute: uid
|
||||
# You need to set this to ou=people, because all users are stored in this ou!
|
||||
additional_users_dn: ou=people
|
||||
# To allow sign in both with username and email, one can use a filter like
|
||||
@@ -35,17 +36,13 @@ authentication_backend:
|
||||
# The groups are not displayed in the UI, but this filter works.
|
||||
groups_filter: "(member={dn})"
|
||||
# The attribute holding the name of the group.
|
||||
attributes:
|
||||
display_name: displayName
|
||||
username: uid
|
||||
group_name: cn
|
||||
mail: mail
|
||||
# distinguished_name: distinguishedName
|
||||
# member_of: memberOf
|
||||
|
||||
# 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
|
||||
group_name_attribute: cn
|
||||
# Email attribute
|
||||
mail_attribute: mail
|
||||
# The attribute holding the display name of the user. This will be used to greet an authenticated user.
|
||||
display_name_attribute: displayName
|
||||
# The username and password of the admin user.
|
||||
# "admin" should be the admin username you set in the LLDAP configuration
|
||||
user: uid=admin,ou=people,dc=example,dc=com
|
||||
# Password can also be set using a secret: https://www.authelia.com/docs/configuration/secrets.html
|
||||
password: 'REPLACE_ME'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Bootstrapping lldap using [bootstrap.sh](/scripts/bootstrap.sh) script
|
||||
# Bootstrapping lldap using [bootstrap.sh](bootstrap.sh) script
|
||||
|
||||
bootstrap.sh allows managing your lldap in a git-ops, declarative way using JSON config files.
|
||||
|
||||
@@ -12,7 +12,7 @@ The script can:
|
||||
* create groups
|
||||
* delete redundant users and groups (when `DO_CLEANUP` env var is true)
|
||||
* maintain the desired state described in JSON config files
|
||||
* create user/group user-defined attributes
|
||||
|
||||
|
||||

|
||||
|
||||
@@ -27,13 +27,11 @@ The script can:
|
||||
|
||||
## Environment variables
|
||||
|
||||
- `LLDAP_URL` or `LLDAP_URL_FILE` (default value: `http://localhost:17170`) - URL to your lldap instance or path to file that contains URL
|
||||
- `LLDAP_ADMIN_USERNAME` or `LLDAP_ADMIN_USERNAME_FILE` (default value: `admin`) - admin username or path to file that contains username
|
||||
- `LLDAP_ADMIN_PASSWORD` or `LLDAP_ADMIN_PASSWORD_FILE` (default value: `password`) - admin password or path to file that contains password
|
||||
- `USER_CONFIGS_DIR` (default value: `/bootstrap/user-configs`) - directory where the user JSON configs could be found
|
||||
- `GROUP_CONFIGS_DIR` (default value: `/bootstrap/group-configs`) - directory where the group JSON configs could be found
|
||||
- `USER_SCHEMAS_DIR` (default value: `/bootstrap/user-schemas`) - directory where the user 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_URL` or `LLDAP_URL_FILE` - URL to your lldap instance or path to file that contains URL (**MANDATORY**)
|
||||
- `LLDAP_ADMIN_USERNAME` or `LLDAP_ADMIN_USERNAME_FILE` - admin username or path to file that contains username (**MANDATORY**)
|
||||
- `LLDAP_ADMIN_PASSWORD` or `LLDAP_ADMIN_PASSWORD_FILE` - admin password or path to file that contains password (**MANDATORY**)
|
||||
- `USER_CONFIGS_DIR` (default value: `/user-configs`) - directory where the user JSON configs could be found
|
||||
- `GROUP_CONFIGS_DIR` (default value: `/group-configs`) - directory where the group JSON configs could be found
|
||||
- `LLDAP_SET_PASSWORD_PATH` - path to the `lldap_set_password` utility (default value: `/app/lldap_set_password`)
|
||||
- `DO_CLEANUP` (default value: `false`) - delete groups and users not specified in config files, also remove users from groups that they do not belong to
|
||||
|
||||
@@ -98,44 +96,6 @@ Fields description:
|
||||
|
||||
```
|
||||
|
||||
### User and group schema config file example
|
||||
|
||||
User and group schema have the same structure.
|
||||
|
||||
Fields description:
|
||||
|
||||
* `name`: name of field, case insensitve - you should use lowercase
|
||||
* `attributeType`: `STRING` / `INTEGER` / `JPEG` / `DATE_TIME`
|
||||
* `isList`: single on multiple value field
|
||||
* `isEditable`: self-explanatory
|
||||
* `isVisible`: self-explanatory
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "uid",
|
||||
"attributeType": "INTEGER",
|
||||
"isEditable": false,
|
||||
"isList": false,
|
||||
"isVisible": true
|
||||
},
|
||||
{
|
||||
"name": "mailbox",
|
||||
"attributeType": "STRING",
|
||||
"isEditable": false,
|
||||
"isList": false,
|
||||
"isVisible": true
|
||||
},
|
||||
{
|
||||
"name": "mail_alias",
|
||||
"attributeType": "STRING",
|
||||
"isEditable": false,
|
||||
"isList": true,
|
||||
"isVisible": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Usage example
|
||||
|
||||
### Manually
|
||||
@@ -150,21 +110,11 @@ export LLDAP_ADMIN_USERNAME=admin
|
||||
export LLDAP_ADMIN_PASSWORD=changeme
|
||||
export USER_CONFIGS_DIR="$(realpath ./configs/user)"
|
||||
export GROUP_CONFIGS_DIR="$(realpath ./configs/group)"
|
||||
export USER_SCHEMAS_DIR="$(realpath ./configs/user-schema)"
|
||||
export GROUP_SCHEMAS_DIR="$(realpath ./configs/group-schema)"
|
||||
export LLDAP_SET_PASSWORD_PATH="$(realpath ./lldap_set_password)"
|
||||
export DO_CLEANUP=false
|
||||
./bootstrap.sh
|
||||
```
|
||||
|
||||
### Manually from running docker container or service
|
||||
|
||||
After setting a docker container you can bootstrap users using:
|
||||
|
||||
```
|
||||
docker exec -e LLDAP_ADMIN_PASSWORD_FILE=password -v ./bootstrap:/bootstrap -it $(docker ps --filter name=lldap -q) /app/bootstrap.sh
|
||||
```
|
||||
|
||||
### Docker compose
|
||||
|
||||
Let's suppose you have the next file structure:
|
||||
@@ -179,17 +129,10 @@ Let's suppose you have the next file structure:
|
||||
│ ├─ ...
|
||||
│ └─ user-n.json
|
||||
└─ group-configs
|
||||
| ├─ group-1.json
|
||||
| ├─ ...
|
||||
| └─ group-n.json
|
||||
└─ user-schemas
|
||||
| ├─ user-attrs-1.json
|
||||
| ├─ ...
|
||||
| └─ user-attrs-n.json
|
||||
└─ group-schemas
|
||||
├─ group-attrs-1.json
|
||||
├─ group-1.json
|
||||
├─ ...
|
||||
└─ group-attrs-n.json
|
||||
└─ group-n.json
|
||||
|
||||
```
|
||||
|
||||
You should mount `bootstrap` dir to lldap container and set the corresponding `env` variables:
|
||||
@@ -217,8 +160,6 @@ services:
|
||||
- LLDAP_ADMIN_PASSWORD=changeme # same as LLDAP_LDAP_USER_PASS
|
||||
- USER_CONFIGS_DIR=/bootstrap/user-configs
|
||||
- GROUP_CONFIGS_DIR=/bootstrap/group-configs
|
||||
- USER_SCHEMAS_DIR=/bootstrap/user-schemas
|
||||
- GROUP_SCHEMAS_DIR=/bootstrap/group-schemas
|
||||
- DO_CLEANUP=false
|
||||
```
|
||||
|
||||
@@ -264,15 +205,14 @@ spec:
|
||||
volumeMounts:
|
||||
- name: bootstrap
|
||||
mountPath: /bootstrap/bootstrap.sh
|
||||
readOnly: true
|
||||
subPath: bootstrap.sh
|
||||
|
||||
- name: user-configs
|
||||
mountPath: /bootstrap/user-configs
|
||||
mountPath: /user-configs
|
||||
readOnly: true
|
||||
|
||||
- name: group-configs
|
||||
mountPath: /bootstrap/group-configs
|
||||
mountPath: /group-configs
|
||||
readOnly: true
|
||||
|
||||
volumes:
|
||||
|
||||
@@ -3,24 +3,14 @@
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
LLDAP_URL="${LLDAP_URL:-http://localhost:17170}"
|
||||
LLDAP_ADMIN_USERNAME="${LLDAP_ADMIN_USERNAME:-admin}"
|
||||
LLDAP_ADMIN_PASSWORD="${LLDAP_ADMIN_PASSWORD:-password}"
|
||||
USER_SCHEMAS_DIR="${USER_SCHEMAS_DIR:-/bootstrap/user-schemas}"
|
||||
GROUP_SCHEMAS_DIR="${GROUP_SCHEMAS_DIR:-/bootstrap/group-schemas}"
|
||||
USER_CONFIGS_DIR="${USER_CONFIGS_DIR:-/bootstrap/user-configs}"
|
||||
GROUP_CONFIGS_DIR="${GROUP_CONFIGS_DIR:-/bootstrap/group-configs}"
|
||||
LLDAP_URL="${LLDAP_URL}"
|
||||
LLDAP_ADMIN_USERNAME="${LLDAP_ADMIN_USERNAME}"
|
||||
LLDAP_ADMIN_PASSWORD="${LLDAP_ADMIN_PASSWORD}"
|
||||
USER_CONFIGS_DIR="${USER_CONFIGS_DIR:-/user-configs}"
|
||||
GROUP_CONFIGS_DIR="${GROUP_CONFIGS_DIR:-/group-configs}"
|
||||
LLDAP_SET_PASSWORD_PATH="${LLDAP_SET_PASSWORD_PATH:-/app/lldap_set_password}"
|
||||
DO_CLEANUP="${DO_CLEANUP:-false}"
|
||||
|
||||
# Fallback to support legacy defaults
|
||||
if [[ ! -d $USER_CONFIGS_DIR ]] && [[ -d "/user-configs" ]]; then
|
||||
USER_CONFIGS_DIR="/user-configs"
|
||||
fi
|
||||
if [[ ! -d $GROUP_CONFIGS_DIR ]] && [[ -d "/group-configs" ]]; then
|
||||
GROUP_CONFIGS_DIR="/group-configs"
|
||||
fi
|
||||
|
||||
check_install_dependencies() {
|
||||
local commands=('curl' 'jq' 'jo')
|
||||
local commands_not_found='false'
|
||||
@@ -262,7 +252,7 @@ get_users_list() {
|
||||
}
|
||||
|
||||
user_exists() {
|
||||
if [[ "$(get_users_list | jq --raw-output --arg id "$1" '.data.users | any(.[]; .id == $id)')" == 'true' ]]; then
|
||||
if [[ "$(get_users_list | jq --raw-output --arg id "$1" '.data.users | any(.[]; contains({"id": $id}))')" == 'true' ]]; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
@@ -290,80 +280,6 @@ delete_user() {
|
||||
fi
|
||||
}
|
||||
|
||||
get_group_property_list() {
|
||||
local query='{"query":"query GetGroupAttributesSchema { schema { groupSchema { attributes { name }}}}","operationName":"GetGroupAttributesSchema"}'
|
||||
make_query <(printf '%s' "$query") <(printf '{}')
|
||||
}
|
||||
group_property_exists() {
|
||||
if [[ "$(get_group_property_list | jq --raw-output --arg name "$1" '.data.schema.groupSchema.attributes | any(.[]; select(.name == $name))')" == 'true' ]]; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
create_group_schema_property() {
|
||||
local name="$1"
|
||||
local attributeType="$2"
|
||||
local isEditable="$3"
|
||||
local isList="$4"
|
||||
local isVisible="$5"
|
||||
|
||||
if group_property_exists "$name"; then
|
||||
printf 'Group property "%s" already exists\n' "$name"
|
||||
return
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2016
|
||||
local query='{"query":"mutation CreateGroupAttribute($name: String!, $attributeType: AttributeType!, $isList: Boolean!, $isVisible: Boolean!, $isEditable: Boolean!) {addGroupAttribute(name: $name, attributeType: $attributeType, isList: $isList, isVisible: $isVisible, isEditable: $isEditable) {ok}}","operationName":"CreateGroupAttribute"}'
|
||||
|
||||
local response='' error=''
|
||||
response="$(make_query <(printf '%s' "$query") <(jo -- name="$name" attributeType="$attributeType" isEditable="$isEditable" isList="$isList" isVisible="$isVisible"))"
|
||||
error="$(printf '%s' "$response" | jq --raw-output '.errors | if . != null then .[].message else empty end')"
|
||||
if [[ -n "$error" ]]; then
|
||||
printf '%s\n' "$error"
|
||||
else
|
||||
printf 'Group attribute "%s" successfully created\n' "$name"
|
||||
fi
|
||||
}
|
||||
|
||||
get_user_property_list() {
|
||||
local query='{"query":"query GetUserAttributesSchema { schema { userSchema { attributes { name }}}}","operationName":"GetUserAttributesSchema"}'
|
||||
make_query <(printf '%s' "$query") <(printf '{}')
|
||||
}
|
||||
user_property_exists() {
|
||||
if [[ "$(get_user_property_list | jq --raw-output --arg name "$1" '.data.schema.userSchema.attributes | any(.[]; select(.name == $name))')" == 'true' ]]; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
create_user_schema_property() {
|
||||
local name="$1"
|
||||
local attributeType="$2"
|
||||
local isEditable="$3"
|
||||
local isList="$4"
|
||||
local isVisible="$5"
|
||||
|
||||
if user_property_exists "$name"; then
|
||||
printf 'User property "%s" already exists\n' "$name"
|
||||
return
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2016
|
||||
local query='{"query":"mutation CreateUserAttribute($name: String!, $attributeType: AttributeType!, $isList: Boolean!, $isVisible: Boolean!, $isEditable: Boolean!) {addUserAttribute(name: $name, attributeType: $attributeType, isList: $isList, isVisible: $isVisible, isEditable: $isEditable) {ok}}","operationName":"CreateUserAttribute"}'
|
||||
|
||||
local response='' error=''
|
||||
response="$(make_query <(printf '%s' "$query") <(jo -- name="$name" attributeType="$attributeType" isEditable="$isEditable" isList="$isList" isVisible="$isVisible"))"
|
||||
error="$(printf '%s' "$response" | jq --raw-output '.errors | if . != null then .[].message else empty end')"
|
||||
if [[ -n "$error" ]]; then
|
||||
printf '%s\n' "$error"
|
||||
else
|
||||
printf 'User attribute "%s" successfully created\n' "$name"
|
||||
fi
|
||||
}
|
||||
|
||||
__common_user_mutation_query() {
|
||||
local \
|
||||
query="$1" \
|
||||
@@ -471,18 +387,8 @@ main() {
|
||||
|
||||
local user_config_files=("${USER_CONFIGS_DIR}"/*.json)
|
||||
local group_config_files=("${GROUP_CONFIGS_DIR}"/*.json)
|
||||
local user_schema_files=()
|
||||
local group_schema_files=()
|
||||
|
||||
local file=''
|
||||
[[ -d "$USER_SCHEMAS_DIR" ]] && for file in "${USER_SCHEMAS_DIR}"/*.json; do
|
||||
user_schema_files+=("$file")
|
||||
done
|
||||
[[ -d "$GROUP_SCHEMAS_DIR" ]] && for file in "${GROUP_SCHEMAS_DIR}"/*.json; do
|
||||
group_schema_files+=("$file")
|
||||
done
|
||||
|
||||
if ! check_configs_validity "${group_config_files[@]}" "${user_config_files[@]}" "${group_schema_files[@]}" "${user_schema_files[@]}"; then
|
||||
if ! check_configs_validity "${group_config_files[@]}" "${user_config_files[@]}"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -493,28 +399,6 @@ main() {
|
||||
|
||||
auth "$LLDAP_URL" "$LLDAP_ADMIN_USERNAME" "$LLDAP_ADMIN_PASSWORD"
|
||||
|
||||
printf -- '\n--- group schemas ---\n'
|
||||
local group_schema_config_row=''
|
||||
[[ ${#group_schema_files[@]} -gt 0 ]] && while read -r group_schema_config_row; do
|
||||
local field='' name='' attributeType='' isEditable='' isList='' isVisible=''
|
||||
for field in 'name' 'attributeType' 'isEditable' 'isList' 'isVisible'; do
|
||||
declare "$field"="$(printf '%s' "$group_schema_config_row" | jq --raw-output --arg field "$field" '.[$field]')"
|
||||
done
|
||||
create_group_schema_property "$name" "$attributeType" "$isEditable" "$isList" "$isVisible"
|
||||
done < <(jq --compact-output '.[]' -- "${group_schema_files[@]}")
|
||||
printf -- '--- group schemas ---\n'
|
||||
|
||||
printf -- '\n--- user schemas ---\n'
|
||||
local user_schema_config_row=''
|
||||
[[ ${#user_schema_files[@]} -gt 0 ]] && while read -r user_schema_config_row; do
|
||||
local field='' name='' attributeType='' isEditable='' isList='' isVisible=''
|
||||
for field in 'name' 'attributeType' 'isEditable' 'isList' 'isVisible'; do
|
||||
declare "$field"="$(printf '%s' "$user_schema_config_row" | jq --raw-output --arg field "$field" '.[$field]')"
|
||||
done
|
||||
create_user_schema_property "$name" "$attributeType" "$isEditable" "$isList" "$isVisible"
|
||||
done < <(jq --compact-output '.[]' -- "${user_schema_files[@]}")
|
||||
printf -- '--- user schemas ---\n'
|
||||
|
||||
local redundant_groups=''
|
||||
redundant_groups="$(get_group_list | jq '[ .data.groups[].displayName ]' | jq --compact-output '. - ["lldap_admin","lldap_password_manager","lldap_strict_readonly"]')"
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
# Configuration for Carpal
|
||||
|
||||
[Carpal](https://github.com/peeley/carpal) is a small, configurable
|
||||
[WebFinger](https://webfinger.net) server than can pull resource information
|
||||
from LDAP directories.
|
||||
|
||||
There are two files used to configure Carpal for LDAP:
|
||||
|
||||
- The YAML configuration file for Carpal itself
|
||||
- A Go template file for injecting the LDAP data into the WebFinger response
|
||||
|
||||
### YAML File
|
||||
|
||||
Replace the server URL, admin credentials, and domain for your server:
|
||||
|
||||
```yaml
|
||||
# /etc/carpal/config.yml
|
||||
|
||||
driver: ldap
|
||||
ldap:
|
||||
url: ldap://myldapserver
|
||||
bind_user: uid=myadmin,ou=people,dc=foobar,dc=com
|
||||
bind_pass: myadminpassword
|
||||
basedn: ou=people,dc=foobar,dc=com
|
||||
filter: (uid=*)
|
||||
user_attr: uid
|
||||
attributes:
|
||||
- uid
|
||||
- mail
|
||||
- cn
|
||||
template: /etc/carpal/ldap.gotempl
|
||||
```
|
||||
|
||||
If you have configured any user-defined attributes on your users, you can also
|
||||
add those to the `attributes` field.
|
||||
|
||||
### Go Template File
|
||||
|
||||
This is an example template; the template file is intended to be editable for
|
||||
your needs. If your users, for example, don't have Mastodon profiles, you can
|
||||
delete the Mastodon alias.
|
||||
|
||||
```gotempl
|
||||
# /etc/carpal/ldap.gotempl
|
||||
|
||||
aliases:
|
||||
- "mailto:{{ index . "mail" }}"
|
||||
- "https://mastodon/{{ index . "uid" }}"
|
||||
properties:
|
||||
'http://webfinger.example/ns/name': '{{ index . "cn" }}'
|
||||
links:
|
||||
- rel: "http://webfinger.example/rel/profile-page"
|
||||
href: "https://www.example.com/~{{ index . "uid" }}/"
|
||||
```
|
||||
|
||||
This example also only contains the default attributes present on all LLDAP
|
||||
users. If you have added custom user-defined attributes to your users and added
|
||||
them to the `attributes` field of the YAML config file, you can use them in
|
||||
this template file.
|
||||
@@ -10,7 +10,8 @@ connectors:
|
||||
id: ldap
|
||||
name: LDAP
|
||||
config:
|
||||
host: lldap-host:3890 # or 6360 if you have ldaps enabled, make sure it does not start with `ldap://`
|
||||
host: lldap-host # make sure it does not start with `ldap://`
|
||||
port: 3890 # or 6360 if you have ldaps enabled
|
||||
insecureNoSSL: true # or false if you have ldaps enabled
|
||||
insecureSkipVerify: true # or false if you have ldaps enabled
|
||||
bindDN: uid=admin,ou=people,dc=example,dc=com # replace admin with your admin user
|
||||
|
||||
@@ -9,7 +9,7 @@ $conf['authtype'] = 'authldap'; //enable this Auth plugin
|
||||
$conf['superuser'] = 'admin';
|
||||
$conf['plugin']['authldap']['server'] = 'ldap://lldap_server:3890'; #IP of your lldap
|
||||
$conf['plugin']['authldap']['usertree'] = 'ou=people,dc=example,dc=com';
|
||||
$conf['plugin']['authldap']['grouptree'] = 'ou=groups,dc=example,dc=com';
|
||||
$conf['plugin']['authldap']['grouptree'] = 'ou=groups, dc=example, dc=com';
|
||||
$conf['plugin']['authldap']['userfilter'] = '(&(uid=%{user})(objectClass=person))';
|
||||
$conf['plugin']['authldap']['groupfilter'] = '(&(member=%{dn})(objectClass=groupOfUniqueNames))';
|
||||
$conf['plugin']['authldap']['attributes'] = array('cn', 'displayname', 'mail', 'givenname', 'objectclass', 'sn', 'uid', 'memberof');
|
||||
@@ -24,11 +24,3 @@ All you need to do is to activate the plugin. This can be done on the DokuWiki E
|
||||
Once the LDAP settings are defined, proceed to define the default authentication method.
|
||||
Navigate to Table of Contents > DokuWiki > Authentication.
|
||||
On the Authentication backend, select ```authldap``` and save the changes.
|
||||
|
||||
## Internal (or other authentication) fallback
|
||||
If you dont want to use LDAP authentication exclusively, you can install the [authchained plugin](https://www.dokuwiki.org/plugin:authchained). It tries multiple auth backends when a user logs in.
|
||||
|
||||
```
|
||||
$conf['authtype'] = 'authchained';
|
||||
$conf['plugin']['authchained']['authtypes'] = 'authldap:authplain';
|
||||
```
|
||||
@@ -1,18 +0,0 @@
|
||||
Extract lldap's [FreeBSD tar.gz](https://github.com/n-connect/rustd-hbbx/blob/main/x86_64-freebsd_lldap-0.5.1.tar.gz) under /usr/local/:
|
||||
|
||||
`tar -xvf x86_64-freebsd_lldap-0.5.1.tar.gz -C /usr/local/`
|
||||
|
||||
Move rc.d script into the right place:
|
||||
`mv /usr/local/lldap_server/rc.d_lldap /usr/local/etc/rc.d/lldap`
|
||||
|
||||
Make your config, if your want to enable LDAPS, copy your server key and certification files, and set the owneship (currently www):
|
||||
|
||||
`cp /usr/local/lldap_server/lldap_config.docker_template.toml /usr/local/lldap_server/lldap_config..toml`
|
||||
|
||||
Enable lldap service in /etc/rc.conf:
|
||||
|
||||
`sysrc lldap_enable="YES"`
|
||||
|
||||
Start your service:
|
||||
|
||||
`service lldap start`
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
# PROVIDE: lldap
|
||||
# REQUIRE: DAEMON NETWORKING
|
||||
# KEYWORD: shutdown
|
||||
|
||||
# Add the following lines to /etc/rc.conf to enable lldap:
|
||||
# lldap_enable : set to "YES" to enable the daemon, default is "NO"
|
||||
|
||||
. /etc/rc.subr
|
||||
|
||||
name=lldap
|
||||
rcvar=lldap_enable
|
||||
|
||||
lldap_chdir="/usr/local/lldap_server"
|
||||
|
||||
load_rc_config $name
|
||||
|
||||
lldap_enable=${lldap_enable:-"NO"}
|
||||
|
||||
logfile="/var/log/${name}.log"
|
||||
|
||||
procname=/usr/local/lldap_server/lldap
|
||||
command="/usr/sbin/daemon"
|
||||
command_args="-u www -o ${logfile} -t ${name} /usr/local/lldap_server/lldap run"
|
||||
|
||||
run_rc_command "$1"
|
||||
@@ -20,7 +20,7 @@ ssl_skip_verify = false
|
||||
# client_key = "/path/to/client.key"
|
||||
|
||||
# Search user bind dn
|
||||
bind_dn = "uid=<your grafana user>,ou=people,dc=example,dc=com"
|
||||
bind_dn = "uid=<your grafana user>,ou=people,dc=example,dc=org"
|
||||
# Search user bind password
|
||||
# If the password contains # or ; you have to wrap it with triple quotes. Ex """#password;"""
|
||||
bind_password = "<grafana user password>"
|
||||
@@ -31,7 +31,7 @@ search_filter = "(uid=%s)"
|
||||
# search_filter = "(&(uid=%s)(memberOf=cn=<your group>,ou=groups,dc=example,dc=org))"
|
||||
|
||||
# An array of base dns to search through
|
||||
search_base_dns = ["dc=example,dc=com"]
|
||||
search_base_dns = ["dc=example,dc=org"]
|
||||
|
||||
# Specify names of the LDAP attributes your LDAP uses
|
||||
[servers.attributes]
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
[Harbor](https://goharbor.io) is a CNCF cloud native container registry for kubernetes.
|
||||
|
||||
You can pass environment variables into ``harbor-core`` for auth configuration as documented [here](https://github.com/goharbor/website/blob/release-2.10.0/docs/install-config/configure-system-settings-cli.md#harbor-configuration-items).
|
||||
|
||||
Configure ``ldap_url`` and ``ldap_verify_cert`` as needed for your installation.
|
||||
|
||||
Using the [harbor-helm](https://github.com/goharbor/harbor-helm) chart, these vars can be passed in under ``core.configureUserSettings`` as a JSON string:
|
||||
|
||||
```yaml
|
||||
core:
|
||||
configureUserSettings: |
|
||||
{
|
||||
"auth_mode": "ldap_auth",
|
||||
"ldap_url": "ldaps://lldap.example.com",
|
||||
"ldap_base_dn": "ou=people,dc=example,dc=com",
|
||||
"ldap_search_dn": "uid=bind,ou=people,dc=example,dc=com",
|
||||
"ldap_search_password": "very-secure-password",
|
||||
"ldap_group_base_dn": "ou=groups,dc=example,dc=com",
|
||||
"ldap_group_admin_dn": "cn=harbor-admin-group,ou=groups,dc=example,dc=com",
|
||||
"ldap_group_search_filter": "(objectClass=groupOfUniqueNames)",
|
||||
"ldap_group_attribute_name": "uid"
|
||||
}
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> ``ldap_search_dn`` needs to be able to bind and search. The ``lldap_strict_readonly`` group is sufficient.
|
||||
|
||||
> [!NOTE]
|
||||
> Members of the ``ldap_group_admin_dn`` group will receive harbor admin privledges.
|
||||
> Users outside this group will have their ldap group(s) imported into harbor (under "groups" with type "ldap").
|
||||
> These groups can be used for permissions assigned to a harbor "project".
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,12 +1,10 @@
|
||||
# Configuration for Jellyfin
|
||||
|
||||
Replace all instances of `dc=example,dc=com` with your LLDAP configured domain.
|
||||
Replace `dc=example,dc=com` with your LLDAP configured domain.
|
||||
|
||||
## LDAP Server Settings
|
||||
### LDAP Bind User
|
||||
Create an ldap user for Jellyfin to run search queries (and optionally reset passwords). For example `jellyfin_bind_user`
|
||||
```
|
||||
uid=jellyfin_bind_user,ou=people,dc=example,dc=com
|
||||
uid=admin,ou=people,dc=example,dc=com
|
||||
```
|
||||
|
||||
### LDAP Base DN for searches
|
||||
@@ -14,30 +12,31 @@ uid=jellyfin_bind_user,ou=people,dc=example,dc=com
|
||||
ou=people,dc=example,dc=com
|
||||
```
|
||||
|
||||
## LDAP User Settings
|
||||
### LDAP Search Filter
|
||||
If you have a `media` group, you can use:
|
||||
```
|
||||
(memberof=cn=media,ou=groups,dc=example,dc=com)
|
||||
```
|
||||
Otherwise, just use:
|
||||
```
|
||||
(uid=*)
|
||||
```
|
||||
### LDAP Search Attributes
|
||||
### LDAP Attributes
|
||||
|
||||
```
|
||||
uid, mail
|
||||
```
|
||||
### LDAP Uid Attribute
|
||||
```
|
||||
uid
|
||||
```
|
||||
### LDAP Username Attribute
|
||||
|
||||
### LDAP Name Attribute
|
||||
|
||||
```
|
||||
uid
|
||||
```
|
||||
|
||||
### LDAP Admin Base DN
|
||||
### User Filter
|
||||
|
||||
If you have a `media` group, you can use:
|
||||
```
|
||||
(memberof=cn=media,ou=groups,dc=example,dc=com)
|
||||
```
|
||||
|
||||
Otherwise, just use:
|
||||
```
|
||||
(uid=*)
|
||||
```
|
||||
### Admin Base DN
|
||||
|
||||
The DN to search for your admins.
|
||||
```
|
||||
ou=people,dc=example,dc=com
|
||||
|
||||
@@ -69,4 +69,4 @@ Since Keycloak and LLDAP use different attributes for different parts of a user'
|
||||
|
||||
Go back to "User Federation", edit your LDAP integration and click on the "Mappers" tab.
|
||||
|
||||
Find or create the "first name" mapper (it should have type `user-attribute-ldap-mapper`) and ensure the "LDAP Attribute" setting is set to `givenName`. Keycloak may have defaulted to `cn` which LLDAP uses for the "Display Name" of a user.
|
||||
Find or create the "first name" mapper (it should have type `user-attribute-ldap-mapper`) and ensure the "LDAP Attribute" setting is set to `givenname`. Keycloak may have defaulted to `cn` which LLDAP uses for the "Display Name" of a user.
|
||||
|
||||
@@ -54,7 +54,7 @@ services:
|
||||
- ENABLE_OPENDMARC=0
|
||||
# >>> Postfix LDAP Integration
|
||||
- ACCOUNT_PROVISIONER=LDAP
|
||||
- LDAP_SERVER_HOST=ldap://lldap:3890
|
||||
- LDAP_SERVER_HOST=lldap:3890
|
||||
- LDAP_SEARCH_BASE=ou=people,dc=example,dc=com
|
||||
- LDAP_BIND_DN=uid=admin,ou=people,dc=example,dc=com
|
||||
- LDAP_BIND_PW=adminpassword
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
# Configuring LDAP in Metabase
|
||||
|
||||
[Metabase](https://github.com/metabase/metabase)
|
||||
|
||||
The simplest, fastest way to get business intelligence and analytics to everyone in your company 😋
|
||||
|
||||
---
|
||||
|
||||
## LDAP Host
|
||||
|
||||
```
|
||||
example.com
|
||||
```
|
||||
|
||||
## LDAP Port
|
||||
|
||||
```
|
||||
3890
|
||||
```
|
||||
|
||||
## LDAP Security
|
||||
```
|
||||
None
|
||||
```
|
||||
|
||||
## Username or DN
|
||||
It is recommended to use users belonging to the `lldap_strict_readonly` group
|
||||
```
|
||||
cn=adminro,ou=people,dc=example,dc=com
|
||||
```
|
||||
|
||||
## Password
|
||||
```
|
||||
passwd
|
||||
```
|
||||
|
||||
## User search base
|
||||
```
|
||||
ou=people,dc=example,dc=com
|
||||
```
|
||||
|
||||
## User filter
|
||||
Only users in the `metabase_users` group can log in
|
||||
```
|
||||
(&(objectClass=inetOrgPerson)(|(uid={login})(mail={login}))(memberOf=cn=metabase_users,ou=groups,dc=example,dc=com))
|
||||
```
|
||||
|
||||
## Email attribute
|
||||
```
|
||||
mail
|
||||
```
|
||||
|
||||
## First name attribute
|
||||
```
|
||||
givenname
|
||||
```
|
||||
|
||||
## Last name attribute
|
||||
```
|
||||
cn
|
||||
```
|
||||
|
||||
## Group Schema
|
||||
|
||||
**Synchronize Group Memberships**: Check this option to synchronize LDAP group memberships.
|
||||
|
||||
**New Mapping**: Create a new mapping between Metabase and LDAP groups:
|
||||
|
||||
- **Group Name**: `cn=metabase_users,ou=groups,dc=example,dc=com`
|
||||
|
||||
## Group search base
|
||||
|
||||
```
|
||||
ou=groups,dc=example,dc=com
|
||||
```
|
||||
|
||||
## Useful links
|
||||
|
||||
> [Metabase docker-compose.yaml](https://www.metabase.com/docs/latest/troubleshooting-guide/ldap)
|
||||
@@ -35,38 +35,3 @@ Creating MinIO policies is outside of the scope for this document, but it is wel
|
||||
|
||||
- Alias your MinIO instance: `mc alias set myMinIO http://<your-minio-address>:<your-minio-api-port> admin <your-admin-password>`
|
||||
- Attach a policy to your LDAP group: `mc admin policy attach myMinIO consoleAdmin --group='cn=minio_admin,ou=groups,dc=example,dc=com'`
|
||||
|
||||
## Alternative configuration
|
||||
|
||||
The above options didn't work for me (thielj; 2024-6-10; latest lldap and minio docker images). In particular, having a User DN search base of `ou=people,dc=example,dc=com` conflicted with the condition `memberOf=cn=admins,ou=groups,dc=example,dc=com` due to the groups being outside the 'ou=people' search base. Using just `dc=example,dc=com` as search base was frowned upon by MinIO due to duplicate results.
|
||||
|
||||
The following environment variables made both MinIO and LLDAP happy:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
MINIO_ROOT_USER: "admin"
|
||||
MINIO_ROOT_PASSWORD: "${ADMIN_PASSWORD:?error}"
|
||||
|
||||
MINIO_IDENTITY_LDAP_SERVER_ADDR: "ldap.${TOP_DOMAIN}:636"
|
||||
#MINIO_IDENTITY_LDAP_TLS_SKIP_VERIFY: "off"
|
||||
#MINIO_IDENTITY_LDAP_SERVER_INSECURE: "off"
|
||||
#MINIO_IDENTITY_LDAP_SERVER_STARTTLS: "off"
|
||||
|
||||
# https://github.com/lldap/lldap/blob/main/example_configs/minio.md
|
||||
MINIO_IDENTITY_LDAP_LOOKUP_BIND_DN: "${LDAP_AUTH_BIND_USER}"
|
||||
MINIO_IDENTITY_LDAP_LOOKUP_BIND_PASSWORD: "${LDAP_AUTH_BIND_PASSWORD}"
|
||||
MINIO_IDENTITY_LDAP_USER_DN_SEARCH_BASE_DN: "ou=people,${LDAP_BASE_DN}"
|
||||
# allow all users to login; they need a policy attached before they can actually do anything
|
||||
MINIO_IDENTITY_LDAP_USER_DN_SEARCH_FILTER: "(&(objectclass=posixAccount)(uid=%s))"
|
||||
#MINIO_IDENTITY_LDAP_USER_DN_ATTRIBUTES: "uid,cn,mail"
|
||||
MINIO_IDENTITY_LDAP_GROUP_SEARCH_BASE_DN: "ou=groups,${LDAP_BASE_DN}"
|
||||
MINIO_IDENTITY_LDAP_GROUP_SEARCH_FILTER: "(&(objectclass=groupOfUniqueNames)(member=%d))"
|
||||
```
|
||||
|
||||
Another tip, there's no need to download or install the MinIO CLI. Assuming your running container is named `minio`, this does the trick:
|
||||
|
||||
```
|
||||
$ docker exec minio mc alias set localhost http://localhost:9000 admin "${ADMIN_PASSWORD}"
|
||||
$ docker exec minio mc ready localhost
|
||||
$ docker exec minio mc admin policy attach localhost consoleAdmin --group="cn=admins,ou=groups,${LDAP_BASE_DN}"
|
||||
```
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
# Configuration for Netbox
|
||||
|
||||
Netbox LDAP configuration is located [here](https://netboxlabs.com/docs/netbox/en/stable/installation/6-ldap/)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Install requirements
|
||||
|
||||
**Debian/Ubuntu:** `sudo apt install -y libldap2-dev libsasl2-dev libssl-dev`
|
||||
|
||||
**CentOS:** `sudo yum install -y openldap-devel python3-devel`
|
||||
|
||||
2. Install django-auth-ldap
|
||||
|
||||
`source /opt/netbox/venv/bin/activatepip3 install django-auth-ldap`
|
||||
|
||||
3. Add package to local requirements
|
||||
|
||||
`sudo sh -c "echo 'django-auth-ldap' >> /opt/netbox/local_requirements.txt"`
|
||||
|
||||
4. Enable LDAP backend in configuration.py (*default: /opt/netbox/netbox/netbox/configuration.py*)
|
||||
|
||||
`REMOTE_AUTH_BACKEND = 'netbox.authentication.LDAPBackend'`
|
||||
|
||||
## LDAP Configuration
|
||||
|
||||
1. Create ldap_config.py file
|
||||
|
||||
`touch /opt/netbox/netbox/netbox/ldap_config.py`
|
||||
|
||||
2. Copy and modify the configuration below
|
||||
|
||||
```python
|
||||
import ldap
|
||||
from django_auth_ldap.config import LDAPSearch, NestedGroupOfNamesType
|
||||
|
||||
# Server URI
|
||||
AUTH_LDAP_SERVER_URI = "ldaps://lldap.example.com:6360"
|
||||
|
||||
# Connection options, if necessary
|
||||
AUTH_LDAP_CONNECTION_OPTIONS = {
|
||||
ldap.OPT_REFERRALS: 0 # Disable referral chasing if not needed
|
||||
}
|
||||
|
||||
# Bind DN and password for the service account
|
||||
AUTH_LDAP_BIND_DN = "uid=admin,ou=people,dc=example,dc=com"
|
||||
AUTH_LDAP_BIND_PASSWORD = "ChangeMe!"
|
||||
|
||||
# Ignore certificate errors (for self-signed certificates)
|
||||
LDAP_IGNORE_CERT_ERRORS = False # Only use in development or testing!
|
||||
|
||||
# Include this setting if you want to validate the LDAP server certificates against a CA certificate directory on your server
|
||||
# Note that this is a NetBox-specific setting which sets:
|
||||
# ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, LDAP_CA_CERT_DIR)
|
||||
LDAP_CA_CERT_DIR = '/etc/ssl/certs'
|
||||
|
||||
# Include this setting if you want to validate the LDAP server certificates against your own CA.
|
||||
# Note that this is a NetBox-specific setting which sets:
|
||||
# ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, LDAP_CA_CERT_FILE)
|
||||
LDAP_CA_CERT_FILE = '/path/to/example-CA.crt'
|
||||
|
||||
# User search configuration
|
||||
AUTH_LDAP_USER_SEARCH = LDAPSearch(
|
||||
"ou=people,dc=example,dc=com",
|
||||
ldap.SCOPE_SUBTREE,
|
||||
"(uid=%(user)s)"
|
||||
)
|
||||
|
||||
# User DN template
|
||||
AUTH_LDAP_USER_DN_TEMPLATE = "uid=%(user)s,ou=people,dc=example,dc=com"
|
||||
|
||||
# Map LDAP attributes to Django user attributes
|
||||
AUTH_LDAP_USER_ATTR_MAP = {
|
||||
"username": "uid",
|
||||
"email": "mail",
|
||||
"first_name": "givenName",
|
||||
"last_name": "sn",
|
||||
}
|
||||
|
||||
# Group search configuration
|
||||
AUTH_LDAP_GROUP_SEARCH = LDAPSearch(
|
||||
"ou=groups,dc=example,dc=com",
|
||||
ldap.SCOPE_SUBTREE,
|
||||
"(objectClass=group)"
|
||||
)
|
||||
AUTH_LDAP_GROUP_TYPE = NestedGroupOfNamesType()
|
||||
|
||||
# Require users to be in a specific group to log in
|
||||
AUTH_LDAP_REQUIRE_GROUP = "cn=netbox_users,ou=groups,dc=example,dc=com"
|
||||
|
||||
# Mirror LDAP group assignments
|
||||
AUTH_LDAP_MIRROR_GROUPS = True
|
||||
|
||||
# Map LDAP groups to Django user flags
|
||||
AUTH_LDAP_USER_FLAGS_BY_GROUP = {
|
||||
"is_superuser": "cn=netbox_admins,ou=groups,dc=example,dc=com"
|
||||
}
|
||||
|
||||
# Find group permissions
|
||||
AUTH_LDAP_FIND_GROUP_PERMS = True
|
||||
|
||||
# Cache group memberships to reduce LDAP traffic
|
||||
AUTH_LDAP_CACHE_TIMEOUT = 3600
|
||||
|
||||
# Always update user information from LDAP on login
|
||||
AUTH_LDAP_ALWAYS_UPDATE_USER = True
|
||||
```
|
||||
|
||||
3. Restart netbox and netbox-rq
|
||||
|
||||
`sudo systemctl restart netbox netbox-rq`
|
||||
|
||||
## Troubleshoot LDAP
|
||||
|
||||
1. Make logging directory
|
||||
|
||||
`sudo mkdir -p /opt/netbox/local/logs/`
|
||||
|
||||
2. Make log file
|
||||
|
||||
`sudo touch /opt/netbox/local/logs/django-ldap-debug.log`
|
||||
|
||||
3. Set permissions
|
||||
|
||||
`sudo chown -R netbox:root /opt/netbox/local`
|
||||
|
||||
4. Add the following to */opt/netbox/netbox/netbox/configuration.py*
|
||||
|
||||
```py
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'handlers': {
|
||||
'netbox_auth_log': {
|
||||
'level': 'DEBUG',
|
||||
'class': 'logging.handlers.RotatingFileHandler',
|
||||
'filename': '/opt/netbox/local/logs/django-ldap-debug.log',
|
||||
'maxBytes': 1024 * 500,
|
||||
'backupCount': 5,
|
||||
},
|
||||
},
|
||||
'loggers': {
|
||||
'django_auth_ldap': {
|
||||
'handlers': ['netbox_auth_log'],
|
||||
'level': 'DEBUG',
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
@@ -74,7 +74,6 @@ occ ldap:set-config s01 ldapUserDisplayName displayname
|
||||
occ ldap:set-config s01 ldapUserFilterMode 1
|
||||
occ ldap:set-config s01 ldapUuidGroupAttribute auto
|
||||
occ ldap:set-config s01 ldapUuidUserAttribute auto
|
||||
occ ldap:set-config s01 ldapExpertUsernameAttr user_id
|
||||
```
|
||||
With a bit of of luck, you should be able to log in your nextcloud instance with LLDAP accounts in the `nextcloud_users` group.
|
||||
|
||||
@@ -115,16 +114,9 @@ Enter a valid username in lldap and check if your filter is working.
|
||||
### Groups
|
||||
|
||||
You can use the menus for this part : select `groupOfUniqueNames` in the first menu and check every group you want members to be allowed to view their group member / share files with.
|
||||
For example:
|
||||
```
|
||||
(&(|(objectclass=groupOfUniqueNames))(|(cn=family)(cn=friends)))
|
||||
```
|
||||
|
||||

|
||||
|
||||
### Expert
|
||||
|
||||
Set `Internal Username` to `user_id`. This is needed to that the user ID used by Nextcloud corresponds to the `user_id` field and not the `UUID` field.
|
||||
|
||||
## Sharing restrictions
|
||||
|
||||
Go to Settings > Administration > Sharing and check following boxes :
|
||||
|
||||
@@ -19,7 +19,7 @@ Click `Verify connection` if successful click `Next`
|
||||
* Select a template: Generic ldap server
|
||||
* User Relative DN: `ou=people`
|
||||
* User subtree: Leave unchecked
|
||||
* Object class: `person`
|
||||
* Object class: person
|
||||
* User Filter: Leave empty to allow all users to log in or `(memberOf=uid=nexus_users,ou=groups,dc=example,dc=com)` for a specific group
|
||||
* Username Attribute: `uid`
|
||||
* Real Name Attribute: `cn`
|
||||
@@ -53,4 +53,4 @@ Click `Create Role`
|
||||
* Role Name: e.g. nexus_admin (group in lldap)
|
||||
* Add privileges/roles as needed e.g. under Roles add nx-admin to the "contained" list
|
||||
|
||||
Click `Save`
|
||||
Click `Save`
|
||||
@@ -1,99 +0,0 @@
|
||||
# OCIS (OwnCloud Infinite Scale)
|
||||
|
||||
This is using version 5 which is currently still in RC.
|
||||
|
||||
IMPORTANT: There is a bug/quirk in how the OCIS container handles bind mounts.
|
||||
|
||||
If the bind mount locations (eg. `/srv/ocis/{app,cfg}`) don't exist when the container is started, OCIS creates them with `root` permissions. It then seems to drop permissions to UID 1000 and gives an error because it can't create files in the `{app,cfg}`.
|
||||
|
||||
So you must create the bind mount locations and manually chown them to uid/gid 1000, eg.
|
||||
|
||||
```
|
||||
# cd /srv/ocis
|
||||
# mkdir app cfg
|
||||
# chown 1000:1000 app cfg
|
||||
# docker compose up -d && docker compose logs -f
|
||||
```
|
||||
|
||||
## .env
|
||||
|
||||
```
|
||||
OCIS_URL="https://ocis.example.nz"
|
||||
LDAP_BASE_DN="dc=example,dc=nz"
|
||||
LDAP_BIND_PASSWORD=very-secret-yogurt
|
||||
# LLDAP UUID to be given admin permissions
|
||||
LLDAP_ADMIN_UUID=c1c2428a-xxxx-yyyy-zzzz-6cc946bf6809
|
||||
```
|
||||
|
||||
## docker-compose.yml
|
||||
|
||||
```
|
||||
version: "3.7"
|
||||
|
||||
networks:
|
||||
caddy:
|
||||
external: true
|
||||
|
||||
services:
|
||||
ocis:
|
||||
image: owncloud/ocis:5.0.0-rc.4
|
||||
container_name: ocis
|
||||
networks:
|
||||
- caddy
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
command: ["-c", "ocis init || true; ocis server"]
|
||||
environment:
|
||||
OCIS_URL: ${OCIS_URL}
|
||||
OCIS_LOG_LEVEL: warn
|
||||
OCIS_LOG_COLOR: "false"
|
||||
PROXY_TLS: "false" # do not use SSL between Traefik and oCIS
|
||||
OCIS_INSECURE: "false"
|
||||
# Basic Auth is required for WebDAV clients that don't support OIDC
|
||||
PROXY_ENABLE_BASIC_AUTH: "false"
|
||||
#IDM_ADMIN_PASSWORD: "${ADMIN_PASSWORD}" # Not needed if admin user is in LDAP (?)
|
||||
#OCIS_PASSWORD_POLICY_BANNED_PASSWORDS_LIST: "banned-password-list.txt"
|
||||
|
||||
# Assumes your LLDAP container is named `lldap`
|
||||
OCIS_LDAP_URI: ldap://lldap:3890
|
||||
OCIS_LDAP_INSECURE: "true"
|
||||
OCIS_LDAP_BIND_DN: "uid=admin,ou=people,${LDAP_BASE_DN}"
|
||||
OCIS_LDAP_BIND_PASSWORD: ${LDAP_BIND_PASSWORD}
|
||||
OCIS_ADMIN_USER_ID: ${LLDAP_ADMIN_UUID}
|
||||
|
||||
OCIS_LDAP_USER_ENABLED_ATTRIBUTE: uid
|
||||
GRAPH_LDAP_SERVER_WRITE_ENABLED: "false" # Does your LLDAP bind user have write access?
|
||||
GRAPH_LDAP_REFINT_ENABLED: "false"
|
||||
# Disable the built in LDAP server
|
||||
OCIS_EXCLUDE_RUN_SERVICES: idm
|
||||
# both text and binary cause errors in LLDAP, seems harmless though (?)
|
||||
#IDP_LDAP_UUID_ATTRIBUTE_TYPE: 'text'
|
||||
|
||||
LDAP_LOGIN_ATTRIBUTES: "uid"
|
||||
IDP_LDAP_LOGIN_ATTRIBUTE: "uid"
|
||||
IDP_LDAP_UUID_ATTRIBUTE: "entryuuid"
|
||||
OCIS_LDAP_USER_SCHEMA_ID: "entryuuid"
|
||||
OCIS_LDAP_GROUP_SCHEMA_ID: "uid"
|
||||
OCIS_LDAP_GROUP_SCHEMA_GROUPNAME: "uid"
|
||||
|
||||
OCIS_LDAP_GROUP_BASE_DN: "ou=groups,${LDAP_BASE_DN}"
|
||||
OCIS_LDAP_GROUP_OBJECTCLASS: "groupOfUniqueNames"
|
||||
# can filter which groups are imported, eg: `(&(objectclass=groupOfUniqueNames)(uid=ocis_*))`
|
||||
OCIS_LDAP_GROUP_FILTER: "(objectclass=groupOfUniqueNames)"
|
||||
|
||||
OCIS_LDAP_USER_BASE_DN: "ou=people,${LDAP_BASE_DN}"
|
||||
OCIS_LDAP_USER_OBJECTCLASS: "inetOrgPerson"
|
||||
# Allows all users
|
||||
#OCIS_LDAP_USER_FILTER: "(objectclass=inetOrgPerson)"
|
||||
# Allows users who are in the LLDAP group `ocis_users`
|
||||
OCIS_LDAP_USER_FILTER: "(&(objectclass=person)(memberOf=cn=ocis_users,ou=groups,${LDAP_BASE_DN}))"
|
||||
# NOT WORKING: Used instead of restricting users with OCIS_LDAP_USER_FILTER
|
||||
#OCIS_LDAP_DISABLE_USER_MECHANISM: "group"
|
||||
#OCIS_LDAP_DISABLED_USERS_GROUP_DN: "uid=ocis_disabled,ou=groups,${LDAP_BASE_DN}"
|
||||
volumes:
|
||||
# - ./config/ocis/banned-password-list.txt:/etc/ocis/banned-password-list.txt
|
||||
# IMPORTANT: see note at top about creating/cowning bind mounts
|
||||
- ./cfg:/etc/ocis
|
||||
- ./app:/var/lib/ocis
|
||||
restart: always
|
||||
```
|
||||
@@ -1,99 +0,0 @@
|
||||
> [!IMPORTANT]
|
||||
> The integration requires custom ldap properties which are not supported on
|
||||
> stable (as of 2024-09-19), please use nightly/latest tag.
|
||||
|
||||
# Configure lldap
|
||||
|
||||
You MUST use LDAPS. You MUST NOT use plain ldap. Even over a private network
|
||||
this costs you nearly nothing, and passwords will be sent in PLAIN TEXT without
|
||||
it.
|
||||
|
||||
```toml
|
||||
[ldaps_options]
|
||||
enabled=true
|
||||
port=6360
|
||||
cert_file="cert.pem"
|
||||
key_file="key.pem"
|
||||
```
|
||||
|
||||
You can generate an SSL certificate for it with the following command. The
|
||||
`subjectAltName` is REQUIRED. Make sure all domains are listed there, even your
|
||||
`CN`.
|
||||
|
||||
```sh
|
||||
openssl req -x509 -nodes -newkey rsa:4096 -keyout key.pem -out cert.pem -sha256 -days 36500 -nodes -subj "/CN=lldap.example.net" -addext "subjectAltName = DNS:lldap.example.net"
|
||||
```
|
||||
|
||||
# Install the client packages.
|
||||
|
||||
This guide used `libnss-ldapd` (which is different from `libnss-ldap`).
|
||||
|
||||
PURGE the following ubuntu packages: `libnss-ldap`, `libpam-ldap`
|
||||
|
||||
Install the following ubuntu packages: `libnss-ldapd`, `nslcd`, `nscd`, `libpam-ldapd`
|
||||
|
||||
# Configure the client's `nslcd` settings.
|
||||
|
||||
Edit `/etc/nslcd.conf`. Use the [provided template](./nslcd.conf).
|
||||
|
||||
You will need to set `tls_cacertfile` to a copy of the public portion of your
|
||||
LDAPS certificate, which must be available on the client. This is used to
|
||||
verify the LDAPS server identity.
|
||||
|
||||
You will need to add the `binddn` and `bindpw` settings.
|
||||
|
||||
The provided implementation uses custom attributes to mark users and groups
|
||||
that should be included in the system (for instance, you don't want LDAP
|
||||
accounts of other services to have a matching unix user).
|
||||
|
||||
> [!TIP]
|
||||
> You can create custom attributes in the Web UI, but to provide values, you
|
||||
> need to communicate with the API, see [scripting]. Example using lldap-cli:
|
||||
> `./lldap-cli user update set example-user unix-uid 5000`
|
||||
|
||||
For users, you need to add an (integer) `unix-uid` attribute to the schema, and
|
||||
manually set the value for the users you want to enable to login with PAM.
|
||||
|
||||
For groups, you need an (integer) `unix-gid` attribute, similarly set manually
|
||||
to some value.
|
||||
|
||||
If you want to change this representation, update the `filter passwd` and
|
||||
`filter group` accordingly.
|
||||
|
||||
You should check whether you need to edit the `pam_authz_search` setting. This
|
||||
is used after authentication, at the PAM `account` stage, to determine whether
|
||||
the user should be allowed to log in. If someone is an LDAP user, even if they
|
||||
use an SSH key to log in, they must still pass this check. The provided example
|
||||
will check for membership of a group named `YOUR_LOGIN_GROUP_FOR_THIS_MACHINE`.
|
||||
|
||||
You should review the `map` settings. These contain custom attributes that you
|
||||
will need to add to lldap and set on your users.
|
||||
|
||||
# Configure the client OS.
|
||||
|
||||
Ensure the `nslcd` and `nscd` services are installed and running. `nslcd`
|
||||
provides LDAP NSS service. `nscd` provides caching for NSS databased. You want
|
||||
the caching.
|
||||
|
||||
```
|
||||
systemctl enable --now nslcd nscd
|
||||
```
|
||||
|
||||
Configure PAM to create the home directory for LDAP users automatically at
|
||||
first login.
|
||||
|
||||
```
|
||||
pam-auth-update --enable mkhomedir
|
||||
```
|
||||
|
||||
Edit /etc/nsswitch.conf and add "ldap" to the END of the "passwd" and "group"
|
||||
lines.
|
||||
|
||||
You're done!
|
||||
|
||||
## Clearing nscd caches.
|
||||
|
||||
If you want to manually clear nscd's caches, run `nscd -i passwd; nscd -i group`.
|
||||
|
||||
[scripting]: https://github.com/lldap/lldap/blob/main/docs/scripting.md
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
# /etc/nslcd.conf
|
||||
# nslcd configuration file. See nslcd.conf(5)
|
||||
# for details.
|
||||
|
||||
# The user and group nslcd should run as.
|
||||
uid nslcd
|
||||
gid nslcd
|
||||
|
||||
# The location at which the LDAP server(s) should be reachable.
|
||||
uri ldaps://lldap.example.net:6360/
|
||||
|
||||
# The search base that will be used for all queries.
|
||||
base dc=example,dc=net
|
||||
|
||||
# The LDAP protocol version to use.
|
||||
#ldap_version 3
|
||||
|
||||
# The DN to bind with for normal lookups.
|
||||
binddn cn=...,ou=people,dc=example,dc=com
|
||||
bindpw ...
|
||||
|
||||
# The DN used for password modifications by root.
|
||||
#rootpwmoddn cn=admin,dc=example,dc=com
|
||||
|
||||
# SSL options
|
||||
#ssl off
|
||||
tls_reqcert demand
|
||||
tls_cacertfile /etc/cert-lldap.example.com.pem
|
||||
|
||||
# The search scope.
|
||||
#scope sub
|
||||
|
||||
reconnect_invalidate passwd group
|
||||
nss_initgroups_ignoreusers ALLLOCAL
|
||||
|
||||
# Do you have users/groups that aren't for linux? These filters determine which user/group objects are used.
|
||||
filter passwd (&(objectClass=posixAccount)(unix-uid=*))
|
||||
filter group (&(objectClass=groupOfUniqueNames)(unix-gid=*))
|
||||
|
||||
# This check is done AFTER authentication, in the pam "account" stage.
|
||||
# Regardless of if they used a LDAP password, or an SSH key, if they're an LDAP user, they have to pass this check.
|
||||
pam_authz_search (&(objectClass=posixAccount)(unix-uid=*)(unix-username=$username)(memberOf=cn=YOUR_LOGIN_GROUP_FOR_THIS_MACHINE,ou=groups,dc=example,dc=com))
|
||||
|
||||
|
||||
map passwd uid unix-username
|
||||
map passwd uidNumber unix-uid
|
||||
map passwd gidNumber unix-gid
|
||||
map passwd gecos unix-username
|
||||
map passwd homeDirectory "/home/${unix-username}"
|
||||
map passwd loginShell unix-shell
|
||||
|
||||
map group gidNumber unix-gid
|
||||
map group memberUid member
|
||||
|
||||
nss_min_uid 1000
|
||||
|
||||
pam_password_prohibit_message "Please use the forgot password link on https://lldap.example.com/ to change your password."
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
# Configuration for pfSense
|
||||
|
||||
> [!NOTE]
|
||||
> Replace `dc=example,dc=com` with the same LDAP Base DN that you set via the *LLDAP_LDAP_BASE_DN* environment variable or in `lldap_config.toml`.
|
||||
|
||||
## Create a LDAP Server
|
||||
|
||||
- Login to pfSense
|
||||
@@ -19,9 +16,7 @@
|
||||
- Protocol version: `3`
|
||||
- Server Timeout: `25`
|
||||
|
||||
> [!NOTE]
|
||||
> Make sure the host running LLDAP is accessible to pfSense and that you mapped the LLDAP port to the LLDAP host
|
||||
|
||||
(Make sure the host running LLDAP is accessible to pfSense and that you mapped the LLDAP port to the LLDAP host)
|
||||
### Search Scope
|
||||
```
|
||||
Entire Subtree
|
||||
@@ -32,25 +27,18 @@ Entire Subtree
|
||||
dc=example,dc=com
|
||||
```
|
||||
|
||||
This is the same LDAP Base DN that you set via the *LLDAP_LDAP_BASE_DN* environment variable or in `lldap_config.toml`.
|
||||
### Authentication containers
|
||||
|
||||
```
|
||||
ou=people
|
||||
```
|
||||
|
||||
> [!Note]
|
||||
> The `Select a container` seach fuction will not work for selecting containers. You enter the `Authentication containers` directly into the text field.
|
||||
> This is due to Pfsense running the following filter `"(|(ou=*)(cn=Users))"`, and `Organizational Units` is currently not supported.Could not connect to the LDAP server. Please check the LDAP configuration.
|
||||
|
||||
> [!WARNING]
|
||||
> if search button is pressed a warning will show on the bottom of the page: `Could not connect to the LDAP server. Please check the LDAP configuration.`
|
||||
|
||||
Note: The `Select a container` box may not work for selecting containers. You can just enter the `Authentication containers` directly into the text field.
|
||||
|
||||
### Extended Query
|
||||
|
||||
Enable extended query:
|
||||
|
||||
- [X] `Checked`
|
||||
Enable extended query: `Checked`
|
||||
|
||||
### Query:
|
||||
|
||||
@@ -61,8 +49,7 @@ Enable extended query:
|
||||
This example gives you two groups in LLDAP, one for pfSense admin access (`pfsense_admin`) and one for guest access (`pfsense_guest`). You **must** create these exact same groups in both LLDAP and pfSense, then give them the correct permissions in pfSense.
|
||||
|
||||
### Bind Anonymous
|
||||
|
||||
- [ ] `Unchecked`
|
||||
`Unchecked`
|
||||
|
||||
### Bind credentials
|
||||
|
||||
@@ -93,18 +80,13 @@ cn
|
||||
memberof
|
||||
```
|
||||
### RFC 2307 Groups
|
||||
|
||||
- [ ] `Unchecked`
|
||||
`Unchecked`
|
||||
|
||||
### Group Object Class
|
||||
```
|
||||
groupOfUniqueNames
|
||||
```
|
||||
`groupOfUniqueNames`
|
||||
|
||||
### Shell Authentication Group DN
|
||||
```
|
||||
cn=pfsense_admin,ou=groups,dc=example,dc=com
|
||||
```
|
||||
`cn=pfsense_admin,ou=groups,dc=example,dc=com`
|
||||
|
||||
(This is only if you want to give a group shell access through LDAP. Leave blank and only the pfSense admin user will have shell access.
|
||||
|
||||
@@ -112,9 +94,9 @@ cn=pfsense_admin,ou=groups,dc=example,dc=com
|
||||
|
||||
Enable the following options on the pfSense configuration page for your LLDAP server (the same page where you entered the prior configuration):
|
||||
|
||||
- [X] UTF8 Encodes: `Checked`
|
||||
- [ ] Username Alterations: `Unchecked`
|
||||
- [ ] Allow unauthenticated bind: `Unchecked`
|
||||
- UTF8 Encodes: `Checked`
|
||||
- Username Alterations: `Unchecked`
|
||||
- Allow unauthenticated bind: `Unchecked`
|
||||
|
||||
### Create pfSense Groups
|
||||
|
||||
@@ -130,9 +112,6 @@ Go to `System > User Manager > Settings` page. Add your LLDAP server configurati
|
||||
|
||||
pfSense includes a built-in feature for testing user authentication at `Diagnostics > Authentication`. Select your LLDAP server configuration in the `Authentication Server` to test logins for your LLDAP users. The groups (only the ones you added to pfSense) should show up when tested.
|
||||
|
||||
> [!WARNING]
|
||||
> When running `Save and test`, the `Attempting to fetch Organizational Units from` will fail. This is due to Pfsense running the following filter `"(|(ou=*)(cn=Users))"`, and `Organizational Units` is currently not supported.
|
||||
|
||||
## More Information
|
||||
|
||||
Please read the [pfSense docs](https://docs.netgate.com/pfsense/en/latest/usermanager/ldap.html) for more information on LDAP configuration and managing access to pfSense.
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# Configuration for Prosody XMPP server
|
||||
|
||||
Prosody is setup with virtual hosts, at least one. If you want to have users access only specific virtual hosts, create a group per vHost (I called it `xmpp-example.com`). If not, remove the memberOf part in the filter below. I would also create a read only user (mine is called `query`) with the group `lldap_strict_readonly` to find the users that will be used to bind.
|
||||
|
||||
In `prosody.cfg.lua` you need to set `authentication` to `ldap` and the following settings:
|
||||
|
||||
```authentication = "ldap"
|
||||
ldap_base = "dc=example,dc=com"
|
||||
ldap_server = "lldap_ip:3890"
|
||||
ldap_rootdn = "uid=query,ou=people,dc=example,dc=com"
|
||||
ldap_password = "query-password"
|
||||
ldap_filter = "(&(uid=$user)(memberOf=cn=xmpp-$host,ou=groups,dc=example,dc=com)(objectclass=person))"
|
||||
```
|
||||
|
||||
Restart Prosody and you should be good to go.
|
||||
@@ -1,24 +1,15 @@
|
||||
# Configuration of Radicale authentication with LLDAP
|
||||
# Configuration of RADICALE authentification with lldap.
|
||||
|
||||
## Native configuration (requires Radicale >=3.3.0)
|
||||
# Fork of the radicale LDAP plugin to work with LLDAP : https://github.com/shroomify-it/radicale-auth-ldap-plugin
|
||||
|
||||
```ini
|
||||
[auth]
|
||||
type = ldap
|
||||
ldap_uri = ldap://lldap:3890
|
||||
ldap_base = dc=example,dc=com
|
||||
ldap_reader_dn = uid=admin,ou=people,dc=example,dc=com
|
||||
ldap_secret = CHANGEME
|
||||
ldap_filter = (&(objectClass=person)(uid={0}))
|
||||
lc_username = True
|
||||
```
|
||||
# Full docker-compose stack : https://github.com/shroomify-it/docker-deploy_radicale-agendav-lldap
|
||||
|
||||
## Plugin configuration (requires [radicale-auth-ldap](https://github.com/shroomify-it/radicale-auth-ldap-plugin) plugin and Radicale >=3.0)
|
||||
# Radicale config file v0.3 (inside docker container /etc/radicale/config https://radicale.org/v3.html#configuration)
|
||||
|
||||
```ini
|
||||
```toml
|
||||
[auth]
|
||||
type = radicale_auth_ldap
|
||||
ldap_url = ldap://lldap:3890
|
||||
ldap_url = ldap://lldap:3890
|
||||
ldap_base = dc=example,dc=com
|
||||
ldap_attribute = uid
|
||||
ldap_filter = (objectClass=person)
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
# Configuring LDAP in SonarQube
|
||||
|
||||
[SonarQube](https://github.com/SonarSource/sonarqube)
|
||||
|
||||
Continuous Inspection
|
||||
|
||||
---
|
||||
|
||||
SonarQube can configure ldap through environment variables when deploying using docker-compose
|
||||
|
||||
## docker-compose.yaml
|
||||
|
||||
```yaml
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
sonarqube:
|
||||
image: sonarqube:community
|
||||
depends_on:
|
||||
- db
|
||||
environment:
|
||||
SONAR_JDBC_URL: jdbc:postgresql://db:5432/sonar
|
||||
SONAR_JDBC_USERNAME: sonar
|
||||
SONAR_JDBC_PASSWORD: sonar
|
||||
LDAP_URL: ldap://example.com:3890
|
||||
LDAP_BINDDN: cn=admin,ou=people,dc=example,dc=com
|
||||
LDAP_BINDPASSWORD: passwd
|
||||
LDAP_AUTHENTICATION: simple
|
||||
LDAP_USER_BASEDN: ou=people,dc=example,dc=com
|
||||
LDAP_USER_REQUEST: (&(objectClass=inetOrgPerson)(uid={login})(memberof=cn=sonarqube_users,ou=groups,dc=example,dc=com))
|
||||
LDAP_USER_REALNAMEATTRIBUTE: cn
|
||||
LDAP_USER_EMAILATTRIBUTE: mail
|
||||
volumes:
|
||||
- sonarqube_data:/opt/sonarqube/data
|
||||
- sonarqube_extensions:/opt/sonarqube/extensions
|
||||
- sonarqube_logs:/opt/sonarqube/logs
|
||||
ports:
|
||||
- "9000:9000"
|
||||
db:
|
||||
image: postgres:12
|
||||
environment:
|
||||
POSTGRES_USER: sonar
|
||||
POSTGRES_PASSWORD: sonar
|
||||
volumes:
|
||||
- postgresql:/var/lib/postgresql
|
||||
- postgresql_data:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
sonarqube_data:
|
||||
sonarqube_extensions:
|
||||
sonarqube_logs:
|
||||
postgresql:
|
||||
postgresql_data:
|
||||
```
|
||||
|
||||
> [SonarQube docker-compose.yaml example](https://docs.sonarsource.com/sonarqube/latest/setup-and-upgrade/install-the-server/installing-sonarqube-from-docker/)
|
||||
|
||||
@@ -9,11 +9,12 @@ Replace `dc=example,dc=com` with your LLDAP configured domain.
|
||||
version: '3'
|
||||
services:
|
||||
ldap_sync:
|
||||
image: vividboarder/vaultwarden_ldap:2.0.2
|
||||
image: vividboarder/vaultwarden_ldap:0.6-alpine
|
||||
volumes:
|
||||
- ./config.toml:/config.toml:ro
|
||||
environment:
|
||||
CONFIG_PATH: /config.toml
|
||||
RUST_BACKTRACE: 1
|
||||
restart: always
|
||||
```
|
||||
Configuration to use LDAP in `config.toml`
|
||||
@@ -22,7 +23,6 @@ vaultwarden_url = "http://your_bitwarden_url:port"
|
||||
vaultwarden_admin_token = "insert_admin_token_vaultwarden"
|
||||
ldap_host = "insert_ldap_host"
|
||||
ldap_port = 3890
|
||||
ldap_ssl = false # true if using LDAPS
|
||||
ldap_bind_dn = "uid=admin,ou=people,dc=example,dc=com"
|
||||
ldap_bind_password = "insert_admin_pw_ldap"
|
||||
ldap_search_base_dn = "dc=example,dc=com"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#! /bin/sh
|
||||
|
||||
print_random () {
|
||||
function print_random () {
|
||||
LC_ALL=C tr -dc 'A-Za-z0-9!#%&()*+,-./:;<=>?@[\]^_{|}~' </dev/urandom | head -c 32
|
||||
}
|
||||
|
||||
|
||||
@@ -82,8 +82,7 @@
|
||||
## Break glass in case of emergency: if you lost the admin password, you
|
||||
## can set this to true to force a reset of the admin password to the value
|
||||
## of ldap_user_pass above.
|
||||
## Alternatively, you can set it to "always" to reset every time the server starts.
|
||||
# force_ldap_user_pass_reset = false
|
||||
# force_reset_admin_password = false
|
||||
|
||||
## Database URL.
|
||||
## This encodes the type of database (SQlite, MySQL, or PostgreSQL)
|
||||
|
||||
@@ -182,14 +182,14 @@ impl TryFrom<ResultEntry> for User {
|
||||
.attrs
|
||||
.get("jpegPhoto")
|
||||
.map(|v| v.iter().map(|s| s.as_bytes().to_vec()).collect::<Vec<_>>())
|
||||
.or_else(|| entry.bin_attrs.get("jpegPhoto").cloned())
|
||||
.or_else(|| entry.bin_attrs.get("jpegPhoto").map(Clone::clone))
|
||||
.and_then(|v| v.into_iter().next().filter(|s| !s.is_empty()));
|
||||
let password =
|
||||
get_optional_attribute("userPassword").or_else(|| get_optional_attribute("password"));
|
||||
Ok(User::new(
|
||||
crate::lldap::CreateUserInput {
|
||||
id,
|
||||
email: Some(email),
|
||||
email,
|
||||
display_name,
|
||||
first_name,
|
||||
last_name,
|
||||
|
||||
3
schema.graphql
generated
3
schema.graphql
generated
@@ -63,7 +63,7 @@ type Query {
|
||||
"The details required to create a user."
|
||||
input CreateUserInput {
|
||||
id: String!
|
||||
email: String
|
||||
email: String!
|
||||
displayName: String
|
||||
firstName: String
|
||||
lastName: String
|
||||
@@ -78,7 +78,6 @@ type AttributeSchema {
|
||||
isVisible: Boolean!
|
||||
isEditable: Boolean!
|
||||
isHardcoded: Boolean!
|
||||
isReadonly: Boolean!
|
||||
}
|
||||
|
||||
"The fields that can be updated for a user."
|
||||
|
||||
@@ -8,7 +8,7 @@ keywords = ["cli", "ldap", "graphql", "server", "authentication"]
|
||||
license = "GPL-3.0-only"
|
||||
name = "lldap"
|
||||
repository = "https://github.com/lldap/lldap"
|
||||
version = "0.6.0"
|
||||
version = "0.5.1-alpha"
|
||||
|
||||
[dependencies]
|
||||
actix = "0.13"
|
||||
@@ -25,6 +25,7 @@ base64 = "0.21"
|
||||
bincode = "1.3"
|
||||
cron = "*"
|
||||
derive_builder = "0.12"
|
||||
derive_more = "0.99"
|
||||
figment_file_provider_adapter = "0.1"
|
||||
futures = "*"
|
||||
futures-util = "*"
|
||||
@@ -34,7 +35,7 @@ itertools = "0.10"
|
||||
juniper = "0.15"
|
||||
jwt = "0.16"
|
||||
lber = "0.4.1"
|
||||
ldap3_proto = "^0.5.1"
|
||||
ldap3_proto = "^0.4.3"
|
||||
log = "*"
|
||||
orion = "0.17"
|
||||
rand_chacha = "0.3"
|
||||
@@ -63,11 +64,6 @@ version = "*"
|
||||
features = ["std", "color", "suggestions", "derive", "env"]
|
||||
version = "4"
|
||||
|
||||
[dependencies.derive_more]
|
||||
features = ["debug", "display", "from", "from_str"]
|
||||
default-features = false
|
||||
version = "1"
|
||||
|
||||
[dependencies.figment]
|
||||
features = ["env", "toml"]
|
||||
version = "*"
|
||||
|
||||
@@ -3,7 +3,7 @@ use thiserror::Error;
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
#[derive(Error, Debug)]
|
||||
pub enum DomainError {
|
||||
#[error("Authentication error {0}")]
|
||||
#[error("Authentication error: `{0}`")]
|
||||
AuthenticationError(String),
|
||||
#[error("Database error: `{0}`")]
|
||||
DatabaseError(#[from] sea_orm::DbErr),
|
||||
|
||||
@@ -61,7 +61,6 @@ pub enum UserRequestFilter {
|
||||
MemberOf(GroupName),
|
||||
// Same, by id.
|
||||
MemberOfId(GroupId),
|
||||
CustomAttributePresent(AttributeName),
|
||||
}
|
||||
|
||||
impl From<bool> for UserRequestFilter {
|
||||
@@ -86,7 +85,6 @@ pub enum GroupRequestFilter {
|
||||
// Check if the group contains a user identified by uid.
|
||||
Member(UserId),
|
||||
AttributeEquality(AttributeName, Serialized),
|
||||
CustomAttributePresent(AttributeName),
|
||||
}
|
||||
|
||||
impl From<bool> for GroupRequestFilter {
|
||||
@@ -147,7 +145,6 @@ pub struct AttributeSchema {
|
||||
pub is_visible: bool,
|
||||
pub is_editable: bool,
|
||||
pub is_hardcoded: bool,
|
||||
pub is_readonly: bool,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
||||
|
||||
@@ -7,28 +7,29 @@ use tracing::{debug, instrument, warn};
|
||||
use crate::domain::{
|
||||
deserialize::deserialize_attribute_value,
|
||||
handler::{GroupListerBackendHandler, GroupRequestFilter},
|
||||
ldap::{
|
||||
error::{LdapError, LdapResult},
|
||||
utils::{
|
||||
expand_attribute_wildcards, get_custom_attribute,
|
||||
get_group_id_from_distinguished_name_or_plain_name,
|
||||
get_user_id_from_distinguished_name_or_plain_name, map_group_field, ExpandedAttributes,
|
||||
GroupFieldType, LdapInfo,
|
||||
},
|
||||
},
|
||||
ldap::error::LdapError,
|
||||
schema::{PublicSchema, SchemaGroupAttributeExtractor},
|
||||
types::{AttributeName, AttributeType, Group, LdapObjectClass, UserId, Uuid},
|
||||
};
|
||||
|
||||
use super::{
|
||||
error::LdapResult,
|
||||
utils::{
|
||||
expand_attribute_wildcards, get_custom_attribute, get_group_id_from_distinguished_name,
|
||||
get_user_id_from_distinguished_name, map_group_field, GroupFieldType, LdapInfo,
|
||||
},
|
||||
};
|
||||
|
||||
pub fn get_group_attribute(
|
||||
group: &Group,
|
||||
base_dn_str: &str,
|
||||
attribute: &AttributeName,
|
||||
attribute: &str,
|
||||
user_filter: &Option<UserId>,
|
||||
ignored_group_attributes: &[AttributeName],
|
||||
schema: &PublicSchema,
|
||||
) -> Option<Vec<Vec<u8>>> {
|
||||
let attribute_values = match map_group_field(attribute, schema) {
|
||||
let attribute = AttributeName::from(attribute);
|
||||
let attribute_values = match map_group_field(&attribute, schema) {
|
||||
GroupFieldType::ObjectClass => {
|
||||
let mut classes = vec![b"groupOfUniqueNames".to_vec()];
|
||||
classes.extend(
|
||||
@@ -71,12 +72,12 @@ pub fn get_group_attribute(
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
if ignored_group_attributes.contains(attribute) {
|
||||
if ignored_group_attributes.contains(&attribute) {
|
||||
return None;
|
||||
}
|
||||
get_custom_attribute::<SchemaGroupAttributeExtractor>(
|
||||
&group.attributes,
|
||||
attribute,
|
||||
&attribute,
|
||||
schema,
|
||||
).or_else(||{warn!(
|
||||
r#"Ignoring unrecognized group attribute: {}\n\
|
||||
@@ -102,42 +103,33 @@ const ALL_GROUP_ATTRIBUTE_KEYS: &[&str] = &[
|
||||
"entryuuid",
|
||||
];
|
||||
|
||||
fn expand_group_attribute_wildcards(attributes: &[String]) -> ExpandedAttributes {
|
||||
fn expand_group_attribute_wildcards(attributes: &[String]) -> Vec<&str> {
|
||||
expand_attribute_wildcards(attributes, ALL_GROUP_ATTRIBUTE_KEYS)
|
||||
}
|
||||
|
||||
fn make_ldap_search_group_result_entry(
|
||||
group: Group,
|
||||
base_dn_str: &str,
|
||||
mut expanded_attributes: ExpandedAttributes,
|
||||
expanded_attributes: &[&str],
|
||||
user_filter: &Option<UserId>,
|
||||
ignored_group_attributes: &[AttributeName],
|
||||
schema: &PublicSchema,
|
||||
) -> LdapSearchResultEntry {
|
||||
if expanded_attributes.include_custom_attributes {
|
||||
expanded_attributes.attribute_keys.extend(
|
||||
group
|
||||
.attributes
|
||||
.iter()
|
||||
.map(|a| (a.name.clone(), a.name.to_string())),
|
||||
);
|
||||
}
|
||||
LdapSearchResultEntry {
|
||||
dn: format!("cn={},ou=groups,{}", group.display_name, base_dn_str),
|
||||
attributes: expanded_attributes
|
||||
.attribute_keys
|
||||
.into_iter()
|
||||
.filter_map(|(attribute, name)| {
|
||||
.iter()
|
||||
.filter_map(|a| {
|
||||
let values = get_group_attribute(
|
||||
&group,
|
||||
base_dn_str,
|
||||
&attribute,
|
||||
a,
|
||||
user_filter,
|
||||
ignored_group_attributes,
|
||||
schema,
|
||||
)?;
|
||||
Some(LdapPartialAttribute {
|
||||
atype: name,
|
||||
atype: a.to_string(),
|
||||
vals: values,
|
||||
})
|
||||
})
|
||||
@@ -150,13 +142,13 @@ fn get_group_attribute_equality_filter(
|
||||
typ: AttributeType,
|
||||
is_list: bool,
|
||||
value: &str,
|
||||
) -> GroupRequestFilter {
|
||||
) -> LdapResult<GroupRequestFilter> {
|
||||
deserialize_attribute_value(&[value.to_owned()], typ, is_list)
|
||||
.map(|v| GroupRequestFilter::AttributeEquality(field.clone(), v))
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("Invalid value for attribute {}: {}", field, e);
|
||||
GroupRequestFilter::from(false)
|
||||
.map_err(|e| LdapError {
|
||||
code: LdapResultCode::Other,
|
||||
message: format!("Invalid value for attribute {}: {}", field, e),
|
||||
})
|
||||
.map(|v| GroupRequestFilter::AttributeEquality(field.clone(), v))
|
||||
}
|
||||
|
||||
fn convert_group_filter(
|
||||
@@ -171,22 +163,20 @@ fn convert_group_filter(
|
||||
let value = value.to_ascii_lowercase();
|
||||
match map_group_field(&field, schema) {
|
||||
GroupFieldType::DisplayName => Ok(GroupRequestFilter::DisplayName(value.into())),
|
||||
GroupFieldType::Uuid => Uuid::try_from(value.as_str())
|
||||
.map(GroupRequestFilter::Uuid)
|
||||
.map_err(|e| LdapError {
|
||||
code: LdapResultCode::Other,
|
||||
GroupFieldType::Uuid => Ok(GroupRequestFilter::Uuid(
|
||||
Uuid::try_from(value.as_str()).map_err(|e| LdapError {
|
||||
code: LdapResultCode::InappropriateMatching,
|
||||
message: format!("Invalid UUID: {:#}", e),
|
||||
}),
|
||||
GroupFieldType::Member => Ok(get_user_id_from_distinguished_name_or_plain_name(
|
||||
&value,
|
||||
&ldap_info.base_dn,
|
||||
&ldap_info.base_dn_str,
|
||||
)
|
||||
.map(GroupRequestFilter::Member)
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("Invalid member filter on group: {}", e);
|
||||
GroupRequestFilter::from(false)
|
||||
})),
|
||||
})?,
|
||||
)),
|
||||
GroupFieldType::Member => {
|
||||
let user_name = get_user_id_from_distinguished_name(
|
||||
&value,
|
||||
&ldap_info.base_dn,
|
||||
&ldap_info.base_dn_str,
|
||||
)?;
|
||||
Ok(GroupRequestFilter::Member(user_name))
|
||||
}
|
||||
GroupFieldType::ObjectClass => Ok(GroupRequestFilter::from(
|
||||
matches!(value.as_str(), "groupofuniquenames" | "groupofnames")
|
||||
|| schema
|
||||
@@ -195,7 +185,7 @@ fn convert_group_filter(
|
||||
.contains(&LdapObjectClass::from(value)),
|
||||
)),
|
||||
GroupFieldType::Dn | GroupFieldType::EntryDn => {
|
||||
Ok(get_group_id_from_distinguished_name_or_plain_name(
|
||||
Ok(get_group_id_from_distinguished_name(
|
||||
value.as_str(),
|
||||
&ldap_info.base_dn,
|
||||
&ldap_info.base_dn_str,
|
||||
@@ -216,9 +206,9 @@ fn convert_group_filter(
|
||||
}
|
||||
Ok(GroupRequestFilter::from(false))
|
||||
}
|
||||
GroupFieldType::Attribute(field, typ, is_list) => Ok(
|
||||
get_group_attribute_equality_filter(&field, typ, is_list, &value),
|
||||
),
|
||||
GroupFieldType::Attribute(field, typ, is_list) => {
|
||||
get_group_attribute_equality_filter(&field, typ, is_list, &value)
|
||||
}
|
||||
GroupFieldType::CreationDate => Err(LdapError {
|
||||
code: LdapResultCode::UnwillingToPerform,
|
||||
message: "Creation date filter for groups not supported".to_owned(),
|
||||
@@ -234,13 +224,10 @@ fn convert_group_filter(
|
||||
LdapFilter::Not(filter) => Ok(GroupRequestFilter::Not(Box::new(rec(filter)?))),
|
||||
LdapFilter::Present(field) => {
|
||||
let field = AttributeName::from(field.as_str());
|
||||
Ok(match map_group_field(&field, schema) {
|
||||
GroupFieldType::Attribute(name, _, _) => {
|
||||
GroupRequestFilter::CustomAttributePresent(name)
|
||||
}
|
||||
GroupFieldType::NoMatch => GroupRequestFilter::from(false),
|
||||
_ => GroupRequestFilter::from(true),
|
||||
})
|
||||
Ok(GroupRequestFilter::from(!matches!(
|
||||
map_group_field(&field, schema),
|
||||
GroupFieldType::NoMatch
|
||||
)))
|
||||
}
|
||||
LdapFilter::Substring(field, substring_filter) => {
|
||||
let field = AttributeName::from(field.as_str());
|
||||
@@ -301,7 +288,7 @@ pub fn convert_groups_to_ldap_op<'a>(
|
||||
LdapOp::SearchResultEntry(make_ldap_search_group_result_entry(
|
||||
g,
|
||||
&ldap_info.base_dn_str,
|
||||
expanded_attributes.clone().unwrap(),
|
||||
expanded_attributes.as_ref().unwrap(),
|
||||
user_filter,
|
||||
&ldap_info.ignored_group_attributes,
|
||||
schema,
|
||||
|
||||
@@ -10,10 +10,8 @@ use crate::domain::{
|
||||
ldap::{
|
||||
error::{LdapError, LdapResult},
|
||||
utils::{
|
||||
expand_attribute_wildcards, get_custom_attribute,
|
||||
get_group_id_from_distinguished_name_or_plain_name,
|
||||
get_user_id_from_distinguished_name_or_plain_name, map_user_field, ExpandedAttributes,
|
||||
LdapInfo, UserFieldType,
|
||||
expand_attribute_wildcards, get_custom_attribute, get_group_id_from_distinguished_name,
|
||||
get_user_id_from_distinguished_name, map_user_field, LdapInfo, UserFieldType,
|
||||
},
|
||||
},
|
||||
schema::{PublicSchema, SchemaUserAttributeExtractor},
|
||||
@@ -25,13 +23,14 @@ use crate::domain::{
|
||||
|
||||
pub fn get_user_attribute(
|
||||
user: &User,
|
||||
attribute: &AttributeName,
|
||||
attribute: &str,
|
||||
base_dn_str: &str,
|
||||
groups: Option<&[GroupDetails]>,
|
||||
ignored_user_attributes: &[AttributeName],
|
||||
schema: &PublicSchema,
|
||||
) -> Option<Vec<Vec<u8>>> {
|
||||
let attribute_values = match map_user_field(attribute, schema) {
|
||||
let attribute = AttributeName::from(attribute);
|
||||
let attribute_values = match map_user_field(&attribute, schema) {
|
||||
UserFieldType::ObjectClass => {
|
||||
let mut classes = vec![
|
||||
b"inetOrgPerson".to_vec(),
|
||||
@@ -92,12 +91,12 @@ pub fn get_user_attribute(
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
if ignored_user_attributes.contains(attribute) {
|
||||
if ignored_user_attributes.contains(&attribute) {
|
||||
return None;
|
||||
}
|
||||
get_custom_attribute::<SchemaUserAttributeExtractor>(
|
||||
&user.attributes,
|
||||
attribute,
|
||||
&attribute,
|
||||
schema,
|
||||
)
|
||||
.or_else(|| {
|
||||
@@ -133,34 +132,27 @@ const ALL_USER_ATTRIBUTE_KEYS: &[&str] = &[
|
||||
fn make_ldap_search_user_result_entry(
|
||||
user: User,
|
||||
base_dn_str: &str,
|
||||
mut expanded_attributes: ExpandedAttributes,
|
||||
expanded_attributes: &[&str],
|
||||
groups: Option<&[GroupDetails]>,
|
||||
ignored_user_attributes: &[AttributeName],
|
||||
schema: &PublicSchema,
|
||||
) -> LdapSearchResultEntry {
|
||||
if expanded_attributes.include_custom_attributes {
|
||||
expanded_attributes.attribute_keys.extend(
|
||||
user.attributes
|
||||
.iter()
|
||||
.map(|a| (a.name.clone(), a.name.to_string())),
|
||||
);
|
||||
}
|
||||
let dn = format!("uid={},ou=people,{}", user.user_id.as_str(), base_dn_str);
|
||||
LdapSearchResultEntry {
|
||||
dn: format!("uid={},ou=people,{}", user.user_id.as_str(), base_dn_str),
|
||||
dn,
|
||||
attributes: expanded_attributes
|
||||
.attribute_keys
|
||||
.into_iter()
|
||||
.filter_map(|(attribute, name)| {
|
||||
.iter()
|
||||
.filter_map(|a| {
|
||||
let values = get_user_attribute(
|
||||
&user,
|
||||
&attribute,
|
||||
a,
|
||||
base_dn_str,
|
||||
groups,
|
||||
ignored_user_attributes,
|
||||
schema,
|
||||
)?;
|
||||
Some(LdapPartialAttribute {
|
||||
atype: name,
|
||||
atype: a.to_string(),
|
||||
vals: values,
|
||||
})
|
||||
})
|
||||
@@ -173,13 +165,13 @@ fn get_user_attribute_equality_filter(
|
||||
typ: AttributeType,
|
||||
is_list: bool,
|
||||
value: &str,
|
||||
) -> UserRequestFilter {
|
||||
) -> LdapResult<UserRequestFilter> {
|
||||
deserialize_attribute_value(&[value.to_owned()], typ, is_list)
|
||||
.map(|v| UserRequestFilter::AttributeEquality(field.clone(), v))
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("Invalid value for attribute {}: {}", field, e);
|
||||
UserRequestFilter::from(false)
|
||||
.map_err(|e| LdapError {
|
||||
code: LdapResultCode::Other,
|
||||
message: format!("Invalid value for attribute {}: {}", field, e),
|
||||
})
|
||||
.map(|v| UserRequestFilter::AttributeEquality(field.clone(), v))
|
||||
}
|
||||
|
||||
fn convert_user_filter(
|
||||
@@ -203,14 +195,10 @@ fn convert_user_filter(
|
||||
UserFieldType::PrimaryField(UserColumn::UserId) => {
|
||||
Ok(UserRequestFilter::UserId(UserId::new(&value)))
|
||||
}
|
||||
UserFieldType::PrimaryField(UserColumn::Email) => Ok(UserRequestFilter::Equality(
|
||||
UserColumn::LowercaseEmail,
|
||||
value,
|
||||
)),
|
||||
UserFieldType::PrimaryField(field) => Ok(UserRequestFilter::Equality(field, value)),
|
||||
UserFieldType::Attribute(field, typ, is_list) => Ok(
|
||||
get_user_attribute_equality_filter(&field, typ, is_list, &value),
|
||||
),
|
||||
UserFieldType::Attribute(field, typ, is_list) => {
|
||||
get_user_attribute_equality_filter(&field, typ, is_list, &value)
|
||||
}
|
||||
UserFieldType::NoMatch => {
|
||||
if !ldap_info.ignored_user_attributes.contains(&field) {
|
||||
warn!(
|
||||
@@ -230,18 +218,15 @@ fn convert_user_filter(
|
||||
.extra_user_object_classes
|
||||
.contains(&LdapObjectClass::from(value)),
|
||||
)),
|
||||
UserFieldType::MemberOf => Ok(get_group_id_from_distinguished_name_or_plain_name(
|
||||
&value,
|
||||
&ldap_info.base_dn,
|
||||
&ldap_info.base_dn_str,
|
||||
)
|
||||
.map(UserRequestFilter::MemberOf)
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("Invalid memberOf filter: {}", e);
|
||||
UserRequestFilter::from(false)
|
||||
})),
|
||||
UserFieldType::MemberOf => Ok(UserRequestFilter::MemberOf(
|
||||
get_group_id_from_distinguished_name(
|
||||
&value,
|
||||
&ldap_info.base_dn,
|
||||
&ldap_info.base_dn_str,
|
||||
)?,
|
||||
)),
|
||||
UserFieldType::EntryDn | UserFieldType::Dn => {
|
||||
Ok(get_user_id_from_distinguished_name_or_plain_name(
|
||||
Ok(get_user_id_from_distinguished_name(
|
||||
value.as_str(),
|
||||
&ldap_info.base_dn,
|
||||
&ldap_info.base_dn_str,
|
||||
@@ -256,13 +241,13 @@ fn convert_user_filter(
|
||||
}
|
||||
LdapFilter::Present(field) => {
|
||||
let field = AttributeName::from(field.as_str());
|
||||
Ok(match map_user_field(&field, schema) {
|
||||
UserFieldType::Attribute(name, _, _) => {
|
||||
UserRequestFilter::CustomAttributePresent(name)
|
||||
}
|
||||
UserFieldType::NoMatch => UserRequestFilter::from(false),
|
||||
_ => UserRequestFilter::from(true),
|
||||
})
|
||||
// Check that it's a field we support.
|
||||
Ok(UserRequestFilter::from(
|
||||
field.as_str() == "objectclass"
|
||||
|| field.as_str() == "dn"
|
||||
|| field.as_str() == "distinguishedname"
|
||||
|| !matches!(map_user_field(&field, schema), UserFieldType::NoMatch),
|
||||
))
|
||||
}
|
||||
LdapFilter::Substring(field, substring_filter) => {
|
||||
let field = AttributeName::from(field.as_str());
|
||||
@@ -284,10 +269,6 @@ fn convert_user_filter(
|
||||
),
|
||||
}),
|
||||
UserFieldType::NoMatch => Ok(UserRequestFilter::from(false)),
|
||||
UserFieldType::PrimaryField(UserColumn::Email) => Ok(UserRequestFilter::SubString(
|
||||
UserColumn::LowercaseEmail,
|
||||
substring_filter.clone().into(),
|
||||
)),
|
||||
UserFieldType::PrimaryField(field) => Ok(UserRequestFilter::SubString(
|
||||
field,
|
||||
substring_filter.clone().into(),
|
||||
@@ -301,7 +282,7 @@ fn convert_user_filter(
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_user_attribute_wildcards(attributes: &[String]) -> ExpandedAttributes {
|
||||
fn expand_user_attribute_wildcards(attributes: &[String]) -> Vec<&str> {
|
||||
expand_attribute_wildcards(attributes, ALL_USER_ATTRIBUTE_KEYS)
|
||||
}
|
||||
|
||||
@@ -340,7 +321,7 @@ pub fn convert_users_to_ldap_op<'a>(
|
||||
LdapOp::SearchResultEntry(make_ldap_search_user_result_entry(
|
||||
u.user,
|
||||
&ldap_info.base_dn_str,
|
||||
expanded_attributes.clone().unwrap(),
|
||||
expanded_attributes.as_ref().unwrap(),
|
||||
u.groups.as_deref(),
|
||||
&ldap_info.ignored_user_attributes,
|
||||
schema,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use chrono::{NaiveDateTime, TimeZone};
|
||||
use itertools::Itertools;
|
||||
use ldap3_proto::{proto::LdapSubstringFilter, LdapResultCode};
|
||||
use tracing::{debug, instrument, warn};
|
||||
|
||||
@@ -110,67 +109,30 @@ pub fn get_group_id_from_distinguished_name(
|
||||
get_id_from_distinguished_name(dn, base_tree, base_dn_str, true).map(GroupName::from)
|
||||
}
|
||||
|
||||
fn looks_like_distinguished_name(dn: &str) -> bool {
|
||||
dn.contains('=') || dn.contains(',')
|
||||
}
|
||||
|
||||
pub fn get_user_id_from_distinguished_name_or_plain_name(
|
||||
dn: &str,
|
||||
base_tree: &[(String, String)],
|
||||
base_dn_str: &str,
|
||||
) -> LdapResult<UserId> {
|
||||
if !looks_like_distinguished_name(dn) {
|
||||
Ok(UserId::from(dn))
|
||||
} else {
|
||||
get_user_id_from_distinguished_name(dn, base_tree, base_dn_str)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_group_id_from_distinguished_name_or_plain_name(
|
||||
dn: &str,
|
||||
base_tree: &[(String, String)],
|
||||
base_dn_str: &str,
|
||||
) -> LdapResult<GroupName> {
|
||||
if !looks_like_distinguished_name(dn) {
|
||||
Ok(GroupName::from(dn))
|
||||
} else {
|
||||
get_group_id_from_distinguished_name(dn, base_tree, base_dn_str)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ExpandedAttributes {
|
||||
// Lowercase name to original name.
|
||||
pub attribute_keys: BTreeMap<AttributeName, String>,
|
||||
pub include_custom_attributes: bool,
|
||||
}
|
||||
|
||||
#[instrument(skip(all_attribute_keys), level = "debug")]
|
||||
pub fn expand_attribute_wildcards(
|
||||
ldap_attributes: &[String],
|
||||
all_attribute_keys: &[&'static str],
|
||||
) -> ExpandedAttributes {
|
||||
let mut include_custom_attributes = false;
|
||||
let mut attributes_out: BTreeMap<_, _> = ldap_attributes
|
||||
.iter()
|
||||
.filter(|&s| s != "*" && s != "+" && s != "1.1")
|
||||
.map(|s| (AttributeName::from(s), s.to_string()))
|
||||
.collect();
|
||||
attributes_out.extend(
|
||||
pub fn expand_attribute_wildcards<'a>(
|
||||
ldap_attributes: &'a [String],
|
||||
all_attribute_keys: &'a [&'static str],
|
||||
) -> Vec<&'a str> {
|
||||
let extra_attributes =
|
||||
if ldap_attributes.iter().any(|x| x == "*") || ldap_attributes.is_empty() {
|
||||
include_custom_attributes = true;
|
||||
all_attribute_keys
|
||||
} else {
|
||||
&[]
|
||||
}
|
||||
.iter()
|
||||
.map(|&s| (AttributeName::from(s), s.to_string())),
|
||||
);
|
||||
debug!(?attributes_out);
|
||||
ExpandedAttributes {
|
||||
attribute_keys: attributes_out,
|
||||
include_custom_attributes,
|
||||
}
|
||||
.copied();
|
||||
let attributes_out = ldap_attributes
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.filter(|&s| s != "*" && s != "+" && s != "1.1");
|
||||
|
||||
// Deduplicate, preserving order
|
||||
let resolved_attributes = itertools::chain(attributes_out, extra_attributes)
|
||||
.unique_by(|a| a.to_ascii_lowercase())
|
||||
.collect_vec();
|
||||
debug!(?resolved_attributes);
|
||||
resolved_attributes
|
||||
}
|
||||
|
||||
pub fn is_subtree(subtree: &[(String, String)], base_tree: &[(String, String)]) -> bool {
|
||||
|
||||
@@ -50,7 +50,6 @@ impl From<Model> for AttributeSchema {
|
||||
is_visible: value.is_group_visible,
|
||||
is_editable: value.is_group_editable,
|
||||
is_hardcoded: value.is_hardcoded,
|
||||
is_readonly: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,6 @@ impl From<Model> for AttributeSchema {
|
||||
is_visible: value.is_user_visible,
|
||||
is_editable: value.is_user_editable,
|
||||
is_hardcoded: value.is_hardcoded,
|
||||
is_readonly: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,6 @@ impl From<Schema> for PublicSchema {
|
||||
is_visible: true,
|
||||
is_editable: false,
|
||||
is_hardcoded: true,
|
||||
is_readonly: true,
|
||||
},
|
||||
AttributeSchema {
|
||||
name: "creation_date".into(),
|
||||
@@ -52,7 +51,6 @@ impl From<Schema> for PublicSchema {
|
||||
is_visible: true,
|
||||
is_editable: false,
|
||||
is_hardcoded: true,
|
||||
is_readonly: true,
|
||||
},
|
||||
AttributeSchema {
|
||||
name: "mail".into(),
|
||||
@@ -61,7 +59,6 @@ impl From<Schema> for PublicSchema {
|
||||
is_visible: true,
|
||||
is_editable: true,
|
||||
is_hardcoded: true,
|
||||
is_readonly: false,
|
||||
},
|
||||
AttributeSchema {
|
||||
name: "uuid".into(),
|
||||
@@ -70,7 +67,6 @@ impl From<Schema> for PublicSchema {
|
||||
is_visible: true,
|
||||
is_editable: false,
|
||||
is_hardcoded: true,
|
||||
is_readonly: true,
|
||||
},
|
||||
AttributeSchema {
|
||||
name: "display_name".into(),
|
||||
@@ -79,7 +75,6 @@ impl From<Schema> for PublicSchema {
|
||||
is_visible: true,
|
||||
is_editable: true,
|
||||
is_hardcoded: true,
|
||||
is_readonly: false,
|
||||
},
|
||||
]);
|
||||
schema
|
||||
@@ -94,7 +89,6 @@ impl From<Schema> for PublicSchema {
|
||||
is_visible: true,
|
||||
is_editable: false,
|
||||
is_hardcoded: true,
|
||||
is_readonly: true,
|
||||
},
|
||||
AttributeSchema {
|
||||
name: "creation_date".into(),
|
||||
@@ -103,7 +97,6 @@ impl From<Schema> for PublicSchema {
|
||||
is_visible: true,
|
||||
is_editable: false,
|
||||
is_hardcoded: true,
|
||||
is_readonly: true,
|
||||
},
|
||||
AttributeSchema {
|
||||
name: "uuid".into(),
|
||||
@@ -112,7 +105,6 @@ impl From<Schema> for PublicSchema {
|
||||
is_visible: true,
|
||||
is_editable: false,
|
||||
is_hardcoded: true,
|
||||
is_readonly: true,
|
||||
},
|
||||
AttributeSchema {
|
||||
name: "display_name".into(),
|
||||
@@ -121,7 +113,6 @@ impl From<Schema> for PublicSchema {
|
||||
is_visible: true,
|
||||
is_editable: true,
|
||||
is_hardcoded: true,
|
||||
is_readonly: false,
|
||||
},
|
||||
]);
|
||||
schema
|
||||
|
||||
@@ -16,18 +16,14 @@ use sea_orm::{
|
||||
};
|
||||
use tracing::instrument;
|
||||
|
||||
fn attribute_condition(name: AttributeName, value: Option<Serialized>) -> Cond {
|
||||
fn attribute_condition(name: AttributeName, value: Serialized) -> Cond {
|
||||
Expr::in_subquery(
|
||||
Expr::col(GroupColumn::GroupId.as_column_ref()),
|
||||
model::GroupAttributes::find()
|
||||
.select_only()
|
||||
.column(model::GroupAttributesColumn::GroupId)
|
||||
.filter(model::GroupAttributesColumn::AttributeName.eq(name))
|
||||
.filter(
|
||||
value
|
||||
.map(|value| model::GroupAttributesColumn::Value.eq(value))
|
||||
.unwrap_or_else(|| SimpleExpr::Value(true.into())),
|
||||
)
|
||||
.filter(model::GroupAttributesColumn::Value.eq(value))
|
||||
.into_query(),
|
||||
)
|
||||
.into_condition()
|
||||
@@ -71,12 +67,11 @@ fn get_group_filter_expr(filter: GroupRequestFilter) -> Cond {
|
||||
.into_condition(),
|
||||
DisplayNameSubString(filter) => SimpleExpr::FunctionCall(Func::lower(Expr::col((
|
||||
group_table,
|
||||
GroupColumn::LowercaseDisplayName,
|
||||
GroupColumn::DisplayName,
|
||||
))))
|
||||
.like(filter.to_sql_filter())
|
||||
.into_condition(),
|
||||
AttributeEquality(name, value) => attribute_condition(name, Some(value)),
|
||||
CustomAttributePresent(name) => attribute_condition(name, None),
|
||||
AttributeEquality(name, value) => attribute_condition(name, value),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -611,25 +606,4 @@ mod tests {
|
||||
let details = fixture.handler.get_group_details(group_id).await.unwrap();
|
||||
assert_eq!(details.attributes, Vec::new());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_group_duplicate_name() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture
|
||||
.handler
|
||||
.create_group(CreateGroupRequest {
|
||||
display_name: "New Group".into(),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
fixture
|
||||
.handler
|
||||
.create_group(CreateGroupRequest {
|
||||
display_name: "neW group".into(),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1090,47 +1090,6 @@ async fn migrate_to_v9(transaction: DatabaseTransaction) -> Result<DatabaseTrans
|
||||
Ok(transaction)
|
||||
}
|
||||
|
||||
async fn migrate_to_v10(transaction: DatabaseTransaction) -> Result<DatabaseTransaction, DbErr> {
|
||||
let builder = transaction.get_database_backend();
|
||||
if let Err(e) = transaction
|
||||
.execute(
|
||||
builder.build(
|
||||
Index::create()
|
||||
.if_not_exists()
|
||||
.name("unique-group-id")
|
||||
.table(Groups::Table)
|
||||
.col(Groups::LowercaseDisplayName)
|
||||
.unique(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!(
|
||||
r#"Found several groups with the same (case-insensitive) display name. Please delete the duplicates"#
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
if let Err(e) = transaction
|
||||
.execute(
|
||||
builder.build(
|
||||
Index::create()
|
||||
.if_not_exists()
|
||||
.name("unique-user-lower-email")
|
||||
.table(Users::Table)
|
||||
.col(Users::LowercaseEmail)
|
||||
.unique(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!(
|
||||
r#"Found several users with the same (case-insensitive) email. Please delete the duplicates"#
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
Ok(transaction)
|
||||
}
|
||||
|
||||
// This is needed to make an array of async functions.
|
||||
macro_rules! to_sync {
|
||||
($l:ident) => {
|
||||
@@ -1160,7 +1119,6 @@ pub async fn migrate_from_version(
|
||||
to_sync!(migrate_to_v7),
|
||||
to_sync!(migrate_to_v8),
|
||||
to_sync!(migrate_to_v9),
|
||||
to_sync!(migrate_to_v10),
|
||||
];
|
||||
assert_eq!(migrations.len(), (LAST_SCHEMA_VERSION.0 - 1) as usize);
|
||||
for migration in 2..=last_version.0 {
|
||||
|
||||
@@ -11,7 +11,7 @@ use base64::Engine;
|
||||
use lldap_auth::opaque;
|
||||
use sea_orm::{ActiveModelTrait, ActiveValue, EntityTrait, QuerySelect};
|
||||
use secstr::SecUtf8;
|
||||
use tracing::{debug, info, instrument, warn};
|
||||
use tracing::{debug, instrument};
|
||||
|
||||
type SqlOpaqueHandler = SqlBackendHandler;
|
||||
|
||||
@@ -70,15 +70,14 @@ impl LoginHandler for SqlBackendHandler {
|
||||
.get_password_file_for_user(request.name.clone())
|
||||
.await?
|
||||
{
|
||||
info!(r#"Login attempt for "{}""#, &request.name);
|
||||
if passwords_match(
|
||||
if let Err(e) = passwords_match(
|
||||
&password_hash,
|
||||
&request.password,
|
||||
self.config.get_server_setup(),
|
||||
&request.name,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
) {
|
||||
debug!(r#"Invalid password for "{}": {}"#, &request.name, e);
|
||||
} else {
|
||||
return Ok(());
|
||||
}
|
||||
} else {
|
||||
@@ -88,7 +87,7 @@ impl LoginHandler for SqlBackendHandler {
|
||||
);
|
||||
}
|
||||
Err(DomainError::AuthenticationError(format!(
|
||||
r#"for user "{}""#,
|
||||
" for user '{}'",
|
||||
request.name
|
||||
)))
|
||||
}
|
||||
@@ -102,7 +101,6 @@ impl OpaqueHandler for SqlOpaqueHandler {
|
||||
request: login::ClientLoginStartRequest,
|
||||
) -> Result<login::ServerLoginStartResponse> {
|
||||
let user_id = request.username;
|
||||
info!(r#"OPAQUE login attempt for "{}""#, &user_id);
|
||||
let maybe_password_file = self
|
||||
.get_password_file_for_user(user_id.clone())
|
||||
.await?
|
||||
@@ -147,16 +145,9 @@ impl OpaqueHandler for SqlOpaqueHandler {
|
||||
)?)?;
|
||||
// Finish the login: this makes sure the client data is correct, and gives a session key we
|
||||
// don't need.
|
||||
match opaque::server::login::finish_login(server_login, request.credential_finalization) {
|
||||
Ok(session) => {
|
||||
info!(r#"OPAQUE login successful for "{}""#, &username);
|
||||
let _ = session.session_key;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(r#"OPAQUE login attempt failed for "{}""#, &username);
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
let _session_key =
|
||||
opaque::server::login::finish_login(server_login, request.credential_finalization)?
|
||||
.session_key;
|
||||
|
||||
Ok(username)
|
||||
}
|
||||
@@ -198,12 +189,11 @@ impl OpaqueHandler for SqlOpaqueHandler {
|
||||
opaque::server::registration::get_password_file(request.registration_upload);
|
||||
// Set the user password to the new password.
|
||||
let user_update = model::users::ActiveModel {
|
||||
user_id: ActiveValue::Set(username.clone()),
|
||||
user_id: ActiveValue::Set(username),
|
||||
password_hash: ActiveValue::Set(Some(password_file.serialize())),
|
||||
..Default::default()
|
||||
};
|
||||
user_update.update(&self.sql_pool).await?;
|
||||
info!(r#"Successfully (re)set password for "{}""#, &username);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,9 +175,7 @@ impl SqlBackendHandler {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::{
|
||||
handler::{AttributeList, UpdateUserRequest, UserBackendHandler, UserRequestFilter},
|
||||
sql_backend_handler::tests::*,
|
||||
types::{AttributeType, AttributeValue, Serialized},
|
||||
handler::AttributeList, sql_backend_handler::tests::*, types::AttributeType,
|
||||
};
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
@@ -196,7 +194,6 @@ mod tests {
|
||||
is_visible: true,
|
||||
is_editable: true,
|
||||
is_hardcoded: true,
|
||||
is_readonly: false,
|
||||
},
|
||||
AttributeSchema {
|
||||
name: "first_name".into(),
|
||||
@@ -205,7 +202,6 @@ mod tests {
|
||||
is_visible: true,
|
||||
is_editable: true,
|
||||
is_hardcoded: true,
|
||||
is_readonly: false,
|
||||
},
|
||||
AttributeSchema {
|
||||
name: "last_name".into(),
|
||||
@@ -214,7 +210,6 @@ mod tests {
|
||||
is_visible: true,
|
||||
is_editable: true,
|
||||
is_hardcoded: true,
|
||||
is_readonly: false,
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -249,7 +244,6 @@ mod tests {
|
||||
is_visible: false,
|
||||
is_editable: false,
|
||||
is_hardcoded: false,
|
||||
is_readonly: false,
|
||||
};
|
||||
assert!(fixture
|
||||
.handler
|
||||
@@ -274,43 +268,6 @@ mod tests {
|
||||
.contains(&expected_value));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_user_attribute_present_filter() {
|
||||
let fixture = TestFixture::new().await;
|
||||
let new_attribute = CreateAttributeRequest {
|
||||
name: "new_attribute".into(),
|
||||
attribute_type: AttributeType::Integer,
|
||||
is_list: true,
|
||||
is_visible: false,
|
||||
is_editable: false,
|
||||
};
|
||||
fixture
|
||||
.handler
|
||||
.add_user_attribute(new_attribute)
|
||||
.await
|
||||
.unwrap();
|
||||
fixture
|
||||
.handler
|
||||
.update_user(UpdateUserRequest {
|
||||
user_id: "bob".into(),
|
||||
insert_attributes: vec![AttributeValue {
|
||||
name: "new_attribute".into(),
|
||||
value: Serialized::from(&3),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let users = get_user_names(
|
||||
&fixture.handler,
|
||||
Some(UserRequestFilter::CustomAttributePresent(
|
||||
"new_attribute".into(),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(users, vec!["bob"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_group_attribute_add_and_delete() {
|
||||
let fixture = TestFixture::new().await;
|
||||
@@ -333,7 +290,6 @@ mod tests {
|
||||
is_visible: true,
|
||||
is_editable: false,
|
||||
is_hardcoded: false,
|
||||
is_readonly: false,
|
||||
};
|
||||
assert!(fixture
|
||||
.handler
|
||||
|
||||
@@ -11,7 +11,7 @@ pub type DbConnection = sea_orm::DatabaseConnection;
|
||||
#[derive(Copy, PartialEq, Eq, Debug, Clone, PartialOrd, Ord, DeriveValueType)]
|
||||
pub struct SchemaVersion(pub i16);
|
||||
|
||||
pub const LAST_SCHEMA_VERSION: SchemaVersion = SchemaVersion(10);
|
||||
pub const LAST_SCHEMA_VERSION: SchemaVersion = SchemaVersion(9);
|
||||
|
||||
#[derive(Copy, PartialEq, Eq, Debug, Clone, PartialOrd, Ord)]
|
||||
pub struct PrivateKeyHash(pub [u8; 32]);
|
||||
|
||||
@@ -22,31 +22,14 @@ use sea_orm::{
|
||||
use std::collections::HashSet;
|
||||
use tracing::instrument;
|
||||
|
||||
fn attribute_condition(name: AttributeName, value: Option<Serialized>) -> Cond {
|
||||
fn attribute_condition(name: AttributeName, value: Serialized) -> Cond {
|
||||
Expr::in_subquery(
|
||||
Expr::col(UserColumn::UserId.as_column_ref()),
|
||||
model::UserAttributes::find()
|
||||
.select_only()
|
||||
.column(model::UserAttributesColumn::UserId)
|
||||
.filter(model::UserAttributesColumn::AttributeName.eq(name))
|
||||
.filter(
|
||||
value
|
||||
.map(|value| model::UserAttributesColumn::Value.eq(value))
|
||||
.unwrap_or_else(|| SimpleExpr::Constant(true.into())),
|
||||
)
|
||||
.into_query(),
|
||||
)
|
||||
.into_condition()
|
||||
}
|
||||
|
||||
fn user_id_subcondition(filter: Cond) -> Cond {
|
||||
Expr::in_subquery(
|
||||
Expr::col(UserColumn::UserId.as_column_ref()),
|
||||
model::User::find()
|
||||
.find_also_linked(model::memberships::UserToGroup)
|
||||
.select_only()
|
||||
.column(UserColumn::UserId)
|
||||
.filter(filter)
|
||||
.filter(model::UserAttributesColumn::Value.eq(value))
|
||||
.into_query(),
|
||||
)
|
||||
.into_condition()
|
||||
@@ -83,17 +66,13 @@ fn get_user_filter_expr(filter: UserRequestFilter) -> Cond {
|
||||
ColumnTrait::eq(&column, value).into_condition()
|
||||
}
|
||||
}
|
||||
AttributeEquality(column, value) => attribute_condition(column, Some(value)),
|
||||
MemberOf(group) => user_id_subcondition(
|
||||
Expr::col((group_table, GroupColumn::LowercaseDisplayName))
|
||||
.eq(group.as_str().to_lowercase())
|
||||
.into_condition(),
|
||||
),
|
||||
MemberOfId(group_id) => user_id_subcondition(
|
||||
Expr::col((group_table, GroupColumn::GroupId))
|
||||
.eq(group_id)
|
||||
.into_condition(),
|
||||
),
|
||||
AttributeEquality(column, value) => attribute_condition(column, value),
|
||||
MemberOf(group) => Expr::col((group_table, GroupColumn::DisplayName))
|
||||
.eq(group)
|
||||
.into_condition(),
|
||||
MemberOfId(group_id) => Expr::col((group_table, GroupColumn::GroupId))
|
||||
.eq(group_id)
|
||||
.into_condition(),
|
||||
UserIdSubString(filter) => UserColumn::UserId
|
||||
.like(filter.to_sql_filter())
|
||||
.into_condition(),
|
||||
@@ -102,7 +81,6 @@ fn get_user_filter_expr(filter: UserRequestFilter) -> Cond {
|
||||
.like(filter.to_sql_filter())
|
||||
.into_condition()
|
||||
}
|
||||
CustomAttributePresent(name) => attribute_condition(name, None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +105,18 @@ impl UserListerBackendHandler for SqlBackendHandler {
|
||||
_get_groups: bool,
|
||||
) -> Result<Vec<UserAndGroups>> {
|
||||
let filters = filters
|
||||
.map(get_user_filter_expr)
|
||||
.map(|f| {
|
||||
UserColumn::UserId
|
||||
.in_subquery(
|
||||
model::User::find()
|
||||
.find_also_linked(model::memberships::UserToGroup)
|
||||
.select_only()
|
||||
.column(UserColumn::UserId)
|
||||
.filter(get_user_filter_expr(f))
|
||||
.into_query(),
|
||||
)
|
||||
.into_condition()
|
||||
})
|
||||
.unwrap_or_else(|| SimpleExpr::Value(true.into()).into_condition());
|
||||
let mut users: Vec<_> = model::User::find()
|
||||
.filter(filters.clone())
|
||||
@@ -553,12 +542,6 @@ mod tests {
|
||||
)
|
||||
.await;
|
||||
assert_eq!(users, vec!["bob", "patrick"]);
|
||||
let users = get_user_names(
|
||||
&fixture.handler,
|
||||
Some(UserRequestFilter::MemberOf("best grOUp".into())),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(users, vec!["bob", "patrick"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -586,34 +569,6 @@ mod tests {
|
||||
assert_eq!(users, vec!["bob", "patrick"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_users_filter_several_member_of() {
|
||||
let fixture = TestFixture::new().await;
|
||||
let users = get_user_names(
|
||||
&fixture.handler,
|
||||
Some(UserRequestFilter::And(vec![
|
||||
UserRequestFilter::MemberOf("Best Group".into()),
|
||||
UserRequestFilter::MemberOf("Worst Group".into()),
|
||||
])),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(users, vec!["patrick"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_users_filter_several_member_of_id() {
|
||||
let fixture = TestFixture::new().await;
|
||||
let users = get_user_names(
|
||||
&fixture.handler,
|
||||
Some(UserRequestFilter::And(vec![
|
||||
UserRequestFilter::MemberOfId(fixture.groups[0]),
|
||||
UserRequestFilter::MemberOfId(fixture.groups[1]),
|
||||
])),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(users, vec!["patrick"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[should_panic]
|
||||
async fn test_list_users_invalid_userid_filter() {
|
||||
@@ -1166,29 +1121,4 @@ mod tests {
|
||||
.await
|
||||
.expect_err("Should have failed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_user_duplicate_email() {
|
||||
let fixture = TestFixture::new().await;
|
||||
|
||||
fixture
|
||||
.handler
|
||||
.create_user(CreateUserRequest {
|
||||
user_id: UserId::new("james"),
|
||||
email: "email".into(),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
fixture
|
||||
.handler
|
||||
.create_user(CreateUserRequest {
|
||||
user_id: UserId::new("john"),
|
||||
email: "eMail".into(),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,22 +14,9 @@ use strum::{EnumString, IntoStaticStr};
|
||||
pub use super::model::UserColumn;
|
||||
pub use lldap_auth::types::UserId;
|
||||
|
||||
#[derive(
|
||||
PartialEq,
|
||||
Hash,
|
||||
Eq,
|
||||
Clone,
|
||||
Default,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
DeriveValueType,
|
||||
derive_more::Debug,
|
||||
derive_more::Display,
|
||||
)]
|
||||
#[derive(PartialEq, Hash, Eq, Clone, Debug, Default, Serialize, Deserialize, DeriveValueType)]
|
||||
#[serde(try_from = "&str")]
|
||||
#[sea_orm(column_type = "String(Some(36))")]
|
||||
#[debug(r#""{_0}""#)]
|
||||
#[display("{_0}")]
|
||||
pub struct Uuid(String);
|
||||
|
||||
impl Uuid {
|
||||
@@ -66,6 +53,12 @@ impl<'a> std::convert::TryFrom<&'a str> for Uuid {
|
||||
}
|
||||
}
|
||||
|
||||
impl std::string::ToString for Uuid {
|
||||
fn to_string(&self) -> String {
|
||||
self.0.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[macro_export]
|
||||
macro_rules! uuid {
|
||||
@@ -151,17 +144,7 @@ fn compare_str_case_insensitive(s1: &str, s2: &str) -> Ordering {
|
||||
|
||||
macro_rules! make_case_insensitive_comparable_string {
|
||||
($c:ident) => {
|
||||
#[derive(
|
||||
Clone,
|
||||
Default,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
DeriveValueType,
|
||||
derive_more::Debug,
|
||||
derive_more::Display,
|
||||
)]
|
||||
#[debug(r#""{_0}""#)]
|
||||
#[display("{_0}")]
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, DeriveValueType)]
|
||||
pub struct $c(String);
|
||||
|
||||
impl PartialEq for $c {
|
||||
@@ -216,6 +199,12 @@ macro_rules! make_case_insensitive_comparable_string {
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for $c {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&$c> for Value {
|
||||
fn from(user_id: &$c) -> Self {
|
||||
user_id.as_str().into()
|
||||
@@ -440,6 +429,7 @@ impl Default for User {
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug,
|
||||
Copy,
|
||||
Clone,
|
||||
PartialEq,
|
||||
@@ -450,9 +440,7 @@ impl Default for User {
|
||||
Serialize,
|
||||
Deserialize,
|
||||
DeriveValueType,
|
||||
derive_more::Debug,
|
||||
)]
|
||||
#[debug("{_0}")]
|
||||
pub struct GroupId(pub i32);
|
||||
|
||||
impl TryFromU64 for GroupId {
|
||||
@@ -571,8 +559,14 @@ mod tests {
|
||||
#[test]
|
||||
fn test_serialized_i64_len() {
|
||||
assert_eq!(SERIALIZED_I64_LEN, Serialized::from(&0i64).0.len());
|
||||
assert_eq!(SERIALIZED_I64_LEN, Serialized::from(&i64::MAX).0.len());
|
||||
assert_eq!(SERIALIZED_I64_LEN, Serialized::from(&i64::MIN).0.len());
|
||||
assert_eq!(
|
||||
SERIALIZED_I64_LEN,
|
||||
Serialized::from(&i64::max_value()).0.len()
|
||||
);
|
||||
assert_eq!(
|
||||
SERIALIZED_I64_LEN,
|
||||
Serialized::from(&i64::min_value()).0.len()
|
||||
);
|
||||
assert_eq!(SERIALIZED_I64_LEN, Serialized::from(&-1000i64).0.len());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,14 +401,11 @@ async fn opaque_login_finish<Backend>(
|
||||
where
|
||||
Backend: TcpBackendHandler + BackendHandler + OpaqueHandler + 'static,
|
||||
{
|
||||
match data
|
||||
let name = data
|
||||
.get_opaque_handler()
|
||||
.login_finish(request.into_inner())
|
||||
.await
|
||||
{
|
||||
Ok(name) => get_login_successful_response(&data, &name).await,
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
.await?;
|
||||
get_login_successful_response(&data, &name).await
|
||||
}
|
||||
|
||||
async fn opaque_login_finish_handler<Backend>(
|
||||
@@ -452,6 +449,31 @@ where
|
||||
.unwrap_or_else(error_to_http_response)
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "debug", fields(name = %request.name))]
|
||||
async fn post_authorize<Backend>(
|
||||
data: web::Data<AppState<Backend>>,
|
||||
request: web::Json<BindRequest>,
|
||||
) -> TcpResult<HttpResponse>
|
||||
where
|
||||
Backend: TcpBackendHandler + BackendHandler + LoginHandler + 'static,
|
||||
{
|
||||
let name = request.name.clone();
|
||||
data.get_login_handler().bind(request.into_inner()).await?;
|
||||
get_login_successful_response(&data, &name).await
|
||||
}
|
||||
|
||||
async fn post_authorize_handler<Backend>(
|
||||
data: web::Data<AppState<Backend>>,
|
||||
request: web::Json<BindRequest>,
|
||||
) -> HttpResponse
|
||||
where
|
||||
Backend: TcpBackendHandler + BackendHandler + LoginHandler + 'static,
|
||||
{
|
||||
post_authorize(data, request)
|
||||
.await
|
||||
.unwrap_or_else(error_to_http_response)
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "debug")]
|
||||
async fn opaque_register_start<Backend>(
|
||||
request: actix_web::HttpRequest,
|
||||
@@ -626,28 +648,32 @@ pub fn configure_server<Backend>(cfg: &mut web::ServiceConfig, enable_password_r
|
||||
where
|
||||
Backend: TcpBackendHandler + LoginHandler + OpaqueHandler + BackendHandler + 'static,
|
||||
{
|
||||
cfg.service(
|
||||
web::resource("/opaque/login/start").route(web::post().to(opaque_login_start::<Backend>)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/opaque/login/finish")
|
||||
.route(web::post().to(opaque_login_finish_handler::<Backend>)),
|
||||
)
|
||||
.service(web::resource("/simple/login").route(web::post().to(simple_login_handler::<Backend>)))
|
||||
.service(web::resource("/refresh").route(web::get().to(get_refresh_handler::<Backend>)))
|
||||
.service(web::resource("/logout").route(web::get().to(get_logout_handler::<Backend>)))
|
||||
.service(
|
||||
web::scope("/opaque/register")
|
||||
.wrap(CookieToHeaderTranslatorFactory)
|
||||
.service(
|
||||
web::resource("/start")
|
||||
.route(web::post().to(opaque_register_start_handler::<Backend>)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/finish")
|
||||
.route(web::post().to(opaque_register_finish_handler::<Backend>)),
|
||||
),
|
||||
);
|
||||
cfg.service(web::resource("").route(web::post().to(post_authorize_handler::<Backend>)))
|
||||
.service(
|
||||
web::resource("/opaque/login/start")
|
||||
.route(web::post().to(opaque_login_start::<Backend>)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/opaque/login/finish")
|
||||
.route(web::post().to(opaque_login_finish_handler::<Backend>)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/simple/login").route(web::post().to(simple_login_handler::<Backend>)),
|
||||
)
|
||||
.service(web::resource("/refresh").route(web::get().to(get_refresh_handler::<Backend>)))
|
||||
.service(web::resource("/logout").route(web::get().to(get_logout_handler::<Backend>)))
|
||||
.service(
|
||||
web::scope("/opaque/register")
|
||||
.wrap(CookieToHeaderTranslatorFactory)
|
||||
.service(
|
||||
web::resource("/start")
|
||||
.route(web::post().to(opaque_register_start_handler::<Backend>)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/finish")
|
||||
.route(web::post().to(opaque_register_finish_handler::<Backend>)),
|
||||
),
|
||||
);
|
||||
if enable_password_reset {
|
||||
cfg.service(
|
||||
web::resource("/reset/step1/{user_id}")
|
||||
@@ -657,9 +683,5 @@ where
|
||||
web::resource("/reset/step2/{token}")
|
||||
.route(web::get().to(get_password_reset_step2_handler::<Backend>)),
|
||||
);
|
||||
} else {
|
||||
cfg.service(
|
||||
web::resource("/reset/step1/{user_id}").route(web::post().to(HttpResponse::NotFound)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,78 +1,10 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use clap::{builder::EnumValueParser, Parser};
|
||||
use lettre::message::Mailbox;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use strum::{EnumString, IntoStaticStr};
|
||||
use url::Url;
|
||||
|
||||
use crate::infra::database_string::DatabaseUrl;
|
||||
|
||||
// Can be deserialized from either a boolean or a string, to facilitate migration.
|
||||
#[derive(Copy, Clone, Debug, Serialize, Default, EnumString, IntoStaticStr)]
|
||||
#[strum(ascii_case_insensitive)]
|
||||
pub enum TrueFalseAlways {
|
||||
#[default]
|
||||
False,
|
||||
True,
|
||||
Always,
|
||||
}
|
||||
|
||||
impl TrueFalseAlways {
|
||||
pub fn is_positive(&self) -> bool {
|
||||
matches!(self, TrueFalseAlways::True | TrueFalseAlways::Always)
|
||||
}
|
||||
|
||||
pub fn is_yes(&self) -> bool {
|
||||
matches!(self, TrueFalseAlways::True)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for TrueFalseAlways {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::de::{self};
|
||||
|
||||
struct Visitor;
|
||||
|
||||
impl<'de> serde::de::Visitor<'de> for Visitor {
|
||||
type Value = TrueFalseAlways;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
formatter.write_str("true, false or always")
|
||||
}
|
||||
|
||||
fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E>
|
||||
where
|
||||
E: de::Error,
|
||||
{
|
||||
if value {
|
||||
Ok(TrueFalseAlways::True)
|
||||
} else {
|
||||
Ok(TrueFalseAlways::False)
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: de::Error,
|
||||
{
|
||||
match TrueFalseAlways::from_str(value) {
|
||||
Ok(v) => Ok(v),
|
||||
Err(_) => Err(de::Error::unknown_variant(
|
||||
value,
|
||||
&["true", "false", "always"],
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_any(Visitor)
|
||||
}
|
||||
}
|
||||
|
||||
/// lldap is a lightweight LDAP server
|
||||
#[derive(Debug, Parser, Clone)]
|
||||
#[clap(version, author)]
|
||||
@@ -160,10 +92,8 @@ pub struct RunOpts {
|
||||
pub database_url: Option<DatabaseUrl>,
|
||||
|
||||
/// Force admin password reset to the config value.
|
||||
/// If set to true, it will be a one-time reset that doesn't start the server.
|
||||
/// You can set it to "always" to force a reset every time the server starts.
|
||||
#[clap(long, env = "LLDAP_FORCE_LDAP_USER_PASS_RESET")]
|
||||
pub force_ldap_user_pass_reset: Option<TrueFalseAlways>,
|
||||
#[clap(long, env = "LLDAP_FORCE_LADP_USER_PASS_RESET")]
|
||||
pub force_ldap_user_pass_reset: Option<bool>,
|
||||
|
||||
/// Force update of the private key after a key change.
|
||||
#[clap(long, env = "LLDAP_FORCE_UPDATE_PRIVATE_KEY")]
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::{
|
||||
domain::{
|
||||
sql_tables::{ConfigLocation, PrivateKeyHash, PrivateKeyInfo, PrivateKeyLocation},
|
||||
types::{AttributeName, UserId},
|
||||
},
|
||||
infra::{
|
||||
cli::{
|
||||
GeneralConfigOpts, LdapsOpts, RunOpts, SmtpEncryption, SmtpOpts, TestEmailOpts,
|
||||
TrueFalseAlways,
|
||||
},
|
||||
cli::{GeneralConfigOpts, LdapsOpts, RunOpts, SmtpEncryption, SmtpOpts, TestEmailOpts},
|
||||
database_string::DatabaseUrl,
|
||||
},
|
||||
};
|
||||
@@ -19,19 +14,13 @@ use figment::{
|
||||
Figment,
|
||||
};
|
||||
use figment_file_provider_adapter::FileAdapter;
|
||||
use lettre::message::Mailbox;
|
||||
use lldap_auth::opaque::{server::ServerSetup, KeyPair};
|
||||
use secstr::SecUtf8;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
#[derive(
|
||||
Clone, Deserialize, Serialize, derive_more::FromStr, derive_more::Debug, derive_more::Display,
|
||||
)]
|
||||
#[debug(r#""{_0}""#)]
|
||||
#[display("{_0}")]
|
||||
pub struct Mailbox(pub lettre::message::Mailbox);
|
||||
|
||||
#[derive(Clone, derive_more::Debug, Deserialize, Serialize, derive_builder::Builder)]
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, derive_builder::Builder)]
|
||||
#[builder(pattern = "owned")]
|
||||
pub struct MailOptions {
|
||||
#[builder(default = "false")]
|
||||
@@ -51,8 +40,6 @@ pub struct MailOptions {
|
||||
#[builder(default = "SmtpEncryption::Tls")]
|
||||
pub smtp_encryption: SmtpEncryption,
|
||||
/// Deprecated.
|
||||
#[debug(skip)]
|
||||
#[serde(skip)]
|
||||
#[builder(default = "None")]
|
||||
pub tls_required: Option<bool>,
|
||||
}
|
||||
@@ -82,11 +69,7 @@ impl std::default::Default for LdapsOptions {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize, derive_more::Debug)]
|
||||
#[debug(r#""{_0}""#)]
|
||||
pub struct HttpUrl(pub Url);
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize, derive_builder::Builder, derive_more::Debug)]
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, derive_builder::Builder)]
|
||||
#[builder(pattern = "owned", build_fn(name = "private_build"))]
|
||||
pub struct Configuration {
|
||||
#[builder(default = r#"String::from("0.0.0.0")"#)]
|
||||
@@ -107,8 +90,8 @@ pub struct Configuration {
|
||||
pub ldap_user_email: String,
|
||||
#[builder(default = r#"SecUtf8::from("password")"#)]
|
||||
pub ldap_user_pass: SecUtf8,
|
||||
#[builder(default)]
|
||||
pub force_ldap_user_pass_reset: TrueFalseAlways,
|
||||
#[builder(default = "false")]
|
||||
pub force_ldap_user_pass_reset: bool,
|
||||
#[builder(default = "false")]
|
||||
pub force_update_private_key: bool,
|
||||
#[builder(default = r#"DatabaseUrl::from("sqlite://users.db?mode=rwc")"#)]
|
||||
@@ -129,9 +112,8 @@ pub struct Configuration {
|
||||
pub smtp_options: MailOptions,
|
||||
#[builder(default)]
|
||||
pub ldaps_options: LdapsOptions,
|
||||
#[builder(default = r#"HttpUrl(Url::parse("http://localhost").unwrap())"#)]
|
||||
pub http_url: HttpUrl,
|
||||
#[debug(skip)]
|
||||
#[builder(default = r#"Url::parse("http://localhost").unwrap()"#)]
|
||||
pub http_url: Url,
|
||||
#[serde(skip)]
|
||||
#[builder(field(private), default = "None")]
|
||||
server_setup: Option<ServerSetupConfig>,
|
||||
@@ -247,22 +229,16 @@ fn generate_random_private_key() -> ServerSetup {
|
||||
ServerSetup::new(&mut rng)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_mode(permissions: &mut std::fs::Permissions) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
permissions.set_mode(0o400);
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn set_mode(_: &mut std::fs::Permissions) {}
|
||||
|
||||
fn write_to_readonly_file(path: &std::path::Path, buffer: &[u8]) -> Result<()> {
|
||||
use std::{fs::File, io::Write};
|
||||
assert!(!path.exists());
|
||||
let mut file = File::create(path)?;
|
||||
let mut permissions = file.metadata()?.permissions();
|
||||
permissions.set_readonly(true);
|
||||
set_mode(&mut permissions);
|
||||
if cfg!(unix) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
permissions.set_mode(0o400);
|
||||
}
|
||||
file.set_permissions(permissions)?;
|
||||
Ok(file.write_all(buffer)?)
|
||||
}
|
||||
@@ -361,9 +337,9 @@ fn get_server_setup<L: Into<PrivateKeyLocationOrFigment>>(
|
||||
file_path
|
||||
);
|
||||
} else if file_path == "server_key" {
|
||||
eprintln!("WARNING: A key_seed was given, we will ignore the server_key and generate one from the seed! Set server_key to an empty string in the config to silence this message.");
|
||||
eprintln!("WARNING: A key_seed was given, we will ignore the server_key and generate one from the seed!");
|
||||
} else {
|
||||
println!("Generating the private key from the key_seed");
|
||||
println!("Generating the key from the key_seed");
|
||||
}
|
||||
use rand::SeedableRng;
|
||||
let mut rng = rand_chacha::ChaCha20Rng::from_seed(stable_hash(key_seed.as_bytes()));
|
||||
@@ -434,7 +410,7 @@ impl ConfigOverrider for RunOpts {
|
||||
}
|
||||
|
||||
if let Some(url) = self.http_url.as_ref() {
|
||||
config.http_url = HttpUrl(url.clone());
|
||||
config.http_url = url.clone();
|
||||
}
|
||||
|
||||
if let Some(database_url) = self.database_url.as_ref() {
|
||||
@@ -469,10 +445,10 @@ impl ConfigOverrider for LdapsOpts {
|
||||
config.ldaps_options.port = port;
|
||||
}
|
||||
if let Some(path) = self.ldaps_cert_file.as_ref() {
|
||||
config.ldaps_options.cert_file.clone_from(path);
|
||||
config.ldaps_options.cert_file = path.clone();
|
||||
}
|
||||
if let Some(path) = self.ldaps_key_file.as_ref() {
|
||||
config.ldaps_options.key_file.clone_from(path);
|
||||
config.ldaps_options.key_file = path.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -488,19 +464,19 @@ impl ConfigOverrider for GeneralConfigOpts {
|
||||
impl ConfigOverrider for SmtpOpts {
|
||||
fn override_config(&self, config: &mut Configuration) {
|
||||
if let Some(from) = &self.smtp_from {
|
||||
config.smtp_options.from = Some(Mailbox(from.clone()));
|
||||
config.smtp_options.from = Some(from.clone());
|
||||
}
|
||||
if let Some(reply_to) = &self.smtp_reply_to {
|
||||
config.smtp_options.reply_to = Some(Mailbox(reply_to.clone()));
|
||||
config.smtp_options.reply_to = Some(reply_to.clone());
|
||||
}
|
||||
if let Some(server) = &self.smtp_server {
|
||||
config.smtp_options.server.clone_from(server);
|
||||
config.smtp_options.server = server.clone();
|
||||
}
|
||||
if let Some(port) = self.smtp_port {
|
||||
config.smtp_options.port = port;
|
||||
}
|
||||
if let Some(user) = &self.smtp_user {
|
||||
config.smtp_options.user.clone_from(user);
|
||||
config.smtp_options.user = user.clone();
|
||||
}
|
||||
if let Some(password) = &self.smtp_password {
|
||||
config.smtp_options.password = SecUtf8::from(password.clone());
|
||||
@@ -517,46 +493,6 @@ impl ConfigOverrider for SmtpOpts {
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_keys(dict: &figment::value::Dict) -> HashSet<String> {
|
||||
use figment::value::{Dict, Value};
|
||||
fn process_value(value: &Dict, keys: &mut HashSet<String>, path: &mut Vec<String>) {
|
||||
for (key, value) in value {
|
||||
match value {
|
||||
Value::Dict(_, dict) => {
|
||||
path.push(format!("{}__", key.to_ascii_uppercase()));
|
||||
process_value(dict, keys, path);
|
||||
path.pop();
|
||||
}
|
||||
_ => {
|
||||
keys.insert(format!(
|
||||
"LLDAP_{}{}",
|
||||
path.join(""),
|
||||
key.to_ascii_uppercase()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut keys = HashSet::new();
|
||||
let mut path = Vec::new();
|
||||
process_value(dict, &mut keys, &mut path);
|
||||
keys
|
||||
}
|
||||
|
||||
fn expected_keys(dict: &figment::value::Dict) -> HashSet<String> {
|
||||
let mut keys = extract_keys(dict);
|
||||
// CLI-only values.
|
||||
keys.insert("LLDAP_CONFIG_FILE".to_string());
|
||||
keys.insert("LLDAP_TEST_EMAIL_TO".to_string());
|
||||
// Alternate spellings from clap.
|
||||
keys.insert("LLDAP_SERVER_KEY_FILE".to_string());
|
||||
keys.insert("LLDAP_SERVER_KEY_SEED".to_string());
|
||||
keys.insert("LLDAP_SMTP_OPTIONS__TO".to_string());
|
||||
// Deprecated
|
||||
keys.insert("LLDAP_SMTP_OPTIONS__TLS_REQUIRED".to_string());
|
||||
keys
|
||||
}
|
||||
|
||||
pub fn init<C>(overrides: C) -> Result<Configuration>
|
||||
where
|
||||
C: TopLevelCommandOpts + ConfigOverrider,
|
||||
@@ -567,37 +503,19 @@ where
|
||||
);
|
||||
|
||||
let ignore_keys = ["key_file", "cert_file"];
|
||||
let env_variable_provider =
|
||||
|| FileAdapter::wrap(Env::prefixed("LLDAP_").split("__")).ignore(&ignore_keys);
|
||||
let figment_config = Figment::from(Serialized::defaults(
|
||||
ConfigurationBuilder::default().private_build().unwrap(),
|
||||
))
|
||||
.merge(
|
||||
FileAdapter::wrap(Toml::file(&overrides.general_config().config_file)).ignore(&ignore_keys),
|
||||
)
|
||||
.merge(env_variable_provider());
|
||||
.merge(FileAdapter::wrap(Env::prefixed("LLDAP_").split("__")).ignore(&ignore_keys));
|
||||
let mut config: Configuration = figment_config.extract()?;
|
||||
|
||||
overrides.override_config(&mut config);
|
||||
if config.verbose {
|
||||
println!("Configuration: {:#?}", &config);
|
||||
}
|
||||
{
|
||||
use figment::{Profile, Provider};
|
||||
let expected_keys = expected_keys(
|
||||
&Figment::from(Serialized::defaults(
|
||||
ConfigurationBuilder::default().private_build().unwrap(),
|
||||
))
|
||||
.data()
|
||||
.unwrap()[&Profile::default()],
|
||||
);
|
||||
extract_keys(&env_variable_provider().data().unwrap()[&Profile::default()])
|
||||
.iter()
|
||||
.filter(|k| !expected_keys.contains(k.as_str()))
|
||||
.for_each(|k| {
|
||||
eprintln!("WARNING: Unknown environment variable: LLDAP_{}", k);
|
||||
});
|
||||
}
|
||||
config.server_setup = Some(get_server_setup(
|
||||
&config.key_file,
|
||||
config
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, derive_more::Display)]
|
||||
#[display("{_0}")]
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct DatabaseUrl(Url);
|
||||
|
||||
impl From<Url> for DatabaseUrl {
|
||||
@@ -17,25 +16,25 @@ impl From<&str> for DatabaseUrl {
|
||||
}
|
||||
}
|
||||
|
||||
impl DatabaseUrl {
|
||||
pub fn db_type(&self) -> &str {
|
||||
self.0.scheme()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DatabaseUrl {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
if self.0.password().is_some() {
|
||||
let mut url = self.0.clone();
|
||||
// It can fail for URLs that cannot have a password, like "mailto:bob@example".
|
||||
let _ = url.set_password(Some("***PASSWORD***"));
|
||||
f.write_fmt(format_args!(r#""{}""#, url))
|
||||
f.write_fmt(format_args!("{}", url))
|
||||
} else {
|
||||
f.write_fmt(format_args!(r#""{}""#, self.0))
|
||||
f.write_fmt(format_args!("{}", self.0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToString for DatabaseUrl {
|
||||
fn to_string(&self) -> String {
|
||||
self.0.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -45,7 +44,7 @@ mod tests {
|
||||
let url = DatabaseUrl::from("postgres://user:pass@localhost:5432/dbname");
|
||||
assert_eq!(
|
||||
format!("{:?}", url),
|
||||
r#""postgres://user:***PASSWORD***@localhost:5432/dbname""#
|
||||
"postgres://user:***PASSWORD***@localhost:5432/dbname"
|
||||
);
|
||||
assert_eq!(
|
||||
url.to_string(),
|
||||
|
||||
@@ -7,9 +7,8 @@ use crate::{
|
||||
AttributeList, BackendHandler, CreateAttributeRequest, CreateGroupRequest,
|
||||
CreateUserRequest, UpdateGroupRequest, UpdateUserRequest,
|
||||
},
|
||||
schema::PublicSchema,
|
||||
types::{
|
||||
AttributeName, AttributeType, AttributeValue as DomainAttributeValue, Email, GroupId,
|
||||
AttributeName, AttributeType, AttributeValue as DomainAttributeValue, GroupId,
|
||||
JpegPhoto, LdapObjectClass, UserId,
|
||||
},
|
||||
},
|
||||
@@ -40,7 +39,7 @@ impl<Handler: BackendHandler> Mutation<Handler> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Debug, GraphQLInputObject)]
|
||||
#[derive(PartialEq, Eq, Debug, GraphQLInputObject)]
|
||||
// This conflicts with the attribute values returned by the user/group queries.
|
||||
#[graphql(name = "AttributeValueInput")]
|
||||
struct AttributeValue {
|
||||
@@ -59,8 +58,7 @@ struct AttributeValue {
|
||||
/// The details required to create a user.
|
||||
pub struct CreateUserInput {
|
||||
id: String,
|
||||
// The email can be specified as an attribute, but one of the two is required.
|
||||
email: Option<String>,
|
||||
email: String,
|
||||
display_name: Option<String>,
|
||||
first_name: Option<String>,
|
||||
last_name: Option<String>,
|
||||
@@ -122,44 +120,6 @@ impl Success {
|
||||
}
|
||||
}
|
||||
|
||||
struct UnpackedAttributes {
|
||||
email: Option<Email>,
|
||||
display_name: Option<String>,
|
||||
attributes: Vec<DomainAttributeValue>,
|
||||
}
|
||||
|
||||
fn unpack_attributes(
|
||||
attributes: Vec<AttributeValue>,
|
||||
schema: &PublicSchema,
|
||||
is_admin: bool,
|
||||
) -> FieldResult<UnpackedAttributes> {
|
||||
let email = attributes
|
||||
.iter()
|
||||
.find(|attr| attr.name == "mail")
|
||||
.cloned()
|
||||
.map(|attr| deserialize_attribute(&schema.get_schema().user_attributes, attr, is_admin))
|
||||
.transpose()?
|
||||
.map(|attr| attr.value.unwrap::<String>())
|
||||
.map(Email::from);
|
||||
let display_name = attributes
|
||||
.iter()
|
||||
.find(|attr| attr.name == "display_name")
|
||||
.cloned()
|
||||
.map(|attr| deserialize_attribute(&schema.get_schema().user_attributes, attr, is_admin))
|
||||
.transpose()?
|
||||
.map(|attr| attr.value.unwrap::<String>());
|
||||
let attributes = attributes
|
||||
.into_iter()
|
||||
.filter(|attr| attr.name != "mail" && attr.name != "display_name")
|
||||
.map(|attr| deserialize_attribute(&schema.get_schema().user_attributes, attr, is_admin))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(UnpackedAttributes {
|
||||
email,
|
||||
display_name,
|
||||
attributes,
|
||||
})
|
||||
}
|
||||
|
||||
#[graphql_object(context = Context<Handler>)]
|
||||
impl<Handler: BackendHandler> Mutation<Handler> {
|
||||
async fn create_user(
|
||||
@@ -183,20 +143,17 @@ impl<Handler: BackendHandler> Mutation<Handler> {
|
||||
.transpose()
|
||||
.context("Provided image is not a valid JPEG")?;
|
||||
let schema = handler.get_schema().await?;
|
||||
let UnpackedAttributes {
|
||||
email,
|
||||
display_name,
|
||||
attributes,
|
||||
} = unpack_attributes(user.attributes.unwrap_or_default(), &schema, true)?;
|
||||
let attributes = user
|
||||
.attributes
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|attr| deserialize_attribute(&schema.get_schema().user_attributes, attr, true))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
handler
|
||||
.create_user(CreateUserRequest {
|
||||
user_id: user_id.clone(),
|
||||
email: user
|
||||
.email
|
||||
.map(Email::from)
|
||||
.or(email)
|
||||
.ok_or_else(|| anyhow!("Email is required when creating a new user"))?,
|
||||
display_name: user.display_name.or(display_name),
|
||||
email: user.email.into(),
|
||||
display_name: user.display_name,
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name,
|
||||
avatar,
|
||||
@@ -259,25 +216,17 @@ impl<Handler: BackendHandler> Mutation<Handler> {
|
||||
.transpose()
|
||||
.context("Provided image is not a valid JPEG")?;
|
||||
let schema = handler.get_schema().await?;
|
||||
let user_insert_attributes = user.insert_attributes.unwrap_or_default();
|
||||
let UnpackedAttributes {
|
||||
email,
|
||||
display_name,
|
||||
attributes: insert_attributes,
|
||||
} = unpack_attributes(user_insert_attributes, &schema, is_admin)?;
|
||||
let display_name = display_name.or_else(|| {
|
||||
// If the display name is not inserted, but removed, reset it.
|
||||
user.remove_attributes
|
||||
.iter()
|
||||
.flatten()
|
||||
.find(|attr| *attr == "display_name")
|
||||
.map(|_| String::new())
|
||||
});
|
||||
let insert_attributes = user
|
||||
.insert_attributes
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|attr| deserialize_attribute(&schema.get_schema().user_attributes, attr, is_admin))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
handler
|
||||
.update_user(UpdateUserRequest {
|
||||
user_id,
|
||||
email: user.email.map(Into::into).or(email),
|
||||
display_name: user.display_name.or(display_name),
|
||||
email: user.email.map(Into::into),
|
||||
display_name: user.display_name,
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name,
|
||||
avatar,
|
||||
@@ -285,7 +234,6 @@ impl<Handler: BackendHandler> Mutation<Handler> {
|
||||
.remove_attributes
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|attr| attr != "mail" && attr != "display_name")
|
||||
.map(Into::into)
|
||||
.collect(),
|
||||
insert_attributes,
|
||||
@@ -306,14 +254,7 @@ impl<Handler: BackendHandler> Mutation<Handler> {
|
||||
let handler = context
|
||||
.get_admin_handler()
|
||||
.ok_or_else(field_error_callback(&span, "Unauthorized group update"))?;
|
||||
let new_display_name = group.display_name.clone().or_else(|| {
|
||||
group.insert_attributes.as_ref().and_then(|a| {
|
||||
a.iter()
|
||||
.find(|attr| attr.name == "display_name")
|
||||
.map(|attr| attr.value[0].clone())
|
||||
})
|
||||
});
|
||||
if group.id == 1 && new_display_name.is_some() {
|
||||
if group.id == 1 && group.display_name.is_some() {
|
||||
span.in_scope(|| debug!("Cannot change lldap_admin group name"));
|
||||
return Err("Cannot change lldap_admin group name".into());
|
||||
}
|
||||
@@ -322,18 +263,16 @@ impl<Handler: BackendHandler> Mutation<Handler> {
|
||||
.insert_attributes
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|attr| attr.name != "display_name")
|
||||
.map(|attr| deserialize_attribute(&schema.get_schema().group_attributes, attr, true))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
handler
|
||||
.update_group(UpdateGroupRequest {
|
||||
group_id: GroupId(group.id),
|
||||
display_name: new_display_name.map(|s| s.as_str().into()),
|
||||
display_name: group.display_name.map(Into::into),
|
||||
delete_attributes: group
|
||||
.remove_attributes
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|attr| attr != "display_name")
|
||||
.map(Into::into)
|
||||
.collect(),
|
||||
insert_attributes,
|
||||
@@ -670,13 +609,6 @@ fn deserialize_attribute(
|
||||
let attribute_schema = attribute_schema
|
||||
.get_attribute_schema(&attribute_name)
|
||||
.ok_or_else(|| anyhow!("Attribute {} is not defined in the schema", attribute.name))?;
|
||||
if attribute_schema.is_readonly {
|
||||
return Err(anyhow!(
|
||||
"Permission denied: Attribute {} is read-only",
|
||||
attribute.name
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if !is_admin && !attribute_schema.is_editable {
|
||||
return Err(anyhow!(
|
||||
"Permission denied: Attribute {} is not editable by regular users",
|
||||
|
||||
@@ -7,9 +7,7 @@ use crate::{
|
||||
ldap::utils::{map_user_field, UserFieldType},
|
||||
model::UserColumn,
|
||||
schema::PublicSchema,
|
||||
types::{
|
||||
AttributeType, GroupDetails, GroupId, JpegPhoto, LdapObjectClass, Serialized, UserId,
|
||||
},
|
||||
types::{AttributeType, GroupDetails, GroupId, JpegPhoto, LdapObjectClass, UserId},
|
||||
},
|
||||
infra::{
|
||||
access_control::{ReadonlyBackendHandler, UserReadableBackendHandler},
|
||||
@@ -18,7 +16,7 @@ use crate::{
|
||||
};
|
||||
use anyhow::Context as AnyhowContext;
|
||||
use chrono::{NaiveDateTime, TimeZone};
|
||||
use juniper::{graphql_object, FieldResult, GraphQLInputObject};
|
||||
use juniper::{graphql_object, FieldError, FieldResult, GraphQLInputObject};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, debug_span, Instrument, Span};
|
||||
|
||||
@@ -249,10 +247,15 @@ pub struct User<Handler: BackendHandler> {
|
||||
|
||||
impl<Handler: BackendHandler> User<Handler> {
|
||||
pub fn from_user(mut user: DomainUser, schema: Arc<PublicSchema>) -> FieldResult<Self> {
|
||||
let attributes = AttributeValue::<Handler>::user_attributes_from_schema(&mut user, &schema);
|
||||
let attributes = std::mem::take(&mut user.attributes);
|
||||
Ok(Self {
|
||||
user,
|
||||
attributes,
|
||||
attributes: attributes
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
AttributeValue::<Handler>::from_schema(a, &schema.get_schema().user_attributes)
|
||||
})
|
||||
.collect::<FieldResult<Vec<_>>>()?,
|
||||
schema,
|
||||
groups: None,
|
||||
_phantom: std::marker::PhantomData,
|
||||
@@ -293,26 +296,29 @@ impl<Handler: BackendHandler> User<Handler> {
|
||||
}
|
||||
|
||||
fn first_name(&self) -> &str {
|
||||
self.attributes
|
||||
self.user
|
||||
.attributes
|
||||
.iter()
|
||||
.find(|a| a.attribute.name.as_str() == "first_name")
|
||||
.map(|a| a.attribute.value.unwrap())
|
||||
.find(|a| a.name.as_str() == "first_name")
|
||||
.map(|a| a.value.unwrap())
|
||||
.unwrap_or("")
|
||||
}
|
||||
|
||||
fn last_name(&self) -> &str {
|
||||
self.attributes
|
||||
self.user
|
||||
.attributes
|
||||
.iter()
|
||||
.find(|a| a.attribute.name.as_str() == "last_name")
|
||||
.map(|a| a.attribute.value.unwrap())
|
||||
.find(|a| a.name.as_str() == "last_name")
|
||||
.map(|a| a.value.unwrap())
|
||||
.unwrap_or("")
|
||||
}
|
||||
|
||||
fn avatar(&self) -> Option<String> {
|
||||
self.attributes
|
||||
self.user
|
||||
.attributes
|
||||
.iter()
|
||||
.find(|a| a.attribute.name.as_str() == "avatar")
|
||||
.map(|a| String::from(&a.attribute.value.unwrap::<JpegPhoto>()))
|
||||
.find(|a| a.name.as_str() == "avatar")
|
||||
.map(|a| String::from(&a.value.unwrap::<JpegPhoto>()))
|
||||
}
|
||||
|
||||
fn creation_date(&self) -> chrono::DateTime<chrono::Utc> {
|
||||
@@ -367,36 +373,42 @@ pub struct Group<Handler: BackendHandler> {
|
||||
|
||||
impl<Handler: BackendHandler> Group<Handler> {
|
||||
pub fn from_group(
|
||||
mut group: DomainGroup,
|
||||
group: DomainGroup,
|
||||
schema: Arc<PublicSchema>,
|
||||
) -> FieldResult<Group<Handler>> {
|
||||
let attributes =
|
||||
AttributeValue::<Handler>::group_attributes_from_schema(&mut group, &schema);
|
||||
Ok(Self {
|
||||
group_id: group.id.0,
|
||||
display_name: group.display_name.to_string(),
|
||||
creation_date: group.creation_date,
|
||||
uuid: group.uuid.into_string(),
|
||||
attributes,
|
||||
attributes: group
|
||||
.attributes
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
AttributeValue::<Handler>::from_schema(a, &schema.get_schema().group_attributes)
|
||||
})
|
||||
.collect::<FieldResult<Vec<_>>>()?,
|
||||
schema,
|
||||
_phantom: std::marker::PhantomData,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn from_group_details(
|
||||
mut group_details: GroupDetails,
|
||||
group_details: GroupDetails,
|
||||
schema: Arc<PublicSchema>,
|
||||
) -> FieldResult<Group<Handler>> {
|
||||
let attributes = AttributeValue::<Handler>::group_details_attributes_from_schema(
|
||||
&mut group_details,
|
||||
&schema,
|
||||
);
|
||||
Ok(Self {
|
||||
group_id: group_details.group_id.0,
|
||||
display_name: group_details.display_name.to_string(),
|
||||
creation_date: group_details.creation_date,
|
||||
uuid: group_details.uuid.into_string(),
|
||||
attributes,
|
||||
attributes: group_details
|
||||
.attributes
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
AttributeValue::<Handler>::from_schema(a, &schema.get_schema().group_attributes)
|
||||
})
|
||||
.collect::<FieldResult<Vec<_>>>()?,
|
||||
schema,
|
||||
_phantom: std::marker::PhantomData,
|
||||
})
|
||||
@@ -489,9 +501,6 @@ impl<Handler: BackendHandler> AttributeSchema<Handler> {
|
||||
fn is_hardcoded(&self) -> bool {
|
||||
self.schema.is_hardcoded
|
||||
}
|
||||
fn is_readonly(&self) -> bool {
|
||||
self.schema.is_readonly
|
||||
}
|
||||
}
|
||||
|
||||
impl<Handler: BackendHandler> Clone for AttributeSchema<Handler> {
|
||||
@@ -598,19 +607,6 @@ impl<Handler: BackendHandler> AttributeValue<Handler> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<Handler: BackendHandler> AttributeValue<Handler> {
|
||||
fn from_domain(value: DomainAttributeValue, schema: DomainAttributeSchema) -> Self {
|
||||
Self {
|
||||
attribute: value,
|
||||
schema: AttributeSchema::<Handler> {
|
||||
schema,
|
||||
_phantom: std::marker::PhantomData,
|
||||
},
|
||||
_phantom: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<Handler: BackendHandler> Clone for AttributeValue<Handler> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
@@ -665,136 +661,18 @@ pub fn serialize_attribute(
|
||||
}
|
||||
|
||||
impl<Handler: BackendHandler> AttributeValue<Handler> {
|
||||
fn from_schema(a: DomainAttributeValue, schema: &DomainAttributeList) -> Option<Self> {
|
||||
schema
|
||||
.get_attribute_schema(&a.name)
|
||||
.map(|s| AttributeValue::<Handler>::from_domain(a, s.clone()))
|
||||
}
|
||||
|
||||
fn user_attributes_from_schema(
|
||||
user: &mut DomainUser,
|
||||
schema: &PublicSchema,
|
||||
) -> Vec<AttributeValue<Handler>> {
|
||||
let user_attributes = std::mem::take(&mut user.attributes);
|
||||
let mut all_attributes = schema
|
||||
.get_schema()
|
||||
.user_attributes
|
||||
.attributes
|
||||
.iter()
|
||||
.filter(|a| a.is_hardcoded)
|
||||
.flat_map(|attribute| {
|
||||
let value = match attribute.name.as_str() {
|
||||
"user_id" => Some(Serialized::from(&user.user_id)),
|
||||
"creation_date" => Some(Serialized::from(&user.creation_date)),
|
||||
"mail" => Some(Serialized::from(&user.email)),
|
||||
"uuid" => Some(Serialized::from(&user.uuid)),
|
||||
"display_name" => user.display_name.as_ref().map(Serialized::from),
|
||||
"avatar" | "first_name" | "last_name" => None,
|
||||
_ => panic!("Unexpected hardcoded attribute: {}", attribute.name),
|
||||
};
|
||||
value.map(|v| (attribute, v))
|
||||
})
|
||||
.map(|(attribute, value)| {
|
||||
AttributeValue::<Handler>::from_domain(
|
||||
DomainAttributeValue {
|
||||
name: attribute.name.clone(),
|
||||
value,
|
||||
},
|
||||
attribute.clone(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
user_attributes
|
||||
.into_iter()
|
||||
.flat_map(|a| {
|
||||
AttributeValue::<Handler>::from_schema(a, &schema.get_schema().user_attributes)
|
||||
})
|
||||
.for_each(|value| all_attributes.push(value));
|
||||
all_attributes
|
||||
}
|
||||
|
||||
fn group_attributes_from_schema(
|
||||
group: &mut DomainGroup,
|
||||
schema: &PublicSchema,
|
||||
) -> Vec<AttributeValue<Handler>> {
|
||||
let group_attributes = std::mem::take(&mut group.attributes);
|
||||
let mut all_attributes = schema
|
||||
.get_schema()
|
||||
.group_attributes
|
||||
.attributes
|
||||
.iter()
|
||||
.filter(|a| a.is_hardcoded)
|
||||
.map(|attribute| {
|
||||
(
|
||||
attribute,
|
||||
match attribute.name.as_str() {
|
||||
"group_id" => Serialized::from(&(group.id.0 as i64)),
|
||||
"creation_date" => Serialized::from(&group.creation_date),
|
||||
"uuid" => Serialized::from(&group.uuid),
|
||||
"display_name" => Serialized::from(&group.display_name),
|
||||
_ => panic!("Unexpected hardcoded attribute: {}", attribute.name),
|
||||
},
|
||||
)
|
||||
})
|
||||
.map(|(attribute, value)| {
|
||||
AttributeValue::<Handler>::from_domain(
|
||||
DomainAttributeValue {
|
||||
name: attribute.name.clone(),
|
||||
value,
|
||||
},
|
||||
attribute.clone(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
group_attributes
|
||||
.into_iter()
|
||||
.flat_map(|a| {
|
||||
AttributeValue::<Handler>::from_schema(a, &schema.get_schema().group_attributes)
|
||||
})
|
||||
.for_each(|value| all_attributes.push(value));
|
||||
all_attributes
|
||||
}
|
||||
|
||||
fn group_details_attributes_from_schema(
|
||||
group: &mut GroupDetails,
|
||||
schema: &PublicSchema,
|
||||
) -> Vec<AttributeValue<Handler>> {
|
||||
let group_attributes = std::mem::take(&mut group.attributes);
|
||||
let mut all_attributes = schema
|
||||
.get_schema()
|
||||
.group_attributes
|
||||
.attributes
|
||||
.iter()
|
||||
.filter(|a| a.is_hardcoded)
|
||||
.map(|attribute| {
|
||||
(
|
||||
attribute,
|
||||
match attribute.name.as_str() {
|
||||
"group_id" => Serialized::from(&(group.group_id.0 as i64)),
|
||||
"creation_date" => Serialized::from(&group.creation_date),
|
||||
"uuid" => Serialized::from(&group.uuid),
|
||||
"display_name" => Serialized::from(&group.display_name),
|
||||
_ => panic!("Unexpected hardcoded attribute: {}", attribute.name),
|
||||
},
|
||||
)
|
||||
})
|
||||
.map(|(attribute, value)| {
|
||||
AttributeValue::<Handler>::from_domain(
|
||||
DomainAttributeValue {
|
||||
name: attribute.name.clone(),
|
||||
value,
|
||||
},
|
||||
attribute.clone(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
group_attributes
|
||||
.into_iter()
|
||||
.flat_map(|a| {
|
||||
AttributeValue::<Handler>::from_schema(a, &schema.get_schema().group_attributes)
|
||||
})
|
||||
.for_each(|value| all_attributes.push(value));
|
||||
all_attributes
|
||||
fn from_schema(a: DomainAttributeValue, schema: &DomainAttributeList) -> FieldResult<Self> {
|
||||
match schema.get_attribute_schema(&a.name) {
|
||||
Some(s) => Ok(AttributeValue::<Handler> {
|
||||
attribute: a,
|
||||
schema: AttributeSchema::<Handler> {
|
||||
schema: s.clone(),
|
||||
_phantom: std::marker::PhantomData,
|
||||
},
|
||||
_phantom: std::marker::PhantomData,
|
||||
}),
|
||||
None => Err(FieldError::from(format!("Unknown attribute {}", &a.name))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -838,8 +716,6 @@ mod tests {
|
||||
id
|
||||
email
|
||||
creationDate
|
||||
firstName
|
||||
lastName
|
||||
uuid
|
||||
attributes {
|
||||
name
|
||||
@@ -870,7 +746,6 @@ mod tests {
|
||||
is_visible: true,
|
||||
is_editable: true,
|
||||
is_hardcoded: true,
|
||||
is_readonly: false,
|
||||
},
|
||||
DomainAttributeSchema {
|
||||
name: "last_name".into(),
|
||||
@@ -879,7 +754,6 @@ mod tests {
|
||||
is_visible: true,
|
||||
is_editable: true,
|
||||
is_hardcoded: true,
|
||||
is_readonly: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -891,7 +765,6 @@ mod tests {
|
||||
is_visible: true,
|
||||
is_editable: true,
|
||||
is_hardcoded: false,
|
||||
is_readonly: false,
|
||||
}],
|
||||
},
|
||||
extra_user_object_classes: vec![
|
||||
@@ -949,6 +822,7 @@ mod tests {
|
||||
|
||||
let schema = schema(Query::<MockTestBackendHandler>::new());
|
||||
assert_eq!(
|
||||
execute(QUERY, None, &schema, &Variables::new(), &context).await,
|
||||
Ok((
|
||||
graphql_value!(
|
||||
{
|
||||
@@ -956,26 +830,8 @@ mod tests {
|
||||
"id": "bob",
|
||||
"email": "bob@bobbers.on",
|
||||
"creationDate": "1970-01-01T00:00:00.042+00:00",
|
||||
"firstName": "Bob",
|
||||
"lastName": "Bobberson",
|
||||
"uuid": "b1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8",
|
||||
"attributes": [{
|
||||
"name": "creation_date",
|
||||
"value": ["1970-01-01T00:00:00.042+00:00"],
|
||||
},
|
||||
{
|
||||
"name": "mail",
|
||||
"value": ["bob@bobbers.on"],
|
||||
},
|
||||
{
|
||||
"name": "user_id",
|
||||
"value": ["bob"],
|
||||
},
|
||||
{
|
||||
"name": "uuid",
|
||||
"value": ["b1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8"],
|
||||
},
|
||||
{
|
||||
"name": "first_name",
|
||||
"value": ["Bob"],
|
||||
},
|
||||
@@ -989,22 +845,6 @@ mod tests {
|
||||
"creationDate": "1970-01-01T00:00:00.000000042+00:00",
|
||||
"uuid": "a1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8",
|
||||
"attributes": [{
|
||||
"name": "creation_date",
|
||||
"value": ["1970-01-01T00:00:00.000000042+00:00"],
|
||||
},
|
||||
{
|
||||
"name": "display_name",
|
||||
"value": ["Bobbersons"],
|
||||
},
|
||||
{
|
||||
"name": "group_id",
|
||||
"value": ["3"],
|
||||
},
|
||||
{
|
||||
"name": "uuid",
|
||||
"value": ["a1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8"],
|
||||
},
|
||||
{
|
||||
"name": "club_name",
|
||||
"value": ["Gang of Four"],
|
||||
},
|
||||
@@ -1015,29 +855,12 @@ mod tests {
|
||||
"displayName": "Jefferees",
|
||||
"creationDate": "1970-01-01T00:00:00.000000012+00:00",
|
||||
"uuid": "b1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8",
|
||||
"attributes": [{
|
||||
"name": "creation_date",
|
||||
"value": ["1970-01-01T00:00:00.000000012+00:00"],
|
||||
},
|
||||
{
|
||||
"name": "display_name",
|
||||
"value": ["Jefferees"],
|
||||
},
|
||||
{
|
||||
"name": "group_id",
|
||||
"value": ["7"],
|
||||
},
|
||||
{
|
||||
"name": "uuid",
|
||||
"value": ["b1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8"],
|
||||
},
|
||||
],
|
||||
"attributes": [],
|
||||
}]
|
||||
}
|
||||
}),
|
||||
vec![]
|
||||
)),
|
||||
execute(QUERY, None, &schema, &Variables::new(), &context).await
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1301,14 +1124,13 @@ mod tests {
|
||||
mock.expect_get_schema().times(1).return_once(|| {
|
||||
Ok(crate::domain::handler::Schema {
|
||||
user_attributes: AttributeList {
|
||||
attributes: vec![DomainAttributeSchema {
|
||||
attributes: vec![crate::domain::handler::AttributeSchema {
|
||||
name: "invisible".into(),
|
||||
attribute_type: AttributeType::JpegPhoto,
|
||||
is_list: false,
|
||||
is_visible: false,
|
||||
is_editable: true,
|
||||
is_hardcoded: true,
|
||||
is_readonly: false,
|
||||
}],
|
||||
},
|
||||
group_attributes: AttributeList {
|
||||
|
||||
@@ -31,6 +31,9 @@ use ldap3_proto::proto::{
|
||||
use std::collections::HashMap;
|
||||
use tracing::{debug, instrument, warn};
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone)]
|
||||
struct LdapDn(String);
|
||||
|
||||
#[derive(Debug)]
|
||||
enum SearchScope {
|
||||
Global,
|
||||
@@ -212,13 +215,6 @@ pub struct LdapHandler<Backend> {
|
||||
user_info: Option<ValidationResults>,
|
||||
backend_handler: AccessControlledBackendHandler<Backend>,
|
||||
ldap_info: LdapInfo,
|
||||
session_uuid: uuid::Uuid,
|
||||
}
|
||||
|
||||
impl<Backend> LdapHandler<Backend> {
|
||||
pub fn session_uuid(&self) -> &uuid::Uuid {
|
||||
&self.session_uuid
|
||||
}
|
||||
}
|
||||
|
||||
impl<Backend: LoginHandler> LdapHandler<Backend> {
|
||||
@@ -239,7 +235,6 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
|
||||
mut ldap_base_dn: String,
|
||||
ignored_user_attributes: Vec<AttributeName>,
|
||||
ignored_group_attributes: Vec<AttributeName>,
|
||||
session_uuid: uuid::Uuid,
|
||||
) -> Self {
|
||||
ldap_base_dn.make_ascii_lowercase();
|
||||
Self {
|
||||
@@ -256,7 +251,6 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
|
||||
ignored_user_attributes,
|
||||
ignored_group_attributes,
|
||||
},
|
||||
session_uuid,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,18 +261,11 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
|
||||
ldap_base_dn.to_string(),
|
||||
vec![],
|
||||
vec![],
|
||||
uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "debug", fields(dn = %request.dn))]
|
||||
pub async fn do_bind(&mut self, request: &LdapBindRequest) -> (LdapResultCode, String) {
|
||||
if request.dn.is_empty() {
|
||||
return (
|
||||
LdapResultCode::InappropriateAuthentication,
|
||||
"Anonymous bind not allowed".to_string(),
|
||||
);
|
||||
}
|
||||
let user_id = match get_user_id_from_distinguished_name(
|
||||
&request.dn.to_ascii_lowercase(),
|
||||
&self.ldap_info.base_dn,
|
||||
@@ -355,7 +342,7 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
|
||||
match (&request.user_identity, &request.new_password) {
|
||||
(Some(user), Some(password)) => {
|
||||
match get_user_id_from_distinguished_name(
|
||||
&user.to_ascii_lowercase(),
|
||||
user,
|
||||
&self.ldap_info.base_dn,
|
||||
&self.ldap_info.base_dn_str,
|
||||
) {
|
||||
@@ -411,7 +398,6 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "debug")]
|
||||
async fn do_extended_request(&mut self, request: &LdapExtendedRequest) -> Vec<LdapOp> {
|
||||
match LdapPasswordModifyRequest::try_from(request) {
|
||||
Ok(password_request) => self
|
||||
@@ -517,7 +503,6 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "debug", fields(dn = %request.dn))]
|
||||
async fn do_modify_request(&mut self, request: &LdapModifyRequest) -> Vec<LdapOp> {
|
||||
self.handle_modify_request(request)
|
||||
.await
|
||||
@@ -688,7 +673,6 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "debug")]
|
||||
async fn do_create_user(&self, request: LdapAddRequest) -> LdapResult<Vec<LdapOp>> {
|
||||
let backend_handler = self
|
||||
.user_info
|
||||
@@ -774,7 +758,6 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
|
||||
Ok(vec![make_add_error(LdapResultCode::Success, String::new())])
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "debug")]
|
||||
pub async fn do_compare(&mut self, request: LdapCompareRequest) -> LdapResult<Vec<LdapOp>> {
|
||||
let req = make_search_request::<String>(
|
||||
&self.ldap_info.base_dn_str,
|
||||
@@ -789,13 +772,13 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
|
||||
message: "Too many search results".to_string(),
|
||||
});
|
||||
}
|
||||
let requested_attribute = AttributeName::from(&request.atype);
|
||||
|
||||
match entries.first() {
|
||||
Some(LdapOp::SearchResultEntry(entry)) => {
|
||||
let available = entry.attributes.iter().any(|attr| {
|
||||
AttributeName::from(&attr.atype) == requested_attribute
|
||||
&& attr.vals.contains(&request.val)
|
||||
});
|
||||
let available = entry
|
||||
.attributes
|
||||
.iter()
|
||||
.any(|attr| attr.atype == request.atype && attr.vals.contains(&request.val));
|
||||
Ok(vec![LdapOp::CompareResult(LdapResultOp {
|
||||
code: if available {
|
||||
LdapResultCode::CompareTrue
|
||||
@@ -843,13 +826,6 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
|
||||
.await
|
||||
.unwrap_or_else(|e: LdapError| vec![make_search_error(e.code, e.message)]),
|
||||
LdapOp::UnbindRequest => {
|
||||
debug!(
|
||||
"Unbind request for {}",
|
||||
self.user_info
|
||||
.as_ref()
|
||||
.map(|u| u.user.as_str())
|
||||
.unwrap_or("<not bound>"),
|
||||
);
|
||||
self.user_info = None;
|
||||
// No need to notify on unbind (per rfc4511)
|
||||
return None;
|
||||
@@ -1308,6 +1284,32 @@ mod tests {
|
||||
LdapOp::SearchResultEntry(LdapSearchResultEntry {
|
||||
dn: "uid=bob_1,ou=people,dc=example,dc=com".to_string(),
|
||||
attributes: vec![
|
||||
LdapPartialAttribute {
|
||||
atype: "objectClass".to_string(),
|
||||
vals: vec![
|
||||
b"inetOrgPerson".to_vec(),
|
||||
b"posixAccount".to_vec(),
|
||||
b"mailAccount".to_vec(),
|
||||
b"person".to_vec(),
|
||||
b"customUserClass".to_vec(),
|
||||
]
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "uid".to_string(),
|
||||
vals: vec![b"bob_1".to_vec()]
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "mail".to_string(),
|
||||
vals: vec![b"bob@bobmail.bob".to_vec()]
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "givenName".to_string(),
|
||||
vals: vec!["Bôb".to_string().into_bytes()]
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "sn".to_string(),
|
||||
vals: vec!["Böbberson".to_string().into_bytes()]
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "cn".to_string(),
|
||||
vals: vec!["Bôb Böbberson".to_string().into_bytes()]
|
||||
@@ -1320,14 +1322,11 @@ mod tests {
|
||||
atype: "entryUuid".to_string(),
|
||||
vals: vec![b"698e1d5f-7a40-3151-8745-b9b8a37839da".to_vec()]
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "givenName".to_string(),
|
||||
vals: vec!["Bôb".to_string().into_bytes()]
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "mail".to_string(),
|
||||
vals: vec![b"bob@bobmail.bob".to_vec()]
|
||||
},
|
||||
],
|
||||
}),
|
||||
LdapOp::SearchResultEntry(LdapSearchResultEntry {
|
||||
dn: "uid=jim,ou=people,dc=example,dc=com".to_string(),
|
||||
attributes: vec![
|
||||
LdapPartialAttribute {
|
||||
atype: "objectClass".to_string(),
|
||||
vals: vec![
|
||||
@@ -1339,18 +1338,21 @@ mod tests {
|
||||
]
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "sn".to_string(),
|
||||
vals: vec!["Böbberson".to_string().into_bytes()]
|
||||
atype: "uid".to_string(),
|
||||
vals: vec![b"jim".to_vec()]
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "uid".to_string(),
|
||||
vals: vec![b"bob_1".to_vec()]
|
||||
atype: "mail".to_string(),
|
||||
vals: vec![b"jim@cricket.jim".to_vec()]
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "givenName".to_string(),
|
||||
vals: vec![b"Jim".to_vec()]
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "sn".to_string(),
|
||||
vals: vec![b"Cricket".to_vec()]
|
||||
},
|
||||
],
|
||||
}),
|
||||
LdapOp::SearchResultEntry(LdapSearchResultEntry {
|
||||
dn: "uid=jim,ou=people,dc=example,dc=com".to_string(),
|
||||
attributes: vec![
|
||||
LdapPartialAttribute {
|
||||
atype: "cn".to_string(),
|
||||
vals: vec![b"Jimminy Cricket".to_vec()]
|
||||
@@ -1363,36 +1365,10 @@ mod tests {
|
||||
atype: "entryUuid".to_string(),
|
||||
vals: vec![b"04ac75e0-2900-3e21-926c-2f732c26b3fc".to_vec()]
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "givenName".to_string(),
|
||||
vals: vec![b"Jim".to_vec()]
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "jpegPhoto".to_string(),
|
||||
vals: vec![JpegPhoto::for_tests().into_bytes()]
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "mail".to_string(),
|
||||
vals: vec![b"jim@cricket.jim".to_vec()]
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "objectClass".to_string(),
|
||||
vals: vec![
|
||||
b"inetOrgPerson".to_vec(),
|
||||
b"posixAccount".to_vec(),
|
||||
b"mailAccount".to_vec(),
|
||||
b"person".to_vec(),
|
||||
b"customUserClass".to_vec(),
|
||||
]
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "sn".to_string(),
|
||||
vals: vec![b"Cricket".to_vec()]
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "uid".to_string(),
|
||||
vals: vec![b"jim".to_vec()]
|
||||
},
|
||||
],
|
||||
}),
|
||||
make_search_success(),
|
||||
@@ -1444,22 +1420,14 @@ mod tests {
|
||||
LdapOp::SearchResultEntry(LdapSearchResultEntry {
|
||||
dn: "cn=group_1,ou=groups,dc=example,dc=com".to_string(),
|
||||
attributes: vec![
|
||||
LdapPartialAttribute {
|
||||
atype: "cn".to_string(),
|
||||
vals: vec![b"group_1".to_vec()]
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "entryDN".to_string(),
|
||||
vals: vec![b"uid=group_1,ou=groups,dc=example,dc=com".to_vec()],
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "entryUuid".to_string(),
|
||||
vals: vec![b"04ac75e0-2900-3e21-926c-2f732c26b3fc".to_vec()],
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "objectClass".to_string(),
|
||||
vals: vec![b"groupOfUniqueNames".to_vec(),]
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "cn".to_string(),
|
||||
vals: vec![b"group_1".to_vec()]
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "uniqueMember".to_string(),
|
||||
vals: vec![
|
||||
@@ -1467,30 +1435,38 @@ mod tests {
|
||||
b"uid=john,ou=people,dc=example,dc=com".to_vec(),
|
||||
]
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "entryUuid".to_string(),
|
||||
vals: vec![b"04ac75e0-2900-3e21-926c-2f732c26b3fc".to_vec()],
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "entryDN".to_string(),
|
||||
vals: vec![b"uid=group_1,ou=groups,dc=example,dc=com".to_vec()],
|
||||
},
|
||||
],
|
||||
}),
|
||||
LdapOp::SearchResultEntry(LdapSearchResultEntry {
|
||||
dn: "cn=BestGroup,ou=groups,dc=example,dc=com".to_string(),
|
||||
attributes: vec![
|
||||
LdapPartialAttribute {
|
||||
atype: "objectClass".to_string(),
|
||||
vals: vec![b"groupOfUniqueNames".to_vec(),]
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "cn".to_string(),
|
||||
vals: vec![b"BestGroup".to_vec()]
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "entryDN".to_string(),
|
||||
vals: vec![b"uid=BestGroup,ou=groups,dc=example,dc=com".to_vec()],
|
||||
atype: "uniqueMember".to_string(),
|
||||
vals: vec![b"uid=john,ou=people,dc=example,dc=com".to_vec()]
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "entryUuid".to_string(),
|
||||
vals: vec![b"04ac75e0-2900-3e21-926c-2f732c26b3fc".to_vec()],
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "objectClass".to_string(),
|
||||
vals: vec![b"groupOfUniqueNames".to_vec(),]
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "uniqueMember".to_string(),
|
||||
vals: vec![b"uid=john,ou=people,dc=example,dc=com".to_vec()]
|
||||
atype: "entryDN".to_string(),
|
||||
vals: vec![b"uid=BestGroup,ou=groups,dc=example,dc=com".to_vec()],
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -1864,8 +1840,8 @@ mod tests {
|
||||
eq(Some(UserRequestFilter::MemberOf("group_1".into()))),
|
||||
eq(false),
|
||||
)
|
||||
.times(2)
|
||||
.returning(|_, _| Ok(vec![]));
|
||||
.times(1)
|
||||
.return_once(|_, _| Ok(vec![]));
|
||||
let mut ldap_handler = setup_bound_admin_handler(mock).await;
|
||||
let request = make_user_search_request(
|
||||
LdapFilter::Equality(
|
||||
@@ -1884,17 +1860,11 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
ldap_handler.do_search_or_dse(&request).await,
|
||||
Ok(vec![make_search_success()])
|
||||
Err(LdapError {
|
||||
code: LdapResultCode::InvalidDNSyntax,
|
||||
message: "Missing DN value".to_string()
|
||||
})
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn test_search_member_of_filter_error() {
|
||||
let mut mock = MockTestBackendHandler::new();
|
||||
mock.expect_list_users()
|
||||
.with(eq(Some(UserRequestFilter::from(false))), eq(false))
|
||||
.times(1)
|
||||
.returning(|_, _| Ok(vec![]));
|
||||
let mut ldap_handler = setup_bound_admin_handler(mock).await;
|
||||
let request = make_user_search_request(
|
||||
LdapFilter::Equality(
|
||||
"memberOf".to_string(),
|
||||
@@ -1904,8 +1874,10 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
ldap_handler.do_search_or_dse(&request).await,
|
||||
// The error is ignored, a warning is printed.
|
||||
Ok(vec![make_search_success()])
|
||||
Err(LdapError{
|
||||
code: LdapResultCode::InvalidDNSyntax,
|
||||
message: r#"Unexpected DN format. Got "cn=mygroup,dc=example,dc=com", expected: "uid=id,ou=groups,dc=example,dc=com""#.to_string()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2049,10 +2021,6 @@ mod tests {
|
||||
LdapOp::SearchResultEntry(LdapSearchResultEntry {
|
||||
dn: "uid=bob_1,ou=people,dc=example,dc=com".to_string(),
|
||||
attributes: vec![
|
||||
LdapPartialAttribute {
|
||||
atype: "cn".to_string(),
|
||||
vals: vec!["Bôb Böbberson".to_string().into_bytes()]
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "objectClass".to_string(),
|
||||
vals: vec![
|
||||
@@ -2061,21 +2029,25 @@ mod tests {
|
||||
b"mailAccount".to_vec(),
|
||||
b"person".to_vec(),
|
||||
b"customUserClass".to_vec(),
|
||||
],
|
||||
]
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "cn".to_string(),
|
||||
vals: vec!["Bôb Böbberson".to_string().into_bytes()]
|
||||
},
|
||||
],
|
||||
}),
|
||||
LdapOp::SearchResultEntry(LdapSearchResultEntry {
|
||||
dn: "cn=group_1,ou=groups,dc=example,dc=com".to_string(),
|
||||
attributes: vec![
|
||||
LdapPartialAttribute {
|
||||
atype: "cn".to_string(),
|
||||
vals: vec![b"group_1".to_vec()]
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "objectClass".to_string(),
|
||||
vals: vec![b"groupOfUniqueNames".to_vec(),]
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "cn".to_string(),
|
||||
vals: vec![b"group_1".to_vec()]
|
||||
},
|
||||
],
|
||||
}),
|
||||
make_search_success(),
|
||||
@@ -2135,13 +2107,35 @@ mod tests {
|
||||
dn: "uid=bob_1,ou=people,dc=example,dc=com".to_string(),
|
||||
attributes: vec![
|
||||
LdapPartialAttribute {
|
||||
atype: "avatar".to_string(),
|
||||
vals: vec![JpegPhoto::for_tests().into_bytes()],
|
||||
atype: "objectclass".to_string(),
|
||||
vals: vec![
|
||||
b"inetOrgPerson".to_vec(),
|
||||
b"posixAccount".to_vec(),
|
||||
b"mailAccount".to_vec(),
|
||||
b"person".to_vec(),
|
||||
b"customUserClass".to_vec(),
|
||||
],
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "uid".to_string(),
|
||||
vals: vec![b"bob_1".to_vec()],
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "mail".to_string(),
|
||||
vals: vec![b"bob@bobmail.bob".to_vec()],
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "sn".to_string(),
|
||||
vals: vec!["Böbberson".to_string().into_bytes()],
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "cn".to_string(),
|
||||
vals: vec!["Bôb Böbberson".to_string().into_bytes()],
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "jpegPhoto".to_string(),
|
||||
vals: vec![JpegPhoto::for_tests().into_bytes()],
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "createtimestamp".to_string(),
|
||||
vals: vec![chrono::Utc
|
||||
@@ -2154,58 +2148,12 @@ mod tests {
|
||||
atype: "entryuuid".to_string(),
|
||||
vals: vec![b"b4ac75e0-2900-3e21-926c-2f732c26b3fc".to_vec()],
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "jpegPhoto".to_string(),
|
||||
vals: vec![JpegPhoto::for_tests().into_bytes()],
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "last_name".to_string(),
|
||||
vals: vec!["Böbberson".to_string().into_bytes()],
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "mail".to_string(),
|
||||
vals: vec![b"bob@bobmail.bob".to_vec()],
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "objectclass".to_string(),
|
||||
vals: vec![
|
||||
b"inetOrgPerson".to_vec(),
|
||||
b"posixAccount".to_vec(),
|
||||
b"mailAccount".to_vec(),
|
||||
b"person".to_vec(),
|
||||
b"customUserClass".to_vec(),
|
||||
],
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "sn".to_string(),
|
||||
vals: vec!["Böbberson".to_string().into_bytes()],
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "uid".to_string(),
|
||||
vals: vec![b"bob_1".to_vec()],
|
||||
},
|
||||
],
|
||||
}),
|
||||
// "objectclass", "dn", "uid", "cn", "member", "uniquemember"
|
||||
LdapOp::SearchResultEntry(LdapSearchResultEntry {
|
||||
dn: "cn=group_1,ou=groups,dc=example,dc=com".to_string(),
|
||||
attributes: vec![
|
||||
LdapPartialAttribute {
|
||||
atype: "cn".to_string(),
|
||||
vals: vec![b"group_1".to_vec()],
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "entryuuid".to_string(),
|
||||
vals: vec![b"04ac75e0-2900-3e21-926c-2f732c26b3fc".to_vec()],
|
||||
},
|
||||
//member / uniquemember : "uid={},ou=people,{}"
|
||||
LdapPartialAttribute {
|
||||
atype: "member".to_string(),
|
||||
vals: vec![
|
||||
b"uid=bob,ou=people,dc=example,dc=com".to_vec(),
|
||||
b"uid=john,ou=people,dc=example,dc=com".to_vec(),
|
||||
],
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "objectclass".to_string(),
|
||||
vals: vec![b"groupOfUniqueNames".to_vec()],
|
||||
@@ -2215,6 +2163,18 @@ mod tests {
|
||||
atype: "uid".to_string(),
|
||||
vals: vec![b"group_1".to_vec()],
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "cn".to_string(),
|
||||
vals: vec![b"group_1".to_vec()],
|
||||
},
|
||||
//member / uniquemember : "uid={},ou=people,{}"
|
||||
LdapPartialAttribute {
|
||||
atype: "member".to_string(),
|
||||
vals: vec![
|
||||
b"uid=bob,ou=people,dc=example,dc=com".to_vec(),
|
||||
b"uid=john,ou=people,dc=example,dc=com".to_vec(),
|
||||
],
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "uniquemember".to_string(),
|
||||
vals: vec![
|
||||
@@ -2222,6 +2182,10 @@ mod tests {
|
||||
b"uid=john,ou=people,dc=example,dc=com".to_vec(),
|
||||
],
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "entryuuid".to_string(),
|
||||
vals: vec![b"04ac75e0-2900-3e21-926c-2f732c26b3fc".to_vec()],
|
||||
},
|
||||
],
|
||||
}),
|
||||
make_search_success(),
|
||||
@@ -2919,7 +2883,6 @@ mod tests {
|
||||
is_visible: true,
|
||||
is_editable: true,
|
||||
is_hardcoded: false,
|
||||
is_readonly: false,
|
||||
}],
|
||||
},
|
||||
group_attributes: AttributeList {
|
||||
@@ -2930,7 +2893,6 @@ mod tests {
|
||||
is_visible: true,
|
||||
is_editable: true,
|
||||
is_hardcoded: false,
|
||||
is_readonly: false,
|
||||
}],
|
||||
},
|
||||
extra_user_object_classes: vec![
|
||||
@@ -2953,27 +2915,27 @@ mod tests {
|
||||
LdapOp::SearchResultEntry(LdapSearchResultEntry {
|
||||
dn: "uid=test,ou=people,dc=example,dc=com".to_string(),
|
||||
attributes: vec![
|
||||
LdapPartialAttribute {
|
||||
atype: "nickname".to_owned(),
|
||||
vals: vec![b"Bob the Builder".to_vec()],
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "uid".to_owned(),
|
||||
vals: vec![b"test".to_vec()],
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "nickname".to_owned(),
|
||||
vals: vec![b"Bob the Builder".to_vec()],
|
||||
},
|
||||
],
|
||||
}),
|
||||
LdapOp::SearchResultEntry(LdapSearchResultEntry {
|
||||
dn: "cn=group,ou=groups,dc=example,dc=com".to_owned(),
|
||||
attributes: vec![
|
||||
LdapPartialAttribute {
|
||||
atype: "club_name".to_owned(),
|
||||
vals: vec![b"Breakfast Club".to_vec()],
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "uid".to_owned(),
|
||||
vals: vec![b"group".to_vec()],
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "club_name".to_owned(),
|
||||
vals: vec![b"Breakfast Club".to_vec()],
|
||||
},
|
||||
],
|
||||
}),
|
||||
make_search_success()
|
||||
|
||||
@@ -19,9 +19,8 @@ use rustls::PrivateKey;
|
||||
use tokio_rustls::TlsAcceptor as RustlsTlsAcceptor;
|
||||
use tokio_util::codec::{FramedRead, FramedWrite};
|
||||
use tracing::{debug, error, info, instrument};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[instrument(skip_all, level = "info", name = "LDAP request", fields(session_id = %session.session_uuid()))]
|
||||
#[instrument(skip_all, level = "info", name = "LDAP request")]
|
||||
async fn handle_ldap_message<Backend, Writer>(
|
||||
msg: Result<LdapMsg, std::io::Error>,
|
||||
resp: &mut Writer,
|
||||
@@ -34,11 +33,6 @@ where
|
||||
{
|
||||
use futures_util::SinkExt;
|
||||
let msg = msg.context("while receiving LDAP op")?;
|
||||
for control in msg.ctrl.iter() {
|
||||
if let LdapControl::Unknown { oid, .. } = control {
|
||||
info!("Received unknown control: {}, ignoring", oid);
|
||||
}
|
||||
}
|
||||
debug!(?msg);
|
||||
match session.handle_ldap_message(msg.op).await {
|
||||
None => return Ok(false),
|
||||
@@ -74,6 +68,7 @@ where
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "info", name = "LDAP session")]
|
||||
async fn handle_ldap_stream<Stream, Backend>(
|
||||
stream: Stream,
|
||||
backend_handler: Backend,
|
||||
@@ -91,16 +86,13 @@ where
|
||||
let mut requests = FramedRead::new(r, LdapCodec::default());
|
||||
let mut resp = FramedWrite::new(w, LdapCodec::default());
|
||||
|
||||
let session_uuid = Uuid::new_v4();
|
||||
let mut session = LdapHandler::new(
|
||||
AccessControlledBackendHandler::new(backend_handler),
|
||||
ldap_base_dn,
|
||||
ignored_user_attributes,
|
||||
ignored_group_attributes,
|
||||
session_uuid,
|
||||
);
|
||||
|
||||
info!("LDAP session start: {}", session_uuid);
|
||||
while let Some(msg) = requests.next().await {
|
||||
if !handle_ldap_message(msg, &mut resp, &mut session)
|
||||
.await
|
||||
@@ -109,7 +101,6 @@ where
|
||||
break;
|
||||
}
|
||||
}
|
||||
info!("LDAP session end: {}", session_uuid);
|
||||
Ok(requests.into_inner().unsplit(resp.into_inner()))
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ use actix_web::{
|
||||
dev::{ServiceRequest, ServiceResponse},
|
||||
Error,
|
||||
};
|
||||
use std::env;
|
||||
use tracing::{debug, error, Span};
|
||||
use tracing_actix_web::RootSpanBuilder;
|
||||
use tracing_subscriber::{filter::EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
|
||||
@@ -43,14 +42,10 @@ pub fn init(config: &Configuration) -> anyhow::Result<()> {
|
||||
"sqlx=warn,reqwest=warn,info"
|
||||
})
|
||||
});
|
||||
let registry = tracing_subscriber::registry()
|
||||
tracing_subscriber::registry()
|
||||
.with(env_filter)
|
||||
.with(tracing_forest::ForestLayer::default());
|
||||
if env::var("LLDAP_RAW_LOG").is_ok() {
|
||||
registry.with(tracing_subscriber::fmt::layer()).init();
|
||||
} else {
|
||||
registry.init();
|
||||
}
|
||||
.with(tracing_forest::ForestLayer::default())
|
||||
.init();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@ use lettre::{
|
||||
message::Mailbox, transport::smtp::authentication::Credentials, AsyncSmtpTransport,
|
||||
AsyncTransport, Message, Tokio1Executor,
|
||||
};
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
use tracing::debug;
|
||||
|
||||
async fn send_email(
|
||||
@@ -33,8 +31,8 @@ async fn send_email(
|
||||
),
|
||||
server_url.domain().unwrap_or_default()
|
||||
)))
|
||||
.from(from.0)
|
||||
.reply_to(reply_to.0)
|
||||
.from(from)
|
||||
.reply_to(reply_to)
|
||||
.to(to)
|
||||
.subject(subject)
|
||||
.singlepart(
|
||||
@@ -60,20 +58,11 @@ async fn send_email(
|
||||
}
|
||||
|
||||
if let Err(e) = mailer.port(options.port).build().send(email).await {
|
||||
debug!("Error sending email: {:?}", e);
|
||||
let message = e.to_string();
|
||||
Err(anyhow!(
|
||||
"{}: {}",
|
||||
if message.contains("CorruptMessage")
|
||||
|| message.contains("corrupt message")
|
||||
|| message.contains("incomplete response")
|
||||
{
|
||||
"SMTP protocol error, this usually means the SMTP encryption setting is wrong. Try TLS with port 465 or STARTTLS with port 587"
|
||||
} else {
|
||||
"Error sending email"
|
||||
},
|
||||
message
|
||||
))
|
||||
if e.to_string().contains("CorruptMessage") {
|
||||
Err(anyhow!("CorruptMessage returned by lettre, this usually means the SMTP encryption setting is wrong.").context(e))
|
||||
} else {
|
||||
Err(e.into())
|
||||
}
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
@@ -103,18 +92,14 @@ To reset your password please visit the following URL: {}
|
||||
Please contact an administrator if you did not initiate the process.",
|
||||
username, reset_url
|
||||
);
|
||||
let res = send_email(
|
||||
send_email(
|
||||
to,
|
||||
"[LLDAP] Password reset requested",
|
||||
body,
|
||||
options,
|
||||
server_url,
|
||||
)
|
||||
.await;
|
||||
if res.is_err() {
|
||||
sleep(Duration::from_secs(3)).await;
|
||||
}
|
||||
res
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn send_test_email(to: Mailbox, options: &MailOptions) -> Result<()> {
|
||||
|
||||
@@ -194,7 +194,7 @@ where
|
||||
.get_jwt_blacklist()
|
||||
.await
|
||||
.context("while getting the jwt blacklist")?;
|
||||
let server_url = config.http_url.0.clone();
|
||||
let server_url = config.http_url.clone();
|
||||
let mail_options = config.smtp_options.clone();
|
||||
let verbose = config.verbose;
|
||||
info!("Starting the API/web server on port {}", config.http_port);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user