27 Commits

Author SHA1 Message Date
Austin Alvarado
7119d96712 app: create avatar component and reorganize a little bit (#830)
* Create avatar component and reorganize a little bit

* html fmt

* fmt
2024-02-05 15:51:43 +00:00
Valentin Tolmer
2973529c97 github: Improve codecov integration with better config 2024-02-05 15:51:43 +00:00
Valentin Tolmer
442c70b6d2 server: Fix panic due to database collation
When the database's collation is not "C", the DB order is not the same as the
Rust order. As such, asserting that the elements are in increasing order fails.
However, since both queries get the order from the database, they should be in
the same order.

With too many users, the query had a giant filter `IN (u1, u2, u3,
...)`. In PostgreSQL, we can pass the users as an array instead, but that
doesn't work with SQLite. Instead, we repeat the filter from the
previous query to get the same users/groups, as a subquery.
2024-02-05 15:51:43 +00:00
Austin Alvarado
64140b4939 app: create group attribute schema page (#825) 2024-02-05 15:51:43 +00:00
shroomify-it
6ebeee4126 example_configs: add radicale DAV server to the readme 2024-02-05 15:51:26 +00:00
shroomify-it
a05ae617a1 example_configs: Create radicale.md 2024-02-05 15:51:26 +00:00
Austin Alvarado
7538059f6a app: update forms to use new components (#818) 2024-02-05 15:51:26 +00:00
Austin Alvarado
ee4a62e1e2 server: remove debug print 2024-02-05 15:51:05 +00:00
dependabot[bot]
8a6ce87fb5 build(deps): bump peter-evans/dockerhub-description from 3 to 4
Bumps [peter-evans/dockerhub-description](https://github.com/peter-evans/dockerhub-description) from 3 to 4.
- [Release notes](https://github.com/peter-evans/dockerhub-description/releases)
- [Commits](https://github.com/peter-evans/dockerhub-description/compare/v3...v4)

---
updated-dependencies:
- dependency-name: peter-evans/dockerhub-description
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-05 15:51:05 +00:00
HighwayStar
af670dbc93 example_configs: Fix docker-mailserver example
* Fixes following issues:
 - double braces around mail= filter cause:
 ldap_search_ext: Bad search filter (-7)
 - too wide/upper level base DN cause, changed to ou= level helps
 result: 53 Server is unwilling to perform
 text: Unsupported group attribute for substring filter: "mail"
2024-02-05 15:51:05 +00:00
Valentin Tolmer
5840b3009d server: Clean up main, make more functions async 2024-02-05 15:51:05 +00:00
Austin Alvarado
18f814ba02 app: add user attributes schema page (#802) 2024-02-05 15:51:05 +00:00
Austin Alvarado
b55caae3cc popped stash 2024-02-05 15:42:29 +00:00
Valentin Tolmer
93b4840e93 server: Only call expand_attributes at most once per request 2024-01-23 04:54:31 +00:00
Valentin Tolmer
e0e0da9ebf server: Treat the database password as a secret 2024-01-23 04:54:31 +00:00
Valentin Tolmer
3316f54133 server: don't error on global searches if only one side fails 2024-01-23 04:54:31 +00:00
Valentin Tolmer
c012c2891b server: Add the attribute schema to the attributes in graphql
And make sure that we only request the schema once per top-level query
2024-01-23 04:54:31 +00:00
Austin Alvarado
d459ac0c78 putting a pin in it 2024-01-23 04:11:53 +00:00
Austin Alvarado
c9f9a687a3 add schema to user details query 2024-01-20 18:46:17 +00:00
elmodor
4c47d06c9b Added maddy example config
Updated README.md for Maddy

i
2024-01-20 18:46:17 +00:00
Austin Alvarado
e88db526b4 split tables 2024-01-19 22:10:59 +00:00
Austin Alvarado
e947b8eef0 Refactor + review feedback 2024-01-19 20:58:59 +00:00
Austin Alvarado
ee72b571d0 Clippy fixes 2024-01-18 05:59:59 +00:00
Austin Alvarado
cf492db570 Merge branch 'main' into attributes-ui 2024-01-17 22:47:27 -07:00
Valentin Tolmer
6120a0dca5 server: clean up the attributes, relax the substring filter conditions
This consolidates both user and group attributes in their map_{user,group}_attribute as the only point of parsing. It adds support for custom attribute filters for groups, and makes a SubString filter on an unknown attribute resolve to just false.
2024-01-18 05:46:56 +00:00
dependabot[bot]
523d418459 build(deps): bump actions/cache from 3 to 4
Bumps [actions/cache](https://github.com/actions/cache) from 3 to 4.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v3...v4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-18 05:46:56 +00:00
Austin Alvarado
55225bc15b ui: add user attributes page
todo
2024-01-18 05:41:06 +00:00
108 changed files with 2223 additions and 5864 deletions

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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' }}

View File

@@ -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 }}

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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
View File

@@ -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)

View File

@@ -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"
@@ -37,11 +37,9 @@ version = "0.3"
features = [
"Document",
"Element",
"Event",
"FileReader",
"FormData",
"HtmlDocument",
"HtmlFormElement",
"HtmlInputElement",
"HtmlOptionElement",
"HtmlOptionsCollection",
@@ -75,10 +73,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']

View File

@@ -1,5 +1,5 @@
mutation CreateGroup($group: CreateGroupInput!) {
createGroupWithDetails(request: $group) {
mutation CreateGroup($name: String!) {
createGroup(name: $name) {
id
displayName
}

View File

@@ -7,7 +7,6 @@ query GetGroupAttributesSchema {
isList
isVisible
isHardcoded
isReadonly
}
}
}

View File

@@ -8,22 +8,5 @@ query GetGroupDetails($id: Int!) {
id
displayName
}
attributes {
name
value
}
}
schema {
groupSchema {
attributes {
name
attributeType
isList
isVisible
isEditable
isHardcoded
isReadonly
}
}
}
}

View File

@@ -8,7 +8,6 @@ query GetUserAttributesSchema {
isVisible
isEditable
isHardcoded
isReadonly
}
}
}

View File

@@ -2,29 +2,28 @@ query GetUserDetails($id: String!) {
user(userId: $id) {
id
email
avatar
displayName
firstName
lastName
avatar
creationDate
uuid
groups {
id
displayName
}
}
attributes {
name
value
}
}
schema {
userSchema {
user_attrubutes {
attributes {
name
attributeType
isList
isVisible
isEditable
isHardcoded
isReadonly
}
}
}

View File

@@ -1,6 +0,0 @@
mutation UpdateGroup($group: UpdateGroupInput!) {
updateGroup(group: $group) {
ok
}
}

View File

@@ -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! {

View File

@@ -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} />

View File

@@ -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"
)]

View File

@@ -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())}
/>
}
}
}

View File

@@ -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())}
/>
}
}
}

View File

@@ -1,12 +1,15 @@
use crate::{
components::form::{date_input::DateTimeInput, file_input::JpegFileInput},
infra::{schema::AttributeType, tooltip::Tooltip},
};
use web_sys::Element;
use yew::{
function_component, html, use_effect_with_deps, use_node_ref, virtual_dom::AttrValue,
Component, Context, Html, Properties,
};
use yew::{function_component, html, virtual_dom::AttrValue, Callback, InputEvent, Properties, NodeRef};
use crate::infra::schema::AttributeType;
/*
<input
ref={&ctx.props().input_ref}
type="text"
class="input-component"
placeholder={placeholder}
onmouseover={ctx.link().callback(|_| Msg::Hover)}
/>
*/
#[derive(Properties, PartialEq)]
struct AttributeInputProps {
@@ -21,67 +24,26 @@ 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,
pub name: AttrValue,
pub attribute_type: AttributeType,
#[prop_or(None)]
pub value: Option<String>,
@@ -91,100 +53,16 @@ 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()}
name={props.name.clone()}
value={props.value.clone()} />
attribute_type={props.attribute_type}
name={props.name}
value={props.value} />
</div>
</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>
}
}
}

View File

@@ -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>
}
}

View File

@@ -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()
}

View File

@@ -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;

View File

@@ -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>
}

View File

@@ -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)
}
}

View File

@@ -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;

View File

@@ -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! {

View File

@@ -5,11 +5,7 @@ use crate::{
router::{AppRoute, Link},
user_details_form::UserDetailsForm,
},
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;
@@ -26,34 +22,12 @@ 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;
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 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 +50,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 +178,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 +189,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 +207,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>},
}
}
}

View File

@@ -1,21 +1,57 @@
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::{field::Field, static_value::StaticValue, submit::Submit},
user_details::User,
},
infra::common_component::{CommonComponent, CommonComponentParts},
};
use anyhow::{bail, Error, Result};
use gloo_file::{
callbacks::{read_as_bytes, FileReader},
File,
};
use anyhow::{Ok, Result};
use graphql_client::GraphQLQuery;
use validator_derive::Validate;
use web_sys::{FileList, HtmlInputElement, InputEvent, SubmitEvent};
use yew::prelude::*;
use yew_form_derive::Model;
use gloo_console::log;
#[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,28 +67,36 @@ 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,
form_ref: NodeRef,
}
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>),
/// The "Submit" button was clicked.
OnSubmit(SubmitEvent),
}
#[derive(yew::Properties, Clone, PartialEq, Eq)]
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,10 +107,55 @@ impl CommonComponent<UserDetailsForm> for UserDetailsForm {
) -> Result<bool> {
match msg {
Msg::Update => Ok(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::UserUpdated(Err(e)) => Err(e),
Msg::UserUpdated(Result::Ok(_)) => {
self.just_updated = true;
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) => {
log!(format!("{:#?}", e));
Ok(true)
}
}
@@ -82,11 +171,19 @@ 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 +195,97 @@ 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" onsubmit={link.callback(|e: SubmitEvent| {e.prevent_default(); Msg::OnSubmit(e)})}>
<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>
<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 +304,19 @@ 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()}
/>
}
}
}
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>
}
}
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
.iter()
.find(|base_val| base_val.name == a.name);
base_val
.map(|v| v.value != a.values)
.unwrap_or(!a.values.is_empty())
});
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_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(),
)
};
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 base_user = &self.user;
let mut user_input = update_user::UpdateUserInput {
id: self.user.id.clone(),
email: None,
@@ -257,8 +328,23 @@ impl UserDetailsForm {
insertAttributes: None,
};
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 +358,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))
}
}
}

View File

@@ -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};
@@ -16,32 +16,25 @@ fn get_claims_from_jwt(jwt: &str) -> Result<JWTClaims> {
Ok(token.claims().clone())
}
enum RequestType<Body: Serialize> {
Get,
Post(Body),
}
const GET_REQUEST: RequestType<()> = RequestType::Get;
const NO_BODY: Option<()> = None;
fn base_url() -> String {
yew_router::utils::base_url().unwrap_or_default()
}
async fn call_server<Body: Serialize>(
async fn call_server(
url: &str,
body: RequestType<Body>,
body: Option<impl Serialize>,
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 Some(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?)
@@ -58,7 +51,7 @@ async fn call_server<Body: Serialize>(
async fn call_server_json_with_error_message<CallbackResult, Body: Serialize>(
url: &str,
request: RequestType<Body>,
request: Option<Body>,
error_message: &'static str,
) -> Result<CallbackResult>
where
@@ -70,7 +63,7 @@ where
async fn call_server_empty_response_with_error_message<Body: Serialize>(
url: &str,
request: RequestType<Body>,
request: Option<Body>,
error_message: &'static str,
) -> Result<()> {
call_server(url, request, error_message).await.map(|_| ())
@@ -109,7 +102,7 @@ impl HostService {
let request_body = QueryType::build_query(variables);
call_server_json_with_error_message::<graphql_client::Response<_>, _>(
&(base_url() + "/api/graphql"),
RequestType::Post(request_body),
Some(request_body),
error_message,
)
.await
@@ -121,7 +114,7 @@ impl HostService {
) -> Result<Box<login::ServerLoginStartResponse>> {
call_server_json_with_error_message(
&(base_url() + "/auth/opaque/login/start"),
RequestType::Post(request),
Some(request),
"Could not start authentication: ",
)
.await
@@ -130,7 +123,7 @@ impl HostService {
pub async fn login_finish(request: login::ClientLoginFinishRequest) -> Result<(String, bool)> {
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
&(base_url() + "/auth/opaque/login/finish"),
RequestType::Post(request),
Some(request),
"Could not finish authentication",
)
.await
@@ -142,7 +135,7 @@ impl HostService {
) -> Result<Box<registration::ServerRegistrationStartResponse>> {
call_server_json_with_error_message(
&(base_url() + "/auth/opaque/register/start"),
RequestType::Post(request),
Some(request),
"Could not start registration: ",
)
.await
@@ -153,7 +146,7 @@ impl HostService {
) -> Result<()> {
call_server_empty_response_with_error_message(
&(base_url() + "/auth/opaque/register/finish"),
RequestType::Post(request),
Some(request),
"Could not finish registration",
)
.await
@@ -162,7 +155,7 @@ impl HostService {
pub async fn refresh() -> Result<(String, bool)> {
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
&(base_url() + "/auth/refresh"),
GET_REQUEST,
NO_BODY,
"Could not start authentication: ",
)
.await
@@ -173,7 +166,7 @@ impl HostService {
pub async fn logout() -> Result<()> {
call_server_empty_response_with_error_message(
&(base_url() + "/auth/logout"),
GET_REQUEST,
NO_BODY,
"Could not logout",
)
.await
@@ -186,7 +179,7 @@ impl HostService {
base_url(),
url_escape::encode_query(&username)
),
RequestType::Post(""),
NO_BODY,
"Could not initiate password reset",
)
.await
@@ -197,14 +190,14 @@ impl HostService {
) -> Result<lldap_auth::password_reset::ServerPasswordResetResponse> {
call_server_json_with_error_message(
&format!("{}/auth/reset/step2/{}", base_url(), token),
GET_REQUEST,
NO_BODY,
"Could not validate token",
)
.await
}
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")

View File

@@ -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)
}

View File

@@ -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, UseStateHandle};
// Enum to represent a result that is fetched asynchronously.
#[derive(Debug)]
@@ -13,47 +13,26 @@ pub enum LoadableResult<T> {
Loaded(Result<T>),
}
impl<T: PartialEq> PartialEq for LoadableResult<T> {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(LoadableResult::Loading, LoadableResult::Loading) => true,
(LoadableResult::Loaded(Ok(d1)), LoadableResult::Loaded(Ok(d2))) => d1.eq(d2),
(LoadableResult::Loaded(Err(e1)), LoadableResult::Loaded(Err(e2))) => {
e1.to_string().eq(&e2.to_string())
}
_ => false,
}
}
}
pub fn use_graphql_call<QueryType>(
variables: QueryType::Variables,
) -> 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);
use_state(|| 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()
}

View File

@@ -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;

View File

@@ -1,5 +1,3 @@
#![allow(clippy::empty_docs)]
use wasm_bindgen::prelude::*;
#[wasm_bindgen]

View File

@@ -34,33 +34,63 @@ 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,
}
}
}
};
}
<<<<<<< HEAD
#[derive(Clone, PartialEq, Eq)]
pub struct Attribute {
pub name: String,
pub value: Vec<String>,
pub attribute_type: AttributeType,
pub is_list: bool,
pub is_editable: bool,
pub is_hardcoded: bool,
}
// Macro to generate traits for converting between AttributeType and the
// graphql generated equivalents.
#[macro_export]
macro_rules! combine_schema_and_values {
($schema_list:ident, $value_list:ident, $output_list:ident) => {
let set_attributes = value_list.clone();
let mut attribute_schema = schema_list.clone();
attribute_schema.retain(|schema| !schema.is_hardcoded);
let $output_list = attribute_schema.into_iter().map(|schema| {
Attribute {
name: schema.name.clone(),
value: set_attributes.iter().find(|attribute_value| attribute_value.name == schema.name).unwrap().value.clone(),
attribute_type: AttributeType::from(schema.attribute_type),
is_list: schema.is_list,
}
}).collect();
};
=======
pub fn validate_attribute_type(attribute_type: &str) -> Result<(), ValidationError> {
AttributeType::from_str(attribute_type)
.map_err(|_| ValidationError::new("Invalid attribute type"))?;
Ok(())
>>>>>>> 8f2391a (app: create group attribute schema page (#825))
}

View File

@@ -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;
}

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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`
![General LDAP Settings](images/megarac_user.png)
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)
![Role Groups](images/megarac_group.png)

View File

@@ -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'

View File

@@ -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
![](bootstrap-example-log-1.jpeg)
@@ -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:

View File

@@ -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"]')"

View File

@@ -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.

View 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

View File

@@ -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';
```

View File

@@ -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`

View File

@@ -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"

View File

@@ -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]

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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)

View File

@@ -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}"
```

View File

@@ -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',
},
},
}
```

View File

@@ -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)))
```
![groups configuration page](images/nextcloud_groups.png)
### 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 :

View File

@@ -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`

View File

@@ -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
```

View File

@@ -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

View File

@@ -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."

View File

@@ -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.

View File

@@ -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.

View File

@@ -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)

View File

@@ -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/)

View File

@@ -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"

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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,

8
schema.graphql generated
View File

@@ -18,10 +18,6 @@ type Mutation {
addGroupAttribute(name: String!, attributeType: AttributeType!, isList: Boolean!, isVisible: Boolean!, isEditable: Boolean!): Success!
deleteUserAttribute(name: String!): Success!
deleteGroupAttribute(name: String!): Success!
addUserObjectClass(name: String!): Success!
addGroupObjectClass(name: String!): Success!
deleteUserObjectClass(name: String!): Success!
deleteGroupObjectClass(name: String!): Success!
}
type Group {
@@ -63,7 +59,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 +74,6 @@ type AttributeSchema {
isVisible: Boolean!
isEditable: Boolean!
isHardcoded: Boolean!
isReadonly: Boolean!
}
"The fields that can be updated for a user."
@@ -167,7 +162,6 @@ enum AttributeType {
type AttributeList {
attributes: [AttributeSchema!]!
extraLdapObjectClasses: [String!]!
}
type Success {

View File

@@ -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 = "*"

View File

@@ -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),

View File

@@ -2,8 +2,7 @@ use crate::domain::{
error::Result,
types::{
AttributeName, AttributeType, AttributeValue, Email, Group, GroupDetails, GroupId,
GroupName, JpegPhoto, LdapObjectClass, Serialized, User, UserAndGroups, UserColumn, UserId,
Uuid,
GroupName, JpegPhoto, Serialized, User, UserAndGroups, UserColumn, UserId, Uuid,
},
};
use async_trait::async_trait;
@@ -61,7 +60,6 @@ pub enum UserRequestFilter {
MemberOf(GroupName),
// Same, by id.
MemberOfId(GroupId),
CustomAttributePresent(AttributeName),
}
impl From<bool> for UserRequestFilter {
@@ -86,7 +84,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 +144,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)]
@@ -179,8 +175,6 @@ impl AttributeList {
pub struct Schema {
pub user_attributes: AttributeList,
pub group_attributes: AttributeList,
pub extra_user_object_classes: Vec<LdapObjectClass>,
pub extra_group_object_classes: Vec<LdapObjectClass>,
}
#[async_trait]
@@ -233,11 +227,6 @@ pub trait SchemaBackendHandler: ReadSchemaBackendHandler {
// Note: It's up to the caller to make sure that the attribute is not hardcoded.
async fn delete_user_attribute(&self, name: &AttributeName) -> Result<()>;
async fn delete_group_attribute(&self, name: &AttributeName) -> Result<()>;
async fn add_user_object_class(&self, name: &LdapObjectClass) -> Result<()>;
async fn add_group_object_class(&self, name: &LdapObjectClass) -> Result<()>;
async fn delete_user_object_class(&self, name: &LdapObjectClass) -> Result<()>;
async fn delete_group_object_class(&self, name: &LdapObjectClass) -> Result<()>;
}
#[async_trait]

View File

@@ -7,39 +7,30 @@ 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},
types::{AttributeName, AttributeType, Group, 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) {
GroupFieldType::ObjectClass => {
let mut classes = vec![b"groupOfUniqueNames".to_vec()];
classes.extend(
schema
.get_schema()
.extra_group_object_classes
.iter()
.map(|c| c.as_str().as_bytes().to_vec()),
);
classes
}
let attribute = AttributeName::from(attribute);
let attribute_values = match map_group_field(&attribute, schema) {
GroupFieldType::ObjectClass => vec![b"groupOfUniqueNames".to_vec()],
// Always returned as part of the base response.
GroupFieldType::Dn => return None,
GroupFieldType::EntryDn => {
@@ -71,12 +62,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 +93,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 +132,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,31 +153,26 @@ 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::ObjectClass => Ok(GroupRequestFilter::from(
matches!(value.as_str(), "groupofuniquenames" | "groupofnames")
|| schema
.get_schema()
.extra_group_object_classes
.contains(&LdapObjectClass::from(value)),
})?,
)),
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"
))),
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 +193,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 +211,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 +275,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,

View File

@@ -10,44 +10,30 @@ 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},
types::{
AttributeName, AttributeType, GroupDetails, LdapObjectClass, User, UserAndGroups,
UserColumn, UserId,
},
types::{AttributeName, AttributeType, GroupDetails, User, UserAndGroups, UserColumn, UserId},
};
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) {
UserFieldType::ObjectClass => {
let mut classes = vec![
b"inetOrgPerson".to_vec(),
b"posixAccount".to_vec(),
b"mailAccount".to_vec(),
b"person".to_vec(),
];
classes.extend(
schema
.get_schema()
.extra_user_object_classes
.iter()
.map(|c| c.as_str().as_bytes().to_vec()),
);
classes
}
let attribute = AttributeName::from(attribute);
let attribute_values = match map_user_field(&attribute, schema) {
UserFieldType::ObjectClass => vec![
b"inetOrgPerson".to_vec(),
b"posixAccount".to_vec(),
b"mailAccount".to_vec(),
b"person".to_vec(),
],
// dn is always returned as part of the base response.
UserFieldType::Dn => return None,
UserFieldType::EntryDn => {
@@ -92,12 +78,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 +119,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 +152,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 +182,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!(
@@ -221,27 +196,19 @@ fn convert_user_filter(
}
Ok(UserRequestFilter::from(false))
}
UserFieldType::ObjectClass => Ok(UserRequestFilter::from(
matches!(
value.as_str(),
"person" | "inetorgperson" | "posixaccount" | "mailaccount"
) || schema
.get_schema()
.extra_user_object_classes
.contains(&LdapObjectClass::from(value)),
UserFieldType::ObjectClass => Ok(UserRequestFilter::from(matches!(
value.as_str(),
"person" | "inetorgperson" | "posixaccount" | "mailaccount"
))),
UserFieldType::MemberOf => Ok(UserRequestFilter::MemberOf(
get_group_id_from_distinguished_name(
&value,
&ldap_info.base_dn,
&ldap_info.base_dn_str,
)?,
)),
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::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 +223,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 +251,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 +264,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 +303,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,

View File

@@ -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 {

View File

@@ -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,
}
}
}

View File

@@ -1,23 +0,0 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use crate::domain::types::LdapObjectClass;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "group_object_classes")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub lower_object_class: String,
pub object_class: LdapObjectClass,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
impl From<Model> for LdapObjectClass {
fn from(value: Model) -> Self {
value.object_class
}
}

View File

@@ -1,3 +1,5 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.10.3
pub mod prelude;
pub mod groups;
@@ -9,10 +11,8 @@ pub mod users;
pub mod user_attribute_schema;
pub mod user_attributes;
pub mod user_object_classes;
pub mod group_attribute_schema;
pub mod group_attributes;
pub mod group_object_classes;
pub use prelude::*;

View File

@@ -4,8 +4,6 @@ pub use super::group_attribute_schema::Column as GroupAttributeSchemaColumn;
pub use super::group_attribute_schema::Entity as GroupAttributeSchema;
pub use super::group_attributes::Column as GroupAttributesColumn;
pub use super::group_attributes::Entity as GroupAttributes;
pub use super::group_object_classes::Column as GroupObjectClassesColumn;
pub use super::group_object_classes::Entity as GroupObjectClasses;
pub use super::groups::Column as GroupColumn;
pub use super::groups::Entity as Group;
pub use super::jwt_refresh_storage::Column as JwtRefreshStorageColumn;
@@ -20,7 +18,5 @@ pub use super::user_attribute_schema::Column as UserAttributeSchemaColumn;
pub use super::user_attribute_schema::Entity as UserAttributeSchema;
pub use super::user_attributes::Column as UserAttributesColumn;
pub use super::user_attributes::Entity as UserAttributes;
pub use super::user_object_classes::Column as UserObjectClassesColumn;
pub use super::user_object_classes::Entity as UserObjectClasses;
pub use super::users::Column as UserColumn;
pub use super::users::Entity as User;

View File

@@ -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,
}
}
}

View File

@@ -1,23 +0,0 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use crate::domain::types::LdapObjectClass;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "user_object_classes")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub lower_object_class: String,
pub object_class: LdapObjectClass,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
impl From<Model> for LdapObjectClass {
fn from(value: Model) -> Self {
value.object_class
}
}

View File

@@ -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

View File

@@ -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();
}
}

View File

@@ -88,20 +88,6 @@ pub enum GroupAttributes {
GroupAttributeValue,
}
#[derive(DeriveIden, PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Copy)]
pub enum UserObjectClasses {
Table,
LowerObjectClass,
ObjectClass,
}
#[derive(DeriveIden, PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Copy)]
pub enum GroupObjectClasses {
Table,
LowerObjectClass,
ObjectClass,
}
// Metadata about the SQL DB.
#[derive(DeriveIden)]
pub enum Metadata {
@@ -1045,92 +1031,6 @@ async fn migrate_to_v8(transaction: DatabaseTransaction) -> Result<DatabaseTrans
Ok(transaction)
}
async fn migrate_to_v9(transaction: DatabaseTransaction) -> Result<DatabaseTransaction, DbErr> {
let builder = transaction.get_database_backend();
transaction
.execute(
builder.build(
Table::create()
.table(UserObjectClasses::Table)
.if_not_exists()
.col(
ColumnDef::new(UserObjectClasses::LowerObjectClass)
.string_len(255)
.not_null()
.primary_key(),
)
.col(
ColumnDef::new(UserObjectClasses::ObjectClass)
.string_len(255)
.not_null(),
),
),
)
.await?;
transaction
.execute(
builder.build(
Table::create()
.table(GroupObjectClasses::Table)
.if_not_exists()
.col(
ColumnDef::new(GroupObjectClasses::LowerObjectClass)
.string_len(255)
.not_null()
.primary_key(),
)
.col(
ColumnDef::new(GroupObjectClasses::ObjectClass)
.string_len(255)
.not_null(),
),
),
)
.await?;
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) => {
@@ -1159,8 +1059,6 @@ pub async fn migrate_from_version(
to_sync!(migrate_to_v6),
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 {

View File

@@ -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(())
}
}

View File

@@ -6,7 +6,7 @@ use crate::domain::{
},
model,
sql_backend_handler::SqlBackendHandler,
types::{AttributeName, LdapObjectClass},
types::AttributeName,
};
use async_trait::async_trait;
use sea_orm::{
@@ -66,44 +66,6 @@ impl SchemaBackendHandler for SqlBackendHandler {
.await?;
Ok(())
}
async fn add_user_object_class(&self, name: &LdapObjectClass) -> Result<()> {
let mut name_key = name.to_string();
name_key.make_ascii_lowercase();
model::user_object_classes::ActiveModel {
lower_object_class: Set(name_key),
object_class: Set(name.clone()),
}
.insert(&self.sql_pool)
.await?;
Ok(())
}
async fn add_group_object_class(&self, name: &LdapObjectClass) -> Result<()> {
let mut name_key = name.to_string();
name_key.make_ascii_lowercase();
model::group_object_classes::ActiveModel {
lower_object_class: Set(name_key),
object_class: Set(name.clone()),
}
.insert(&self.sql_pool)
.await?;
Ok(())
}
async fn delete_user_object_class(&self, name: &LdapObjectClass) -> Result<()> {
model::UserObjectClasses::delete_by_id(name.as_str().to_ascii_lowercase())
.exec(&self.sql_pool)
.await?;
Ok(())
}
async fn delete_group_object_class(&self, name: &LdapObjectClass) -> Result<()> {
model::GroupObjectClasses::delete_by_id(name.as_str().to_ascii_lowercase())
.exec(&self.sql_pool)
.await?;
Ok(())
}
}
impl SqlBackendHandler {
@@ -117,8 +79,6 @@ impl SqlBackendHandler {
group_attributes: AttributeList {
attributes: Self::get_group_attributes(transaction).await?,
},
extra_user_object_classes: Self::get_user_object_classes(transaction).await?,
extra_group_object_classes: Self::get_group_object_classes(transaction).await?,
})
}
@@ -145,39 +105,13 @@ impl SqlBackendHandler {
.map(|m| m.into())
.collect())
}
async fn get_user_object_classes(
transaction: &DatabaseTransaction,
) -> Result<Vec<LdapObjectClass>> {
Ok(model::UserObjectClasses::find()
.order_by_asc(model::UserObjectClassesColumn::ObjectClass)
.all(transaction)
.await?
.into_iter()
.map(Into::into)
.collect())
}
async fn get_group_object_classes(
transaction: &DatabaseTransaction,
) -> Result<Vec<LdapObjectClass>> {
Ok(model::GroupObjectClasses::find()
.order_by_asc(model::GroupObjectClassesColumn::ObjectClass)
.all(transaction)
.await?
.into_iter()
.map(Into::into)
.collect())
}
}
#[cfg(test)]
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 +130,6 @@ mod tests {
is_visible: true,
is_editable: true,
is_hardcoded: true,
is_readonly: false,
},
AttributeSchema {
name: "first_name".into(),
@@ -205,7 +138,6 @@ mod tests {
is_visible: true,
is_editable: true,
is_hardcoded: true,
is_readonly: false,
},
AttributeSchema {
name: "last_name".into(),
@@ -214,15 +146,12 @@ mod tests {
is_visible: true,
is_editable: true,
is_hardcoded: true,
is_readonly: false,
}
]
},
group_attributes: AttributeList {
attributes: Vec::new()
},
extra_user_object_classes: Vec::new(),
extra_group_object_classes: Vec::new(),
}
}
);
}
@@ -249,7 +178,6 @@ mod tests {
is_visible: false,
is_editable: false,
is_hardcoded: false,
is_readonly: false,
};
assert!(fixture
.handler
@@ -274,43 +202,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 +224,6 @@ mod tests {
is_visible: true,
is_editable: false,
is_hardcoded: false,
is_readonly: false,
};
assert!(fixture
.handler
@@ -357,50 +247,4 @@ mod tests {
.attributes
.contains(&expected_value));
}
#[tokio::test]
async fn test_user_object_class_add_and_delete() {
let fixture = TestFixture::new().await;
let new_object_class = LdapObjectClass::new("newObjectClass");
fixture
.handler
.add_user_object_class(&new_object_class)
.await
.unwrap();
assert_eq!(
fixture
.handler
.get_schema()
.await
.unwrap()
.extra_user_object_classes,
vec![new_object_class.clone()]
);
fixture
.handler
.add_user_object_class(&LdapObjectClass::new("newobjEctclass"))
.await
.expect_err("Should not be able to add the same object class twice");
assert_eq!(
fixture
.handler
.get_schema()
.await
.unwrap()
.extra_user_object_classes,
vec![new_object_class.clone()]
);
fixture
.handler
.delete_user_object_class(&new_object_class)
.await
.unwrap();
assert!(fixture
.handler
.get_schema()
.await
.unwrap()
.extra_user_object_classes
.is_empty());
}
}

View File

@@ -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(8);
#[derive(Copy, PartialEq, Eq, Debug, Clone, PartialOrd, Ord)]
pub struct PrivateKeyHash(pub [u8; 32]);

View File

@@ -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();
}
}

View File

@@ -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()
@@ -282,8 +271,6 @@ impl TryFromU64 for AttributeName {
))
}
}
make_case_insensitive_comparable_string!(LdapObjectClass);
make_case_insensitive_comparable_string!(Email);
make_case_insensitive_comparable_string!(GroupName);
@@ -440,6 +427,7 @@ impl Default for User {
}
#[derive(
Debug,
Copy,
Clone,
PartialEq,
@@ -450,9 +438,7 @@ impl Default for User {
Serialize,
Deserialize,
DeriveValueType,
derive_more::Debug,
)]
#[debug("{_0}")]
pub struct GroupId(pub i32);
impl TryFromU64 for GroupId {
@@ -571,8 +557,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());
}
}

View File

@@ -12,10 +12,7 @@ use crate::domain::{
UpdateUserRequest, UserBackendHandler, UserListerBackendHandler, UserRequestFilter,
},
schema::PublicSchema,
types::{
AttributeName, Group, GroupDetails, GroupId, GroupName, LdapObjectClass, User,
UserAndGroups, UserId,
},
types::{AttributeName, Group, GroupDetails, GroupId, GroupName, User, UserAndGroups, UserId},
};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
@@ -115,10 +112,6 @@ pub trait AdminBackendHandler:
async fn add_group_attribute(&self, request: CreateAttributeRequest) -> Result<()>;
async fn delete_user_attribute(&self, name: &AttributeName) -> Result<()>;
async fn delete_group_attribute(&self, name: &AttributeName) -> Result<()>;
async fn add_user_object_class(&self, name: &LdapObjectClass) -> Result<()>;
async fn add_group_object_class(&self, name: &LdapObjectClass) -> Result<()>;
async fn delete_user_object_class(&self, name: &LdapObjectClass) -> Result<()>;
async fn delete_group_object_class(&self, name: &LdapObjectClass) -> Result<()>;
}
#[async_trait]
@@ -194,18 +187,6 @@ impl<Handler: BackendHandler> AdminBackendHandler for Handler {
async fn delete_group_attribute(&self, name: &AttributeName) -> Result<()> {
<Handler as SchemaBackendHandler>::delete_group_attribute(self, name).await
}
async fn add_user_object_class(&self, name: &LdapObjectClass) -> Result<()> {
<Handler as SchemaBackendHandler>::add_user_object_class(self, name).await
}
async fn add_group_object_class(&self, name: &LdapObjectClass) -> Result<()> {
<Handler as SchemaBackendHandler>::add_group_object_class(self, name).await
}
async fn delete_user_object_class(&self, name: &LdapObjectClass) -> Result<()> {
<Handler as SchemaBackendHandler>::delete_user_object_class(self, name).await
}
async fn delete_group_object_class(&self, name: &LdapObjectClass) -> Result<()> {
<Handler as SchemaBackendHandler>::delete_group_object_class(self, name).await
}
}
pub struct AccessControlledBackendHandler<Handler> {

View File

@@ -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,40 +648,40 @@ 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}")
.route(web::post().to(get_password_reset_step1_handler::<Backend>)),
.route(web::get().to(get_password_reset_step1_handler::<Backend>)),
)
.service(
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)),
);
}
}

View File

@@ -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")]

View File

@@ -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

View File

@@ -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(),

View File

@@ -7,10 +7,9 @@ use crate::{
AttributeList, BackendHandler, CreateAttributeRequest, CreateGroupRequest,
CreateUserRequest, UpdateGroupRequest, UpdateUserRequest,
},
schema::PublicSchema,
types::{
AttributeName, AttributeType, AttributeValue as DomainAttributeValue, Email, GroupId,
JpegPhoto, LdapObjectClass, UserId,
AttributeName, AttributeType, AttributeValue as DomainAttributeValue, GroupId,
JpegPhoto, UserId,
},
},
infra::{
@@ -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,
@@ -551,90 +490,6 @@ impl<Handler: BackendHandler> Mutation<Handler> {
.await?;
Ok(Success::new())
}
async fn add_user_object_class(
context: &Context<Handler>,
name: String,
) -> FieldResult<Success> {
let span = debug_span!("[GraphQL mutation] add_user_object_class");
span.in_scope(|| {
debug!(?name);
});
let handler = context
.get_admin_handler()
.ok_or_else(field_error_callback(
&span,
"Unauthorized object class addition",
))?;
handler
.add_user_object_class(&LdapObjectClass::from(name))
.instrument(span)
.await?;
Ok(Success::new())
}
async fn add_group_object_class(
context: &Context<Handler>,
name: String,
) -> FieldResult<Success> {
let span = debug_span!("[GraphQL mutation] add_group_object_class");
span.in_scope(|| {
debug!(?name);
});
let handler = context
.get_admin_handler()
.ok_or_else(field_error_callback(
&span,
"Unauthorized object class addition",
))?;
handler
.add_group_object_class(&LdapObjectClass::from(name))
.instrument(span)
.await?;
Ok(Success::new())
}
async fn delete_user_object_class(
context: &Context<Handler>,
name: String,
) -> FieldResult<Success> {
let span = debug_span!("[GraphQL mutation] delete_user_object_class");
span.in_scope(|| {
debug!(?name);
});
let handler = context
.get_admin_handler()
.ok_or_else(field_error_callback(
&span,
"Unauthorized object class deletion",
))?;
handler
.delete_user_object_class(&LdapObjectClass::from(name))
.instrument(span)
.await?;
Ok(Success::new())
}
async fn delete_group_object_class(
context: &Context<Handler>,
name: String,
) -> FieldResult<Success> {
let span = debug_span!("[GraphQL mutation] delete_group_object_class");
span.in_scope(|| {
debug!(?name);
});
let handler = context
.get_admin_handler()
.ok_or_else(field_error_callback(
&span,
"Unauthorized object class deletion",
))?;
handler
.delete_group_object_class(&LdapObjectClass::from(name))
.instrument(span)
.await?;
Ok(Success::new())
}
}
async fn create_group_with_details<Handler: BackendHandler>(
@@ -670,13 +525,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",

View File

@@ -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, 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> {
@@ -514,32 +523,26 @@ impl<Handler: BackendHandler> From<DomainAttributeSchema> for AttributeSchema<Ha
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)]
pub struct AttributeList<Handler: BackendHandler> {
attributes: DomainAttributeList,
extra_classes: Vec<LdapObjectClass>,
schema: DomainAttributeList,
_phantom: std::marker::PhantomData<Box<Handler>>,
}
#[graphql_object(context = Context<Handler>)]
impl<Handler: BackendHandler> AttributeList<Handler> {
fn attributes(&self) -> Vec<AttributeSchema<Handler>> {
self.attributes
self.schema
.attributes
.clone()
.into_iter()
.map(Into::into)
.collect()
}
fn extra_ldap_object_classes(&self) -> Vec<String> {
self.extra_classes.iter().map(|c| c.to_string()).collect()
}
}
impl<Handler: BackendHandler> AttributeList<Handler> {
fn new(attributes: DomainAttributeList, extra_classes: Vec<LdapObjectClass>) -> Self {
impl<Handler: BackendHandler> From<DomainAttributeList> for AttributeList<Handler> {
fn from(value: DomainAttributeList) -> Self {
Self {
attributes,
extra_classes,
schema: value,
_phantom: std::marker::PhantomData,
}
}
@@ -554,16 +557,10 @@ pub struct Schema<Handler: BackendHandler> {
#[graphql_object(context = Context<Handler>)]
impl<Handler: BackendHandler> Schema<Handler> {
fn user_schema(&self) -> AttributeList<Handler> {
AttributeList::<Handler>::new(
self.schema.get_schema().user_attributes.clone(),
self.schema.get_schema().extra_user_object_classes.clone(),
)
self.schema.get_schema().user_attributes.clone().into()
}
fn group_schema(&self) -> AttributeList<Handler> {
AttributeList::<Handler>::new(
self.schema.get_schema().group_attributes.clone(),
self.schema.get_schema().extra_group_object_classes.clone(),
)
self.schema.get_schema().group_attributes.clone().into()
}
}
@@ -598,19 +595,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 +649,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))),
}
}
}
@@ -804,7 +670,7 @@ mod tests {
use crate::{
domain::{
handler::AttributeList,
types::{AttributeName, AttributeType, LdapObjectClass, Serialized},
types::{AttributeName, AttributeType, Serialized},
},
infra::{
access_control::{Permission, ValidationResults},
@@ -838,8 +704,6 @@ mod tests {
id
email
creationDate
firstName
lastName
uuid
attributes {
name
@@ -870,7 +734,6 @@ mod tests {
is_visible: true,
is_editable: true,
is_hardcoded: true,
is_readonly: false,
},
DomainAttributeSchema {
name: "last_name".into(),
@@ -879,7 +742,6 @@ mod tests {
is_visible: true,
is_editable: true,
is_hardcoded: true,
is_readonly: false,
},
],
},
@@ -891,14 +753,8 @@ mod tests {
is_visible: true,
is_editable: true,
is_hardcoded: false,
is_readonly: false,
}],
},
extra_user_object_classes: vec![
LdapObjectClass::from("customUserClass"),
LdapObjectClass::from("myUserClass"),
],
extra_group_object_classes: vec![LdapObjectClass::from("customGroupClass")],
})
});
mock.expect_get_user_details()
@@ -949,6 +805,7 @@ mod tests {
let schema = schema(Query::<MockTestBackendHandler>::new());
assert_eq!(
execute(QUERY, None, &schema, &Variables::new(), &context).await,
Ok((
graphql_value!(
{
@@ -956,26 +813,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 +828,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 +838,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
))
);
}
@@ -1140,7 +946,6 @@ mod tests {
isEditable
isHardcoded
}
extraLdapObjectClasses
}
groupSchema {
attributes {
@@ -1151,7 +956,6 @@ mod tests {
isEditable
isHardcoded
}
extraLdapObjectClasses
}
}
}"#;
@@ -1236,8 +1040,7 @@ mod tests {
"isEditable": false,
"isHardcoded": true,
},
],
"extraLdapObjectClasses": ["customUserClass"],
]
},
"groupSchema": {
"attributes": [
@@ -1273,8 +1076,7 @@ mod tests {
"isEditable": false,
"isHardcoded": true,
},
],
"extraLdapObjectClasses": [],
]
}
}
}),
@@ -1291,7 +1093,6 @@ mod tests {
attributes {
name
}
extraLdapObjectClasses
}
}
}"#;
@@ -1301,21 +1102,18 @@ 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 {
attributes: Vec::new(),
},
extra_user_object_classes: vec![LdapObjectClass::from("customUserClass")],
extra_group_object_classes: Vec::new(),
})
});
@@ -1341,8 +1139,7 @@ mod tests {
{"name": "mail"},
{"name": "user_id"},
{"name": "uuid"},
],
"extraLdapObjectClasses": ["customUserClass"],
]
}
}
} ),

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