Compare commits

...

115 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] 0e248ab3bb Add memberOf attribute definition to LDAP schema
Co-authored-by: nitnelave <796633+nitnelave@users.noreply.github.com>
2025-08-20 16:44:48 +00:00
copilot-swe-agent[bot] 5df92fbb16 Initial analysis: Issue is missing memberOf attribute in LDAP schema
Co-authored-by: nitnelave <796633+nitnelave@users.noreply.github.com>
2025-08-20 16:36:38 +00:00
copilot-swe-agent[bot] e92088a7aa Initial plan 2025-08-20 16:29:11 +00:00
Valentin Tolmer 5e83ed8eb0 release: v0.6.2 2025-08-18 00:06:44 +02:00
Kirill Zhuravlev c69957690e docs: avoid bad-sounding words in secrets example 2025-08-17 23:10:45 +02:00
Linus Astel 7ef2af8beb devcontainer: Bump Rust version 2025-08-14 22:38:45 +02:00
Toby 5c9897b156 ldap: Add missing subschema entries 2025-08-14 16:04:28 +02:00
ibizaman 0b720aa082 bootstrap: fine grained cleanup 2025-08-13 09:36:21 +02:00
dependabot[bot] 3e7277e77d build(deps): bump actions/checkout from 4.2.2 to 5.0.0
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.2.2 to 5.0.0.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4.2.2...v5.0.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-12 08:02:59 +02:00
ibizaman 5241626a3a bootstrap: make password_file a standard custom attribute
Otherwise the bootstrap script tries to create the password_file
as a custom attribute which fails since it's not in the schema.
And anyway, it shouldn't be in the schema.
2025-08-06 22:13:22 +02:00
Valentin Tolmer 363ef106e2 app: Fix attribute type parsing 2025-07-30 01:02:47 +02:00
ibizaman 3c7e4c3dec bootstrap: do not leak password in process list 2025-07-22 08:51:35 +02:00
Valentin Tolmer fa196a9fd9 docker: try several GPG server
Sometimes the docker build fails because the gpg server is intermittently unavailable
2025-07-22 01:10:25 +02:00
ibizaman f02b365478 bootstrap: do not fail if no user or group defined 2025-07-21 23:35:49 +02:00
Valentin Tolmer 0b0e6ae2cd github: Fix warnings about Dockerfile syntax 2025-07-21 23:23:37 +02:00
Valentin Tolmer da525fc99b app: simplify attribute_type handling, display creation time in user details
In the user table it's still only the date, but that makes sense for an overview
2025-07-21 23:15:46 +02:00
ibizaman 78337bce72 bootstrap: allow to give password from a file 2025-07-16 23:51:21 +02:00
selfhoster1312 87e9311a44 meta: Fix cargo clippy failures (format strings) 2025-07-16 23:23:08 +02:00
Hendrik Sievers 53e62ecf5a docs: move authelia configuration to markdown file (#1205) 2025-07-13 22:29:09 +02:00
core 10d33a7537 readme: fix broken Iink 2025-07-11 00:52:03 +02:00
copilot-swe-agent[bot] ada438398e set-password: load system certificates
Fixes #1206
2025-07-08 22:46:13 +02:00
selfhoster1312 8c65d8958a docs: Add FAQ about sustainability and professional support contracts (#1200) 2025-07-06 23:55:08 +02:00
Toby f8cd7ad023 server, ldap: add support for Subschema requests (#1071)
Add a subschema entry to the rootDSE, which shows all attributes and
objectclasses present on the LLDAP server, which is needed for some
applications that need to index the LDAP server. The current
implementation's goal is to have a bare minimum working subschema which
follows the LDAP RFC. It also updates the GraphQL interface to follow
the changes that have been made in actually separating out
objectclasses, instead of having them as an attribute.

Co-authored-by: nitnelave <valentin@tolmer.fr>
2025-07-06 23:42:53 +02:00
selfhoster1312 823adcefd0 docs: Document (lack of) vhosts support (#1201) 2025-07-06 23:32:28 +02:00
selfhoster1312 5b120a5958 docs: Split README into smaller files (#1198) 2025-07-06 23:12:48 +02:00
Alexandre Foley c658666b3f example_configs: Quadlet documentation and improvements 2025-06-30 19:47:32 +02:00
PHIDIAS 7a5a88384d example_configs: add Mailcow (#1188) 2025-06-06 19:13:26 +02:00
Jona Joachim 4eb4fae49c example_configs: wiki_js: Add missing closing curly brackets to filter 2025-06-02 12:55:48 +02:00
Bryan Alves 58b028ad5f example_configs: fix Authelia OU for helm installations
Authelia when installed via helm by default looks for users
in the `Users` OU.  It supports changing this configuration via the
`additional_users_dn` property.  Set this to match what lldap expects.
2025-06-01 09:11:21 +02:00
Josh Thorpe 612bce48ad example_configs: fix overly-permissive next loud config
Any LLDAP user, not just members of nextcloud-users, could log in and get an instance. However, they weren't synchronized to the nextcloud admin view and thus were nearly invisible.
2025-05-30 10:23:07 +02:00
aokblast 1b5f6bfa66 example_configs: correct the manual for pkg support in FreeBSD 2025-05-19 20:28:23 +02:00
Nick 5913d81a44 chore: upgrade top level docker alpine version 2025-05-19 08:13:01 +02:00
sdelnevo cb9fd38271 example_configs: Add UnifiOS Directory 2025-05-17 23:34:53 +02:00
ChibyX 97bcfd1a99 example_configs: Add Peertube 2025-05-16 16:11:38 +02:00
Christian Medel 7330496a77 example_configs: Add Snipe-IT 2025-05-14 23:50:17 +09:00
MikaelUrankar 0baee7a120 Readme: Fix typo
s#LLDPA#LLDAP#g
2025-05-09 22:03:37 +09:00
broemp 0a5b2d4c46 example_configs: Change Grocy example config Port 2025-05-04 09:04:45 +09:00
cogdavid 9978111bec example_configs: fix dovecot example
ACCOUNT_PROVISIONER=LDAP is supported and indeed necessary for ldap integration - however quotas must be explicitly disabled with ENABLE_QUOTAS=0
2025-04-27 06:03:16 +09:00
Valentin Tolmer 8e25e9b2a4 app: Add a create user/group button at the top 2025-04-25 15:51:16 +09:00
Valentin Tolmer 4d6402c838 app: Fix email validation for groups
Fixes #1092
2025-04-25 15:36:43 +09:00
Valentin Tolmer b4f636ded9 server: Introduce True/False for filters
This should help clean up the filter debug representations
2025-04-25 15:25:26 +09:00
cogdavid 4018a6933c exmaple_configs: fix account provisioner in dovecot config
Updated ACCOUNT_PROVISIONER variable in environment directive. Change from ACCOUNT_PROVISIONER=LDAP to ACCOUNT_PROVISIONER=FILE.
2025-04-23 22:33:58 +09:00
Antonio Vargas bd29c7282d bootstrap: Include custom attributes when bootstrapping user and group configs (#1155)
* Include custom attributes when bootstrapping user and group configs

* Fix logic to detect user/group config without custom attributes

* Increase readability of query definition using a heredoc

* Remove duplicate query variables and improve readability

* Revert "Increase readability of query definition using a heredoc"

This reverts commit 7a73dacc21.
2025-04-21 16:07:11 +09:00
v-mod 1f89059c84 example_config: Add SSSD
* example_config: moving nslcd old guide to NSLCD.md

* example_config: creating README for SSSD

* example_config: creating sssd.conf

* example_config: removing explicit links and adding a reference to the old NSLCD guide

* example_config: fixing images in pam README

* example_config: add how to enable automatic home directory creation

* example_config: fixing typo in command to edit ssh config

* example_config: using commments instead of line numbers for the example sssd.conf file

---------

Co-authored-by: nitnelave <valentin@tolmer.fr>
2025-04-09 10:15:03 +09:00
Valentin Tolmer 74dbba0bdc server: dependency cleanup 2025-04-09 09:30:39 +09:00
Valentin Tolmer 3556e41612 server: flatten remaining files 2025-04-09 09:30:39 +09:00
Valentin Tolmer d38a2cd08b server: extract graphql crate 2025-04-09 09:30:39 +09:00
Valentin Tolmer db77a0f023 server: rename sql_backend_server -> sql_tcp_backend_server 2025-04-09 09:30:39 +09:00
Valentin Tolmer 3d61c209d2 server: small dependency cleanup 2025-04-09 09:30:39 +09:00
Valentin Tolmer 55de3ac329 server: extract the sql backend handler to a separate crate 2025-04-09 09:30:39 +09:00
Valentin Tolmer ee21d83056 server: extract ldap operations to a crate 2025-04-09 09:30:39 +09:00
Valentin Tolmer a49ddeaa02 server: extract opaque_handler to a separate crate 2025-04-09 09:30:39 +09:00
Valentin Tolmer dbba4c4e26 server: extract access_control to a separate crate 2025-04-09 09:30:39 +09:00
Valentin Tolmer 0eef966c3e server: Move PublicSchema to the domain crate 2025-04-09 09:30:39 +09:00
Valentin Tolmer cdf43f2a69 server: cleanup extra mut in ldap handler 2025-04-09 09:30:39 +09:00
Valentin Tolmer 7450ff1028 server: Add support for deleting users and groups via LDAP 2025-04-09 09:30:39 +09:00
Valentin Tolmer c3ae149ae3 server: add tests for ldap modify 2025-04-09 09:30:39 +09:00
Valentin Tolmer 0a05a091d8 server: split off modify requests from ldap_handler 2025-04-09 09:30:39 +09:00
Valentin Tolmer 6a2a5fe7f5 server: split off create_* from ldap_handler 2025-04-09 09:30:39 +09:00
Valentin Tolmer 52f22c00c3 server: split off compare from ldap_handler 2025-04-09 09:30:39 +09:00
Valentin Tolmer 37a85b4c2e server: split off password handling from ldap_handler 2025-04-09 09:30:39 +09:00
Valentin Tolmer 63f8b51c88 server: split off do_bind from ldap_handler 2025-04-09 09:30:39 +09:00
Valentin Tolmer c4aca0dad7 server: split off ldap/search from ldap_handler 2025-04-09 09:30:39 +09:00
Valentin Tolmer b8f114bd43 ldap: add support for creating groups 2025-04-08 19:15:47 -05:00
Valentin Tolmer 31364da6d4 chore: add prepare-release to Makefile 2025-04-04 10:43:48 -05:00
Juntong Zhu 853c561314 example_config: fix kimai.yaml 2025-04-01 06:47:53 -05:00
Valentin Tolmer 0aa31a282a app: Remove max-width in main component 2025-03-31 22:07:43 -05:00
ivan-sirosh 41e38234ed example_config: Add penpot 2025-03-31 11:42:26 -05:00
Valentin Tolmer ba9bcb3894 chore: Migrate all the crates to edition 2024 2025-03-30 21:32:46 -05:00
Valentin Tolmer e18f2af54f cargo: Migrate metadata to workspace 2025-03-30 20:56:16 -05:00
Valentin Tolmer 5afcdbda65 app, server: Add an endpoint to fetch the frontend settings 2025-03-30 20:52:39 -05:00
Valentin Tolmer ba93533790 chore: update lldap/rust-dev to 1.85 2025-03-30 18:49:58 -05:00
Valentin Tolmer e4044b7415 dependencies: Upgrade sea-orm to 1.1.8 2025-03-30 23:00:50 +02:00
meetpatty 26b25e7776 example_configs: Fix nslcd group member mapping. 2025-03-29 11:44:17 +01:00
taiwan-king 20ade89633 example_configs: Add MFA with DuoAuthProxy 2025-03-13 23:28:16 +01:00
Yuki 928559890a example_configs: add example with podman quadlets for pgsql deployment w/ secrets 2025-03-13 17:46:57 +01:00
hendrik1120 049e882c35 docs(readme): clarify password change permission for admin users 2025-03-07 12:31:44 +01:00
MickMorley f5f3091313 example_configs: update Home Assistant
Found that only a restart of Home Assistant will read the new lines in configuration.yaml.  Also added a note to use `-k` when using the curl command if needed.
2025-02-27 17:30:51 +01:00
Simon Broeng Jensen 0a0f915ce6 chore: update rust-argon2 crate to v2 2025-02-25 15:18:06 +01:00
xeoneox 5f42d423e3 example_configs: fix typo in Stalwart config 2025-02-24 08:10:48 +01:00
Simon Broeng Jensen 2a226963ee auth: move Permission and ValidationResults to auth crate 2025-02-22 23:18:06 +01:00
Simon Broeng Jensen ca1c6ff645 domain-handlers: move backend handler traits to separate crate 2025-02-21 20:41:20 +01:00
xeoneox e22d17dca6 example_configs: reduce privileges for stalwart bind user
Update instructions for read_only bind
2025-02-21 16:02:08 +01:00
Simon Broeng Jensen f34fa1d701 cargo,server: update ldap3_proto to version 0.6.0 2025-02-21 11:27:13 +01:00
Simon Broeng Jensen d854ace89f domain-model: move domain::model module to separate crate 2025-02-21 10:25:07 +01:00
xeoneox 3c0359eb8a example_configs: Add Stalwart link to README 2025-02-20 21:16:32 +01:00
xeoneox b591539c8a example_configs: Add Stalwart
Failed at getting Docker Mailserver and Maddy working, so I wrote a config for Stalwart Mailserver instead
2025-02-20 20:50:52 +01:00
Simon Broeng Jensen 5d2f168554 domain + server: introduce new AttributeValue enum 2025-02-19 15:48:27 +01:00
JaidenW cf0e9a01f1 readme: add link to the discord bot 2025-02-19 08:03:35 +01:00
Giovanni Geraci 86d15e831e example_configs: Add Project Quay 2025-02-16 21:33:01 +01:00
Simon Broeng Jensen 8285e21ebb domain: rename AttributeValue to Attribute
Preparation for storing the actual types for each value, which
will repurpose the AttributeValue name.
2025-02-05 16:07:05 +01:00
Simon Broeng Jensen 4c6cfeee9e server: remove deprecated fields from CreateUserRequest
The fields first_name, last_name, and avatar have all been moved
to regular attributes in the database, and are available through
the GraphQL API as such as well. This commit removes the legacy
fields for each on the internal CreateUserRequest type, leaving
these to only be updateable through attributes.

The fields are still available in the GraphQL CreateUserInput
type, preserving backwards compatiblity, and if set, they will
be used for the corresponding attribute values. If both fields
and attributes are set, the values given through attributes will
superceed the fields, and be used. This change also fixes a bug,
where creation of a user would fail if either of these attributes
were set as both attribute and field, as it would attempt to
insert the attribute twice, violating a unique constraint in the
database.
2025-02-05 15:42:06 +01:00
Simon Broeng Jensen 37a683dcb2 validation: move validation crate to crates folder 2025-02-03 23:32:42 +01:00
Simon Broeng Jensen b5e87c7226 auth: move auth crate to crates folder 2025-02-03 23:32:42 +01:00
nitnelave dd0ba5975e server: Adds support for whoamiOID
Co-authored-by: eyjhb <eyjhbb@gmail.com>
2025-02-03 23:21:44 +01:00
Simon Broeng Jensen 1b26859141 server: move domain::types to separate domain crate (#1086)
Preparation for using basic type definitions in other upcoming
modules, in particular for plugins.
2025-02-03 23:00:27 +01:00
Fabian May 417abc54e4 server: Cleanup log messages for ignored attribute warnings
Reduce log messages by remove line break and remove visible \n\ sequence
2025-02-03 22:52:15 +01:00
Simon Broeng Jensen 5cc489aafe app: mute a clippy error about a wasm_bindgen directive 2025-01-29 13:32:05 +01:00
Simon Broeng Jensen c01c7744c7 server: fix a couple of clippy warnings 2025-01-29 13:32:05 +01:00
Simon Broeng Jensen 1b58ac61f4 server: fix serving of frontend after PR #1079 (#1090)
Had changed behaviour to serve the gz compressed wasm package
with the uncompressed handler.
2025-01-29 13:25:03 +01:00
tyami94 f46e5375df server: Allow custom path to front-end assets 2025-01-28 19:37:12 +01:00
Mathieu Bélanger 722464daf4 example_configs: Add pgAdmin 2025-01-22 22:12:54 +01:00
Simon Broeng Jensen 0799b6bc26 server: include preserved case in user attribute value search
Extends the generated UserRequestFilter with an OR'ed clause for
the attribute value in both it's original case and lowercased.
2025-01-22 10:37:04 +01:00
Simon Broeng Jensen f5fbb31e6e server, app: Add validation for attribute names (#1075)
This commit adds support for basic validation of attribute
names at creation, and also in the schema overview. Both
user and group attributes are validated with the same rules.

For now, attribute names will be considered valid, if they
only contain alphanumeric characters and dashes.

Validation has been added the following places:

- In graphql API, for creation of both user and group attributes.
  Request will be rejected, if attribute name is invalid.

- In frontend, before submitting a request to create a new user
  or group attribute. Rejection here will show an error message
  including a list of the invalid characters used.

As this change adds stricter validation to attributes, and, since
the rationale for this is partly compatibility with other LDAP
systems, this change also adds a warning in the schema overviews
to any attribute using invalid characters.
2025-01-22 09:57:47 +01:00
Simon Broeng Jensen 31a0cf5a4f app: Change default alias for User & Group schema attributes (#1082)
A number of the hardcoded attributes displayed in the User
and Group schema overviews were using aliases which contain
underscores, which is not always completely supported by
clients. Therefore, this commit changes the primary alias
for each attribute to be one without underscores.

To reduce confusion with this change, and also improve the
visibility of available aliases, this commit also includes
a list of each alias, for each hardcoded attribute. This
list will also include the old primary aliases.
2025-01-21 13:46:55 +01:00
Simon Broeng Jensen 33fb59f2f7 server: Add support for querying GroupId with LDAP filters 2025-01-20 17:07:53 +01:00
farshad fb43af1299 example_configs: update Authelia with LLDAP default settings 2025-01-19 07:02:05 +01:00
Valentin Tolmer f417427635 Prevent starting up if the JWT secret is not given
Similarly, don't create the admin if the password is not given
2024-12-24 19:40:26 +01:00
Dakota G 1f26262e13 example_configs: add Hashicorp 2024-12-10 07:34:50 +01:00
Zepmann 42fccf4713 readme: Updated Arch Linux install-from-repository section
Cleaned up the Arch Linux section. Added a link to the discussions support thread.
2024-12-07 18:49:58 +01:00
xeoneox 928faa4bcc example_configs: add search filter in onedev configuration 2024-12-07 07:17:52 +01:00
xeoneox 3895a5050d example_configs: Update OneDev example for latest release 2024-12-06 00:21:35 +01:00
Christian Medel f92035b6fd example_configs: Add Kimai 2024-11-25 22:20:09 +01:00
Valentin Tolmer 37a10c871f github: Fix release bot clearing the release body 2024-11-22 23:12:36 +01:00
Valentin Tolmer 8397d536d9 chore: bump version to 0.6.2-alpha 2024-11-22 22:55:53 +01:00
195 changed files with 10691 additions and 5974 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
FROM rust:1.74
FROM rust:1.85
ARG USERNAME=lldapdev
# We need to keep the user as 1001 to match the GitHub runner's UID.
+13 -2
View File
@@ -1,6 +1,6 @@
FROM localhost:5000/lldap/lldap:alpine-base
# Taken directly from https://github.com/tianon/gosu/blob/master/INSTALL.md
ENV GOSU_VERSION 1.17
ENV GOSU_VERSION=1.17
RUN set -eux; \
\
apk add --no-cache --virtual .gosu-deps \
@@ -15,7 +15,18 @@ RUN set -eux; \
\
# verify the signature
export GNUPGHOME="$(mktemp -d)"; \
gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \
for server in \
hkps://keys.openpgp.org \
ha.pool.sks-keyservers.net \
hkp://p80.pool.sks-keyservers.net:80 \
keyserver.ubuntu.com \
hkp://keyserver.ubuntu.com:80 \
pgp.mit.edu \
; do \
if gpg --batch --keyserver "$server" --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; then \
break; \
fi; \
done; \
gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
gpgconf --kill all; \
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
+19 -5
View File
@@ -1,12 +1,15 @@
FROM localhost:5000/lldap/lldap:debian-base
# Taken directly from https://github.com/tianon/gosu/blob/master/INSTALL.md
ENV GOSU_VERSION 1.17
ENV GOSU_VERSION=1.17
RUN set -eux; \
# save list of currently installed packages for later so we can clean up
savedAptMark="$(apt-mark showmanual)"; \
apt-get update; \
apt-get install -y --no-install-recommends ca-certificates gnupg wget; \
rm -rf /var/lib/apt/lists/*; \
for i in 1 2 3; do \
apt-get update && \
apt-get install -y --no-install-recommends wget ca-certificates gnupg && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* && break || sleep 5; \
done; \
\
dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \
wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \
@@ -14,7 +17,18 @@ RUN set -eux; \
\
# verify the signature
export GNUPGHOME="$(mktemp -d)"; \
gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \
for server in \
hkps://keys.openpgp.org \
ha.pool.sks-keyservers.net \
hkp://p80.pool.sks-keyservers.net:80 \
keyserver.ubuntu.com \
hkp://keyserver.ubuntu.com:80 \
pgp.mit.edu \
; do \
if gpg --batch --keyserver "$server" --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; then \
break; \
fi; \
done; \
gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
gpgconf --kill all; \
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
+1 -1
View File
@@ -1,5 +1,5 @@
# Keep tracking base image
FROM rust:1.81-slim-bookworm
FROM rust:1.85-slim-bookworm
# Set needed env path
ENV PATH="/opt/armv7l-linux-musleabihf-cross/:/opt/armv7l-linux-musleabihf-cross/bin/:/opt/aarch64-linux-musl-cross/:/opt/aarch64-linux-musl-cross/bin/:/opt/x86_64-linux-musl-cross/:/opt/x86_64-linux-musl-cross/bin/:$PATH"
+40 -22
View File
@@ -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@v5.0.0
- 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@v5.0.0
- uses: actions/cache@v4
with:
path: |
@@ -216,6 +216,8 @@ jobs:
LLDAP_database_url: postgres://lldapuser:lldappass@localhost/lldap
LLDAP_ldap_port: 3890
LLDAP_http_port: 17170
LLDAP_JWT_SECRET: verysecret
LLDAP_LDAP_USER_PASS: password
- name: Run lldap with mariadb DB (MySQL Compatible) and healthcheck
@@ -227,6 +229,8 @@ jobs:
LLDAP_database_url: mysql://lldapuser:lldappass@localhost/lldap
LLDAP_ldap_port: 3891
LLDAP_http_port: 17171
LLDAP_JWT_SECRET: verysecret
LLDAP_LDAP_USER_PASS: password
- name: Run lldap with sqlite DB and healthcheck
@@ -238,6 +242,8 @@ jobs:
LLDAP_database_url: sqlite://users.db?mode=rwc
LLDAP_ldap_port: 3892
LLDAP_http_port: 17172
LLDAP_JWT_SECRET: verysecret
LLDAP_LDAP_USER_PASS: password
- name: Check DB container logs
run: |
@@ -294,7 +300,7 @@ jobs:
steps:
- name: Checkout scripts
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5.0.0
with:
sparse-checkout: 'scripts'
@@ -324,9 +330,9 @@ jobs:
sleep 10s
bin/lldap healthcheck
env:
LLDAP_database_url: sqlite://users.db?mode=rwc
LLDAP_ldap_port: 3890
LLDAP_http_port: 17170
LLDAP_DATABASE_URL: sqlite://users.db?mode=rwc
LLDAP_LDAP_PORT: 3890
LLDAP_HTTP_PORT: 17170
LLDAP_LDAP_USER_PASS: ldappass
LLDAP_JWT_SECRET: somejwtsecret
@@ -350,8 +356,11 @@ jobs:
sed -i -r -e "s/X'([[:xdigit:]]+'[^'])/'\\\x\\1/g" -e ":a; s/(INSERT INTO (user_attribute_schema|jwt_storage)\(.*\) VALUES\(.*),1([^']*\);)$/\1,true\3/; s/(INSERT INTO (user_attribute_schema|jwt_storage)\(.*\) VALUES\(.*),0([^']*\);)$/\1,false\3/; ta" -e '1s/^/BEGIN;\n/' -e '$aCOMMIT;' ./dump.sql
- name: Create schema on postgres
env:
LLDAP_DATABASE_URL: postgres://lldapuser:lldappass@localhost:5432/lldap
LLDAP_JWT_SECRET: somejwtsecret
run: |
bin/lldap create_schema -d postgres://lldapuser:lldappass@localhost:5432/lldap
bin/lldap create_schema
- name: Copy converted db to postgress and import
run: |
@@ -368,7 +377,10 @@ jobs:
sed -i '1 i\SET FOREIGN_KEY_CHECKS = 0;' ./dump.sql
- name: Create schema on mariadb
run: bin/lldap create_schema -d mysql://lldapuser:lldappass@localhost:3306/lldap
env:
LLDAP_DATABASE_URL: mysql://lldapuser:lldappass@localhost:3306/lldap
LLDAP_JWT_SECRET: somejwtsecret
run: bin/lldap create_schema
- name: Copy converted db to mariadb and import
run: |
@@ -384,7 +396,10 @@ jobs:
sed -i '1 i\SET FOREIGN_KEY_CHECKS = 0;' ./dump.sql
- name: Create schema on mysql
run: bin/lldap create_schema -d mysql://lldapuser:lldappass@localhost:3307/lldap
env:
LLDAP_DATABASE_URL: mysql://lldapuser:lldappass@localhost:3307/lldap
LLDAP_JWT_SECRET: somejwtsecret
run: bin/lldap create_schema
- name: Copy converted db to mysql and import
run: |
@@ -399,10 +414,9 @@ jobs:
sleep 10s
bin/lldap healthcheck
env:
LLDAP_database_url: postgres://lldapuser:lldappass@localhost:5432/lldap
LLDAP_ldap_port: 3891
LLDAP_http_port: 17171
LLDAP_LDAP_USER_PASS: ldappass
LLDAP_DATABASE_URL: postgres://lldapuser:lldappass@localhost:5432/lldap
LLDAP_LDAP_PORT: 3891
LLDAP_HTTP_PORT: 17171
LLDAP_JWT_SECRET: somejwtsecret
- name: Run lldap with mariaDB and healthcheck again
@@ -411,9 +425,9 @@ jobs:
sleep 10s
bin/lldap healthcheck
env:
LLDAP_database_url: mysql://lldapuser:lldappass@localhost:3306/lldap
LLDAP_ldap_port: 3892
LLDAP_http_port: 17172
LLDAP_DATABASE_URL: mysql://lldapuser:lldappass@localhost:3306/lldap
LLDAP_LDAP_PORT: 3892
LLDAP_HTTP_PORT: 17172
LLDAP_JWT_SECRET: somejwtsecret
- name: Run lldap with mysql and healthcheck again
@@ -422,9 +436,9 @@ jobs:
sleep 10s
bin/lldap healthcheck
env:
LLDAP_database_url: mysql://lldapuser:lldappass@localhost:3307/lldap
LLDAP_ldap_port: 3893
LLDAP_http_port: 17173
LLDAP_DATABASE_URL: mysql://lldapuser:lldappass@localhost:3307/lldap
LLDAP_LDAP_PORT: 3893
LLDAP_HTTP_PORT: 17173
LLDAP_JWT_SECRET: somejwtsecret
- name: Test Dummy User Postgres
@@ -482,7 +496,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5.0.0
- name: Download all artifacts
uses: actions/download-artifact@v4
@@ -738,5 +752,9 @@ jobs:
artifacts: aarch64-lldap.tar.gz,
amd64-lldap.tar.gz,
armhf-lldap.tar.gz
draft: true
omitBodyDuringUpdate: true
omitDraftDuringUpdate: true
omitNameDuringUpdate: true
env:
GITHUB_TOKEN: ${{ github.token }}
+4 -4
View File
@@ -33,7 +33,7 @@ jobs:
steps:
- name: Checkout sources
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5.0.0
- 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@v5.0.0
- 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@v5.0.0
- 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@v5.0.0
- 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
+55
View File
@@ -5,6 +5,61 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.6.2] 2025-07-21
Small release, focused on LDAP improvements and ongoing maintenance.
### Added
- LDAP
- Support for searching groups by their `groupid`
- Support for `whoamiOID`
- Support for creating groups
- Support for subschema entry
- Custom assets path.
- New endpoint for requesting client settings
### Changed
- A missing JWT secret now prevents startup.
- Attributes with invalid characters (such as underscores) cannot be created anymore.
- Searching custom (string) attributes is now case insensitive.
- Using the top-level `firstName`, `lastName` and `avatar` GraphQL fields for users is now deprecated. Use the `attributes` field instead.
### Fixed
- `lldap_set_password` now uses the system's SSL certificates.
### Cleanups
- Split the main `lldap` crate into many sub-crates
- Various dependency version bumps
- Upgraded to 2024 Rust edition
- Docs/FAQ improvements
### Bootstrap script
- Custom attributes support
- Read the paswsord from a file
- Resilient to no user or group files
### New services
- Discord integration (Discord role to LLDAP user)
- HashiCorp
- Jellyfin 2FA with Duo
- Kimai
- Mailcow
- Peertube
- Penpot
- PgAdmin
- Project Quay
- Quadlet
- Snipe-IT
- SSSD
- Stalwart
- UnifiOS
## [0.6.1] 2024-11-22
Small release, mainly to fix a migration issue with Sqlite and Postgresql.
Generated
+458 -188
View File
File diff suppressed because it is too large Load Diff
+20 -7
View File
@@ -1,16 +1,22 @@
[workspace]
members = [
"server",
"auth",
"app",
"migration-tool",
"set-password",
"server",
"app",
"migration-tool",
"set-password",
"crates/*",
]
default-members = ["server"]
resolver = "2"
[workspace.package]
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
documentation = "https://github.com/lldap/lldap"
edition = "2024"
homepage = "https://github.com/lldap/lldap"
license = "GPL-3.0-only"
repository = "https://github.com/lldap/lldap"
[profile.release]
lto = true
@@ -19,3 +25,10 @@ opt-level = 's'
[patch.crates-io.lber]
git = 'https://github.com/inejge/ldap3/'
[workspace.dependencies.sea-orm]
version = "1.1.8"
default-features = false
[workspace.dependencies.serde]
version = "1"
+3 -3
View File
@@ -1,5 +1,5 @@
# Build image
FROM rust:alpine3.16 AS chef
FROM rust:alpine3.21 AS chef
RUN set -x \
# Add user
@@ -41,9 +41,9 @@ 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.21
ENV GOSU_VERSION 1.14
ENV GOSU_VERSION=1.14
# Fetch gosu from git
RUN set -eux; \
\
+5
View File
@@ -0,0 +1,5 @@
build-dev-container:
docker buildx build --tag lldap/rust-dev --file .github/workflows/Dockerfile.dev --push .github/workflows
prepare-release:
./prepare-release.sh
+25 -541
View File
@@ -34,29 +34,14 @@
</p>
- [About](#about)
- [Installation](#installation)
- [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)
- [Cross-compilation](#cross-compilation)
- [Installation](docs/install.md)
- [Usage](#usage)
- [Recommended architecture](#recommended-architecture)
- [Client configuration](#client-configuration)
- [Compatible services](#compatible-services)
- [Known compatible services](#known-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)
- [Comparisons with other services](#comparisons-with-other-services)
- [vs OpenLDAP](#vs-openldap)
- [vs FreeIPA](#vs-freeipa)
- [vs Kanidm](#vs-kanidm)
- [I can't log in!](#i-cant-log-in)
- [Frequently Asked Questions](#frequently-asked-questions)
- [Contributions](#contributions)
## About
@@ -98,383 +83,9 @@ MySQL/MariaDB or PostgreSQL.
## Installation
### With Docker
It's possible to install lldap from OCI images ([docker](docs/install.md#with-docker)/[podman](docs/install.md#with-podman)), from [Kubernetes](docs/install.md#with-kubernetes), or from [a regular distribution package manager](docs/install.md/#from-a-package-repository) (Archlinux, Debian, CentOS, Fedora, OpenSuse, Ubuntu, FreeBSD).
The image is available at `lldap/lldap`. You should persist the `/data`
folder, which contains your configuration and the SQLite database (you can
remove this step if you use a different DB and configure with environment
variables only).
Configure the server by copying the `lldap_config.docker_template.toml` to
`/data/lldap_config.toml` and updating the configuration values (especially the
`jwt_secret` and `ldap_user_pass`, unless you override them with env variables).
Environment variables should be prefixed with `LLDAP_` to override the
configuration.
If the `lldap_config.toml` doesn't exist when starting up, LLDAP will use
default one. The default admin password is `password`, you can change the
password later using the web interface.
Secrets can also be set through a file. The filename should be specified by the
variables `LLDAP_JWT_SECRET_FILE` or `LLDAP_KEY_SEED_FILE`, and the file
contents are loaded into the respective configuration parameters. Note that
`_FILE` variables take precedence.
Example for docker compose:
- You can use either the `:latest` tag image or `:stable` as used in this example.
- `:latest` tag image contains recently pushed code or feature tests, in which some instability can be expected.
- If `UID` and `GID` no defined LLDAP will use default `UID` and `GID` number `1000`.
- If no `TZ` is set, default `UTC` timezone will be used.
- You can generate the secrets by running `./generate_secrets.sh`
```yaml
version: "3"
volumes:
lldap_data:
driver: local
services:
lldap:
image: lldap/lldap:stable
ports:
# For LDAP, not recommended to expose, see Usage section.
#- "3890:3890"
# For LDAPS (LDAP Over SSL), enable port if LLDAP_LDAPS_OPTIONS__ENABLED set true, look env below
#- "6360:6360"
# For the web front-end
- "17170:17170"
volumes:
- "lldap_data:/data"
# Alternatively, you can mount a local folder
# - "./lldap_data:/data"
environment:
- UID=####
- GID=####
- TZ=####/####
- LLDAP_JWT_SECRET=REPLACE_WITH_RANDOM
- LLDAP_KEY_SEED=REPLACE_WITH_RANDOM
- LLDAP_LDAP_BASE_DN=dc=example,dc=com
# If using LDAPS, set enabled true and configure cert and key path
# - LLDAP_LDAPS_OPTIONS__ENABLED=true
# - LLDAP_LDAPS_OPTIONS__CERT_FILE=/path/to/certfile.crt
# - LLDAP_LDAPS_OPTIONS__KEY_FILE=/path/to/keyfile.key
# 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
front-end.
### With Kubernetes
See https://github.com/Evantage-WS/lldap-kubernetes for a LLDAP deployment for Kubernetes
You can bootstrap your lldap instance (users, groups)
using [bootstrap.sh](example_configs/bootstrap/bootstrap.md#kubernetes-job).
It can be run by Argo CD for managing users in git-opt way, or as a one-shot job.
### From a package repository
**Do not open issues in this repository for problems with third-party
pre-built packages. Report issues downstream.**
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.
<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>
### With FreeBSD
You can also install it as a rc.d service in FreeBSD, see
[FreeBSD-install.md](example_configs/freebsd/freebsd-install.md).
The rc.d script file
[rc.d_lldap](example_configs/freebsd/rc.d_lldap).
### From source
#### Backend
To compile the project, you'll need:
- curl and gzip: `sudo apt install curl gzip`
- Rust/Cargo: [rustup.rs](https://rustup.rs/)
Then you can compile the server (and the migration tool if you want):
```shell
cargo build --release -p lldap -p lldap_migration_tool
```
The resulting binaries will be in `./target/release/`. Alternatively, you can
just run `cargo run -- run` to run the server.
#### Frontend
To bring up the server, you'll need to compile the frontend. In addition to
`cargo`, you'll need WASM-pack, which can be installed by running `cargo install wasm-pack`.
Then you can build the frontend files with
```shell
./app/build.sh
```
(you'll need to run this after every front-end change to update the WASM
package served).
The default config is in `src/infra/configuration.rs`, but you can override it
by creating an `lldap_config.toml`, setting environment variables or passing
arguments to `cargo run`. Have a look at the docker template:
`lldap_config.docker_template.toml`.
You can also install it as a systemd service, see
[lldap.service](example_configs/lldap.service).
### Cross-compilation
Docker images are provided for AMD64, ARM64 and ARM/V7.
If you want to cross-compile yourself, you can do so by installing
[`cross`](https://github.com/rust-embedded/cross):
```sh
cargo install cross
cross build --target=armv7-unknown-linux-musleabihf -p lldap --release
./app/build.sh
```
(Replace `armv7-unknown-linux-musleabihf` with the correct Rust target for your
device.)
You can then get the compiled server binary in
`target/armv7-unknown-linux-musleabihf/release/lldap` and the various needed files
(`index.html`, `main.js`, `pkg` folder) in the `app` folder. Copy them to the
Raspberry Pi (or other target), with the folder structure maintained (`app`
files in an `app` folder next to the binary).
Building [from source](docs/install.md#from-source) and [cross-compiling](docs/install.md#cross-compilation) to a different hardware architecture is also supported.
## Usage
@@ -525,7 +136,7 @@ If you are using containers, a sample architecture could look like this:
## Client configuration
### Compatible services
### Known compatible services
Most services that can use LDAP as an authentication provider should work out
of the box. For new services, it's possible that they require a bit of tweaking
@@ -533,6 +144,13 @@ on LLDAP's side to make things work. In that case, just create an issue with
the relevant details (logs of the service, LLDAP logs with `verbose=true` in
the config).
Some specific clients have been tested to work and come with sample
configuration files, or guides. See the [`example_configs`](example_configs)
folder for example configs for integration with specific services.
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.
### General configuration guide
To configure the services that will talk to LLDAP, here are the values:
@@ -552,83 +170,9 @@ filter like: `(memberOf=cn=admins,ou=groups,dc=example,dc=com)`.
The administrator group for LLDAP is `lldap_admin`: anyone in this group has
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
configuration files, or guides. See the [`example_configs`](example_configs)
folder for help with:
- [Airsonic Advanced](example_configs/airsonic-advanced.md)
- [Apache Guacamole](example_configs/apacheguacamole.md)
- [Apereo CAS Server](example_configs/apereo_cas_server.md)
- [Authelia](example_configs/authelia_config.yml)
- [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)
- [Dolibarr](example_configs/dolibarr.md)
- [Ejabberd](example_configs/ejabberd.md)
- [Emby](example_configs/emby.md)
- [Ergo IRCd](example_configs/ergo.md)
- [Gitea](example_configs/gitea.md)
- [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)
- [Jenkins](example_configs/jenkins.md)
- [Jitsi Meet](example_configs/jitsi_meet.conf)
- [Kasm](example_configs/kasm.md)
- [KeyCloak](example_configs/keycloak.md)
- [LibreNMS](example_configs/librenms.md)
- [Maddy](example_configs/maddy.md)
- [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)
- [OneDev](example_configs/onedev.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)
- [Traccar](example_configs/traccar.xml)
- [Vaultwarden](example_configs/vaultwarden.md)
- [WeKan](example_configs/wekan.md)
- [WG Portal](example_configs/wg_portal.env.example)
- [WikiJS](example_configs/wikijs.md)
- [XBackBone](example_configs/xbackbone_config.php)
- [Zendto](example_configs/zendto.md)
- [Zitadel](example_configs/zitadel.md)
- [Zulip](example_configs/zulip.md)
administration access to many services. To prevent privilege escalation users in the
`lldap_password_manager` group are not allowed to change passwords of admins in the
`lldap_admin` group.
### Incompatible services
@@ -651,76 +195,16 @@ it duplicates the places from which a password hash could leak.
In that category, the most prominent is Synology. It is, to date, the only
service that seems definitely incompatible with LLDAP.
## Migrating from SQLite
## Frequently Asked Questions
If you started with an SQLite database and would like to migrate to
MySQL/MariaDB or PostgreSQL, check out the [DB
migration docs](/docs/database_migration.md).
## Comparisons with other services
### vs OpenLDAP
[OpenLDAP](https://www.openldap.org) is a monster of a service that implements
all of LDAP and all of its extensions, plus some of its own. That said, if you
need all that flexibility, it might be what you need! Note that installation
can be a bit painful (figuring out how to use `slapd`) and people have mixed
experiences following tutorials online. If you don't configure it properly, you
might end up storing passwords in clear, so a breach of your server would
reveal all the stored passwords!
OpenLDAP doesn't come with a UI: if you want a web interface, you'll have to
install one (not that many look nice) and configure it.
LLDAP is much simpler to setup, has a much smaller image (10x smaller, 20x if
you add PhpLdapAdmin), and comes packed with its own purpose-built web UI.
However, it's not as flexible as OpenLDAP.
### vs FreeIPA
[FreeIPA](http://www.freeipa.org) is the one-stop shop for identity management:
LDAP, Kerberos, NTP, DNS, Samba, you name it, it has it. In addition to user
management, it also does security policies, single sign-on, certificate
management, linux account management and so on.
If you need all of that, go for it! Keep in mind that a more complex system is
more complex to maintain, though.
LLDAP is much lighter to run (<10 MB RAM including the DB), easier to
configure (no messing around with DNS or security policies) and simpler to
use. It also comes conveniently packed in a docker container.
### vs Kanidm
[Kanidm](https://kanidm.com) is an up-and-coming Rust identity management
platform, covering all your bases: OAuth, Linux accounts, SSH keys, Radius,
WebAuthn. It comes with a (read-only) LDAPS server.
It's fairly easy to install and does much more; but their LDAP server is
read-only, and by having more moving parts it is inherently more complex. If
you don't need to modify the users through LDAP and you're planning on
installing something like [KeyCloak](https://www.keycloak.org) to provide
modern identity protocols, check out Kanidm.
## I can't log in!
If you just set up the server, can get to the login page but the password you
set isn't working, try the following:
- (For docker): Make sure that the `/data` folder is persistent, either to a
docker volume or mounted from the host filesystem.
- Check if there is a `lldap_config.toml` file (either in `/data` for docker
or in the current directory). If there isn't, copy
`lldap_config.docker_template.toml` there, and fill in the various values
(passwords, secrets, ...).
- Check if there is a `users.db` file (either in `/data` for docker or where
you specified the DB URL, which defaults to the current directory). If
there isn't, check that the user running the command (user with ID 10001
for docker) has the rights to write to the `/data` folder. If in doubt, you
can `chmod 777 /data` (or whatever the folder) to make it world-writeable.
- Make sure you restart the server.
- If it's still not working, join the
[Discord server](https://discord.gg/h5PEdRMNyP) to ask for help.
- [I can't login](docs/faq.md#i-cant-log-in)
- [Discord Integration](docs/faq.md#discord-integration)
- [Migrating from SQLite](docs/faq.md#migrating-from-sqlite)
- How does lldap compare [with OpenLDAP](docs/faq.md#how-does-lldap-compare-with-openldap)? [With FreeIPA](docs/faq.md#how-does-lldap-compare-with-freeipa)? [With Kanidm](docs/faq.md#how-does-lldap-compare-with-kanidm)?
- [Does lldap support vhosts?](docs/faq.md#does-lldap-support-vhosts)
- [Does lldap provide commercial support contracts?](docs/faq.md#does-lldap-provide-commercial-support-contracts)
- [Can I make a donation to fund development?](docs/faq.md#can-i-make-a-donation-to-fund-development)
- [Is lldap sustainable? Can we depend on it for our infrastructure?](docs/faq.md#is-lldap-sustainable-can-we-depend-on-it-for-our-infrastructure)
## Contributions
+32 -10
View File
@@ -1,13 +1,13 @@
[package]
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
description = "Frontend for LLDAP"
edition = "2021"
homepage = "https://github.com/lldap/lldap"
license = "GPL-3.0-only"
name = "lldap_app"
repository = "https://github.com/lldap/lldap"
version = "0.6.1"
version = "0.6.2"
description = "Frontend for LLDAP"
edition.workspace = true
include = ["src/**/*", "queries/**/*", "Cargo.toml", "../schema.graphql"]
authors.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
anyhow = "1"
@@ -19,12 +19,11 @@ graphql_client = "0.10"
http = "0.2"
jwt = "0.13"
rand = "0.8"
serde = "1"
serde_json = "1"
url-escape = "0.1.1"
validator = "0.14"
validator_derive = "0.14"
wasm-bindgen = "0.2"
wasm-bindgen = "0.2.100"
wasm-bindgen-futures = "*"
yew = "0.19.3"
yew-router = "0.16"
@@ -56,15 +55,33 @@ features = [
"wasmbind"
]
[dependencies.derive_more]
features = ["debug", "display", "from", "from_str"]
default-features = false
version = "1"
[dependencies.lldap_auth]
path = "../auth"
path = "../crates/auth"
features = [ "opaque_client" ]
[dependencies.lldap_frontend_options]
path = "../crates/frontend-options"
[dependencies.lldap_validation]
path = "../crates/validation"
[dependencies.image]
features = ["jpeg"]
default-features = false
version = "0.24"
[dependencies.serde]
workspace = true
[dependencies.strum]
features = ["derive"]
version = "0.25"
[dependencies.yew_form]
git = "https://github.com/jfbilodeau/yew_form"
rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9"
@@ -76,6 +93,11 @@ rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9"
[lib]
crate-type = ["cdylib"]
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = [
'cfg(wasm_bindgen_unstable_test_coverage)',
] }
[package.metadata.wasm-pack.profile.dev]
wasm-opt = ['--enable-bulk-memory']
[package.metadata.wasm-pack.profile.profiling]
+36 -26
View File
@@ -21,16 +21,16 @@ use crate::{
};
use gloo_console::error;
use lldap_frontend_options::Options;
use yew::{
function_component,
Context, function_component,
html::Scope,
prelude::{html, Component, Html},
Context,
prelude::{Component, Html, html},
};
use yew_router::{
BrowserRouter, Switch,
prelude::{History, Location},
scope_ext::RouterScopeExt,
BrowserRouter, Switch,
};
#[function_component(AppContainer)]
@@ -51,7 +51,7 @@ pub struct App {
pub enum Msg {
Login((String, bool)),
Logout,
PasswordResetProbeFinished(anyhow::Result<bool>),
SettingsReceived(anyhow::Result<Options>),
}
impl Component for App {
@@ -76,9 +76,8 @@ impl Component for App {
redirect_to: Self::get_redirect_route(ctx),
password_reset_enabled: None,
};
ctx.link().send_future(async move {
Msg::PasswordResetProbeFinished(HostService::probe_password_reset().await)
});
ctx.link()
.send_future(async move { Msg::SettingsReceived(HostService::get_settings().await) });
app.apply_initial_redirections(ctx);
app
}
@@ -103,14 +102,11 @@ impl Component for App {
self.redirect_to = None;
history.push(AppRoute::Login);
}
Msg::PasswordResetProbeFinished(Ok(enabled)) => {
self.password_reset_enabled = Some(enabled);
Msg::SettingsReceived(Ok(settings)) => {
self.password_reset_enabled = Some(settings.password_reset_enabled);
}
Msg::PasswordResetProbeFinished(Err(err)) => {
self.password_reset_enabled = Some(false);
error!(&format!(
"Could not probe for password reset support: {err:#}"
));
Msg::SettingsReceived(Err(err)) => {
error!(err.to_string());
}
}
true
@@ -126,7 +122,7 @@ impl Component for App {
<Banner is_admin={is_admin} username={username} on_logged_out={link.callback(|_| Msg::Logout)} />
<div class="container py-3 bg-kug">
<div class="row justify-content-center" style="padding-bottom: 80px;">
<main class="py-3" style="max-width: 1000px">
<main class="py-3">
<Switch<AppRoute>
render={Switch::render(move |routes| Self::dispatch_route(routes, &link, is_admin, password_reset_enabled))}
/>
@@ -200,15 +196,21 @@ impl App {
AppRoute::CreateUser => html! {
<CreateUserForm/>
},
AppRoute::Index | AppRoute::ListUsers => html! {
<div>
<UserTable />
AppRoute::Index | AppRoute::ListUsers => {
let user_button = html! {
<Link classes="btn btn-primary" to={AppRoute::CreateUser}>
<i class="bi-person-plus me-2"></i>
{"Create a user"}
</Link>
</div>
},
};
html! {
<div>
{ user_button.clone() }
<UserTable />
{ user_button }
</div>
}
}
AppRoute::CreateGroup => html! {
<CreateGroupForm/>
},
@@ -218,15 +220,23 @@ impl App {
AppRoute::CreateGroupAttribute => html! {
<CreateGroupAttributeForm/>
},
AppRoute::ListGroups => html! {
<div>
<GroupTable />
AppRoute::ListGroups => {
let group_button = html! {
<Link classes="btn btn-primary" to={AppRoute::CreateGroup}>
<i class="bi-plus-circle me-2"></i>
{"Create a group"}
</Link>
</div>
},
};
// Note: There's a weird bug when switching from the users page to the groups page
// where the two groups buttons are at the bottom. I don't know why.
html! {
<div>
{ group_button.clone() }
<GroupTable />
{ group_button }
</div>
}
}
AppRoute::ListUserSchema => html! {
<ListUserSchema />
},
+2 -2
View File
@@ -1,6 +1,6 @@
use crate::infra::functional::{use_graphql_call, LoadableResult};
use crate::infra::functional::{LoadableResult, use_graphql_call};
use graphql_client::GraphQLQuery;
use yew::{function_component, html, virtual_dom::AttrValue, Properties};
use yew::{Properties, function_component, html, virtual_dom::AttrValue};
#[derive(GraphQLQuery)]
#[graphql(
+1 -1
View File
@@ -4,7 +4,7 @@ use crate::components::{
router::{AppRoute, Link},
};
use wasm_bindgen::prelude::wasm_bindgen;
use yew::{function_component, html, Callback, Properties};
use yew::{Callback, Properties, function_component, html};
#[derive(Properties, PartialEq)]
pub struct Props {
+1 -1
View File
@@ -8,7 +8,7 @@ use crate::{
common_component::{CommonComponent, CommonComponentParts},
},
};
use anyhow::{anyhow, bail, Result};
use anyhow::{Result, anyhow, bail};
use gloo_console::error;
use lldap_auth::*;
use validator_derive::Validate;
+7 -9
View File
@@ -7,17 +7,16 @@ use crate::{
},
router::AppRoute,
},
convert_attribute_type,
infra::{
common_component::{CommonComponent, CommonComponentParts},
form_utils::{
read_all_form_attributes, AttributeValue, EmailIsRequired, GraphQlAttributeSchema,
IsAdmin,
AttributeValue, EmailIsRequired, GraphQlAttributeSchema, IsAdmin,
read_all_form_attributes,
},
schema::AttributeType,
},
};
use anyhow::{ensure, Result};
use anyhow::{Result, ensure};
use gloo_console::log;
use graphql_client::GraphQLQuery;
use validator_derive::Validate;
@@ -30,7 +29,8 @@ use yew_router::{prelude::History, scope_ext::RouterScopeExt};
schema_path = "../schema.graphql",
query_path = "queries/get_group_attributes_schema.graphql",
response_derives = "Debug,Clone,PartialEq,Eq",
custom_scalars_module = "crate::infra::graphql"
custom_scalars_module = "crate::infra::graphql",
extern_enums("AttributeType")
)]
pub struct GetGroupAttributesSchema;
@@ -39,8 +39,6 @@ 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 {
@@ -218,14 +216,14 @@ fn get_custom_attribute_input(attribute_schema: &Attribute) -> Html {
html! {
<ListAttributeInput
name={attribute_schema.name.clone()}
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
attribute_type={attribute_schema.attribute_type}
/>
}
} else {
html! {
<SingleAttributeInput
name={attribute_schema.name.clone()}
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
attribute_type={attribute_schema.attribute_type}
/>
}
}
+16 -9
View File
@@ -3,15 +3,15 @@ use crate::{
form::{checkbox::CheckBox, field::Field, select::Select, submit::Submit},
router::AppRoute,
},
convert_attribute_type,
infra::{
common_component::{CommonComponent, CommonComponentParts},
schema::{validate_attribute_type, AttributeType},
schema::{AttributeType, validate_attribute_type},
},
};
use anyhow::{bail, Result};
use anyhow::{Result, bail};
use gloo_console::log;
use graphql_client::GraphQLQuery;
use lldap_validation::attributes::validate_attribute_name;
use validator_derive::Validate;
use yew::prelude::*;
use yew_form_derive::Model;
@@ -22,12 +22,11 @@ use yew_router::{prelude::History, scope_ext::RouterScopeExt};
schema_path = "../schema.graphql",
query_path = "queries/create_group_attribute.graphql",
response_derives = "Debug",
custom_scalars_module = "crate::infra::graphql"
custom_scalars_module = "crate::infra::graphql",
extern_enums("AttributeType")
)]
pub struct CreateGroupAttribute;
convert_attribute_type!(create_group_attribute::AttributeType);
pub struct CreateGroupAttributeForm {
common: CommonComponentParts<Self>,
form: yew_form::Form<CreateGroupAttributeModel>,
@@ -62,10 +61,18 @@ impl CommonComponent<CreateGroupAttributeForm> for CreateGroupAttributeForm {
bail!("Check the form for errors");
}
let model = self.form.model();
let attribute_type = model.attribute_type.parse::<AttributeType>().unwrap();
validate_attribute_name(&model.attribute_name).or_else(|invalid_chars| {
let invalid = String::from_iter(invalid_chars);
bail!(
"Attribute name contains one or more invalid characters: {}",
invalid
);
})?;
let attribute_type =
AttributeType::try_from(model.attribute_type.as_str()).unwrap();
let req = create_group_attribute::Variables {
name: model.attribute_name,
attribute_type: create_group_attribute::AttributeType::from(attribute_type),
attribute_type,
is_list: model.is_list,
is_visible: model.is_visible,
};
@@ -137,7 +144,7 @@ impl Component for CreateGroupAttributeForm {
oninput={link.callback(|_| Msg::Update)}>
<option selected=true value="String">{"String"}</option>
<option value="Integer">{"Integer"}</option>
<option value="Jpeg">{"Jpeg"}</option>
<option value="JpegPhoto">{"Jpeg"}</option>
<option value="DateTime">{"DateTime"}</option>
</Select<CreateGroupAttributeModel>>
<CheckBox<CreateGroupAttributeModel>
+7 -9
View File
@@ -7,18 +7,17 @@ use crate::{
},
router::AppRoute,
},
convert_attribute_type,
infra::{
api::HostService,
common_component::{CommonComponent, CommonComponentParts},
form_utils::{
read_all_form_attributes, AttributeValue, EmailIsRequired, GraphQlAttributeSchema,
IsAdmin,
AttributeValue, EmailIsRequired, GraphQlAttributeSchema, IsAdmin,
read_all_form_attributes,
},
schema::AttributeType,
},
};
use anyhow::{ensure, Result};
use anyhow::{Result, ensure};
use gloo_console::log;
use graphql_client::GraphQLQuery;
use lldap_auth::{opaque, registration};
@@ -32,7 +31,8 @@ use yew_router::{prelude::History, scope_ext::RouterScopeExt};
schema_path = "../schema.graphql",
query_path = "queries/get_user_attributes_schema.graphql",
response_derives = "Debug,Clone,PartialEq,Eq",
custom_scalars_module = "crate::infra::graphql"
custom_scalars_module = "crate::infra::graphql",
extern_enums("AttributeType")
)]
pub struct GetUserAttributesSchema;
@@ -40,8 +40,6 @@ 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 {
@@ -310,14 +308,14 @@ fn get_custom_attribute_input(attribute_schema: &Attribute) -> Html {
html! {
<ListAttributeInput
name={attribute_schema.name.clone()}
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
attribute_type={attribute_schema.attribute_type}
/>
}
} else {
html! {
<SingleAttributeInput
name={attribute_schema.name.clone()}
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
attribute_type={attribute_schema.attribute_type}
/>
}
}
+16 -9
View File
@@ -3,15 +3,15 @@ use crate::{
form::{checkbox::CheckBox, field::Field, select::Select, submit::Submit},
router::AppRoute,
},
convert_attribute_type,
infra::{
common_component::{CommonComponent, CommonComponentParts},
schema::{validate_attribute_type, AttributeType},
schema::{AttributeType, validate_attribute_type},
},
};
use anyhow::{bail, Result};
use anyhow::{Result, bail};
use gloo_console::log;
use graphql_client::GraphQLQuery;
use lldap_validation::attributes::validate_attribute_name;
use validator_derive::Validate;
use yew::prelude::*;
use yew_form_derive::Model;
@@ -22,12 +22,11 @@ use yew_router::{prelude::History, scope_ext::RouterScopeExt};
schema_path = "../schema.graphql",
query_path = "queries/create_user_attribute.graphql",
response_derives = "Debug",
custom_scalars_module = "crate::infra::graphql"
custom_scalars_module = "crate::infra::graphql",
extern_enums("AttributeType")
)]
pub struct CreateUserAttribute;
convert_attribute_type!(create_user_attribute::AttributeType);
pub struct CreateUserAttributeForm {
common: CommonComponentParts<Self>,
form: yew_form::Form<CreateUserAttributeModel>,
@@ -66,10 +65,18 @@ impl CommonComponent<CreateUserAttributeForm> for CreateUserAttributeForm {
if model.is_editable && !model.is_visible {
bail!("Editable attributes must also be visible");
}
let attribute_type = model.attribute_type.parse::<AttributeType>().unwrap();
validate_attribute_name(&model.attribute_name).or_else(|invalid_chars| {
let invalid = String::from_iter(invalid_chars);
bail!(
"Attribute name contains one or more invalid characters: {}",
invalid
);
})?;
let attribute_type =
AttributeType::try_from(model.attribute_type.as_str()).unwrap();
let req = create_user_attribute::Variables {
name: model.attribute_name,
attribute_type: create_user_attribute::AttributeType::from(attribute_type),
attribute_type,
is_editable: model.is_editable,
is_list: model.is_list,
is_visible: model.is_visible,
@@ -139,7 +146,7 @@ impl Component for CreateUserAttributeForm {
oninput={link.callback(|_| Msg::Update)}>
<option selected=true value="String">{"String"}</option>
<option value="Integer">{"Integer"}</option>
<option value="Jpeg">{"Jpeg"}</option>
<option value="JpegPhoto">{"Jpeg"}</option>
<option value="DateTime">{"DateTime"}</option>
</Select<CreateUserAttributeModel>>
<CheckBox<CreateUserAttributeModel>
+9 -9
View File
@@ -4,8 +4,8 @@ use crate::{
};
use web_sys::Element;
use yew::{
function_component, html, use_effect_with_deps, use_node_ref, virtual_dom::AttrValue,
Component, Context, Html, Properties,
Component, Context, Html, Properties, function_component, html, use_effect_with_deps,
use_node_ref, virtual_dom::AttrValue,
};
#[derive(Properties, PartialEq)]
@@ -24,12 +24,12 @@ fn attribute_input(props: &AttributeInputProps) -> Html {
AttributeType::DateTime => {
return html! {
<DateTimeInput name={props.name.clone()} value={props.value.clone()} />
}
};
}
AttributeType::Jpeg => {
AttributeType::JpegPhoto => {
return html! {
<JpegFileInput name={props.name.clone()} value={props.value.clone()} />
}
};
}
};
@@ -82,7 +82,7 @@ fn attribute_label(props: &AttributeLabelProps) -> Html {
#[derive(Properties, PartialEq)]
pub struct SingleAttributeInputProps {
pub name: String,
pub attribute_type: AttributeType,
pub(crate) attribute_type: AttributeType,
#[prop_or(None)]
pub value: Option<String>,
}
@@ -94,7 +94,7 @@ pub fn single_attribute_input(props: &SingleAttributeInputProps) -> Html {
<AttributeLabel name={props.name.clone()} />
<div class="col-8">
<AttributeInput
attribute_type={props.attribute_type.clone()}
attribute_type={props.attribute_type}
name={props.name.clone()}
value={props.value.clone()} />
</div>
@@ -105,7 +105,7 @@ pub fn single_attribute_input(props: &SingleAttributeInputProps) -> Html {
#[derive(Properties, PartialEq)]
pub struct ListAttributeInputProps {
pub name: String,
pub attribute_type: AttributeType,
pub(crate) attribute_type: AttributeType,
#[prop_or(vec!())]
pub values: Vec<String>,
}
@@ -165,7 +165,7 @@ impl Component for ListAttributeInput {
{self.indices.iter().map(|&i| html! {
<div class="input-group mb-2" key={i}>
<AttributeInput
attribute_type={props.attribute_type.clone()}
attribute_type={props.attribute_type}
name={props.name.clone()}
value={props.values.get(i).cloned().unwrap_or_default()} />
<button
+1 -1
View File
@@ -1,4 +1,4 @@
use yew::{function_component, html, virtual_dom::AttrValue, Callback, Properties};
use yew::{Callback, Properties, function_component, html, virtual_dom::AttrValue};
use yew_form::{Form, Model};
#[derive(Properties, PartialEq)]
+1 -1
View File
@@ -3,7 +3,7 @@ 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};
use yew::{Event, Properties, function_component, html, use_state, virtual_dom::AttrValue};
#[derive(Properties, PartialEq)]
pub struct DateTimeInputProps {
+1 -1
View File
@@ -1,4 +1,4 @@
use yew::{function_component, html, virtual_dom::AttrValue, Callback, InputEvent, Properties};
use yew::{Callback, InputEvent, Properties, function_component, html, virtual_dom::AttrValue};
use yew_form::{Form, Model};
#[derive(Properties, PartialEq)]
+2 -2
View File
@@ -1,9 +1,9 @@
use std::{fmt::Display, str::FromStr};
use anyhow::{bail, Error, Ok, Result};
use anyhow::{Error, Ok, Result, bail};
use gloo_file::{
callbacks::{read_as_bytes, FileReader},
File,
callbacks::{FileReader, read_as_bytes},
};
use web_sys::{FileList, HtmlInputElement, InputEvent};
use yew::Properties;
+1 -1
View File
@@ -1,5 +1,5 @@
use yew::{
function_component, html, virtual_dom::AttrValue, Callback, Children, InputEvent, Properties,
Callback, Children, InputEvent, Properties, function_component, html, virtual_dom::AttrValue,
};
use yew_form::{Form, Model};
+1 -1
View File
@@ -1,4 +1,4 @@
use yew::{function_component, html, virtual_dom::AttrValue, Children, Properties};
use yew::{Children, Properties, function_component, html, virtual_dom::AttrValue};
#[derive(Properties, PartialEq)]
pub struct Props {
+1 -1
View File
@@ -1,5 +1,5 @@
use web_sys::MouseEvent;
use yew::{function_component, html, virtual_dom::AttrValue, Callback, Children, Properties};
use yew::{Callback, Children, Properties, function_component, html, virtual_dom::AttrValue};
#[derive(Properties, PartialEq)]
pub struct Props {
@@ -0,0 +1,52 @@
use crate::infra::attributes::AttributeDescription;
use lldap_validation::attributes::{ALLOWED_CHARACTERS_DESCRIPTION, validate_attribute_name};
use yew::{Html, html};
fn render_attribute_aliases(attribute_description: &AttributeDescription) -> Html {
if attribute_description.aliases.is_empty() {
html! {}
} else {
html! {
<>
<br/>
<small class="text-muted">
{"Aliases: "}
{attribute_description.aliases.join(", ")}
</small>
</>
}
}
}
fn render_attribute_validation_warnings(attribute_name: &str) -> Html {
match validate_attribute_name(attribute_name) {
Ok(()) => {
html! {}
}
Err(_invalid_chars) => {
html! {
<>
<br/>
<small class="text-warning">
{"Warning: This attribute uses one or more invalid characters "}
{"("}{ALLOWED_CHARACTERS_DESCRIPTION}{"). "}
{"Some clients may not support it."}
</small>
</>
}
}
}
}
pub fn render_attribute_name(
hardcoded: bool,
attribute_description: &AttributeDescription,
) -> Html {
html! {
<>
{&attribute_description.attribute_name}
{if hardcoded {render_attribute_aliases(attribute_description)} else {html!{}}}
{render_attribute_validation_warnings(attribute_description.attribute_name)}
</>
}
}
+1
View File
@@ -0,0 +1 @@
pub mod attribute_schema;
+4 -6
View File
@@ -5,13 +5,13 @@ use crate::{
remove_user_from_group::RemoveUserFromGroupComponent,
router::{AppRoute, Link},
},
convert_attribute_type,
infra::{
common_component::{CommonComponent, CommonComponentParts},
form_utils::GraphQlAttributeSchema,
schema::AttributeType,
},
};
use anyhow::{bail, Error, Result};
use anyhow::{Error, Result, bail};
use graphql_client::GraphQLQuery;
use yew::prelude::*;
@@ -20,7 +20,8 @@ use yew::prelude::*;
schema_path = "../schema.graphql",
query_path = "queries/get_group_details.graphql",
response_derives = "Debug, Hash, PartialEq, Eq, Clone",
custom_scalars_module = "crate::infra::graphql"
custom_scalars_module = "crate::infra::graphql",
extern_enums("AttributeType")
)]
pub struct GetGroupDetails;
@@ -29,9 +30,6 @@ 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 {
+3 -4
View File
@@ -9,8 +9,7 @@ use crate::{
},
infra::{
common_component::{CommonComponent, CommonComponentParts},
form_utils::{read_all_form_attributes, AttributeValue, EmailIsRequired, IsAdmin},
schema::AttributeType,
form_utils::{AttributeValue, EmailIsRequired, IsAdmin, read_all_form_attributes},
},
};
use anyhow::{Ok, Result};
@@ -174,7 +173,7 @@ fn get_custom_attribute_input(
html! {
<ListAttributeInput
name={attribute_schema.name.clone()}
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
attribute_type={attribute_schema.attribute_type}
values={values}
/>
}
@@ -182,7 +181,7 @@ fn get_custom_attribute_input(
html! {
<SingleAttributeInput
name={attribute_schema.name.clone()}
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
attribute_type={attribute_schema.attribute_type}
value={values.first().cloned().unwrap_or_default()}
/>
}
+22 -21
View File
@@ -1,15 +1,16 @@
use crate::{
components::{
delete_group_attribute::DeleteGroupAttribute,
fragments::attribute_schema::render_attribute_name,
router::{AppRoute, Link},
},
convert_attribute_type,
infra::{
attributes::group,
common_component::{CommonComponent, CommonComponentParts},
schema::AttributeType,
},
};
use anyhow::{anyhow, Error, Result};
use anyhow::{Error, Result, anyhow};
use gloo_console::log;
use graphql_client::GraphQLQuery;
use yew::prelude::*;
@@ -19,7 +20,8 @@ use yew::prelude::*;
schema_path = "../schema.graphql",
query_path = "queries/get_group_attributes_schema.graphql",
response_derives = "Debug,Clone,PartialEq,Eq",
custom_scalars_module = "crate::infra::graphql"
custom_scalars_module = "crate::infra::graphql",
extern_enums("AttributeType")
)]
pub struct GetGroupAttributesSchema;
@@ -28,8 +30,6 @@ use get_group_attributes_schema::ResponseData;
pub type Attribute =
get_group_attributes_schema::GetGroupAttributesSchemaSchemaGroupSchemaAttributes;
convert_attribute_type!(get_group_attributes_schema::AttributeType);
#[derive(yew::Properties, Clone, PartialEq, Eq)]
pub struct Props {
pub hardcoded: bool,
@@ -55,21 +55,21 @@ impl CommonComponent<GroupSchemaTable> for GroupSchemaTable {
Ok(true)
}
Msg::OnError(e) => Err(e),
Msg::OnAttributeDeleted(attribute_name) => {
match self.attributes {
None => {
log!(format!("Attribute {attribute_name} was deleted but component has no attributes"));
Err(anyhow!("invalid state"))
}
Some(_) => {
self.attributes
.as_mut()
.unwrap()
.retain(|a| a.name != attribute_name);
Ok(true)
}
Msg::OnAttributeDeleted(attribute_name) => match self.attributes {
None => {
log!(format!(
"Attribute {attribute_name} was deleted but component has no attributes"
));
Err(anyhow!("invalid state"))
}
}
Some(_) => {
self.attributes
.as_mut()
.unwrap()
.retain(|a| a.name != attribute_name);
Ok(true)
}
},
}
}
@@ -145,16 +145,17 @@ impl GroupSchemaTable {
fn view_attribute(&self, ctx: &Context<Self>, attribute: &Attribute) -> Html {
let link = ctx.link();
let attribute_type = AttributeType::from(attribute.attribute_type.clone());
let attribute_type = attribute.attribute_type;
let checkmark = html! {
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check" viewBox="0 0 16 16">
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z"></path>
</svg>
};
let hardcoded = ctx.props().hardcoded;
let desc = group::resolve_group_attribute_description_or_default(&attribute.name);
html! {
<tr key={attribute.name.clone()}>
<td>{&attribute.name}</td>
<td>{render_attribute_name(hardcoded, &desc)}</td>
<td>{if attribute.is_list { format!("List<{attribute_type}>")} else {attribute_type.to_string()}}</td>
<td>{if attribute.is_visible {checkmark.clone()} else {html!{}}}</td>
{
+1 -1
View File
@@ -8,7 +8,7 @@ use crate::{
common_component::{CommonComponent, CommonComponentParts},
},
};
use anyhow::{anyhow, bail, Result};
use anyhow::{Result, anyhow, bail};
use gloo_console::error;
use lldap_auth::*;
use validator_derive::Validate;
+1
View File
@@ -13,6 +13,7 @@ pub mod delete_group_attribute;
pub mod delete_user;
pub mod delete_user_attribute;
pub mod form;
pub mod fragments;
pub mod group_details;
pub mod group_details_form;
pub mod group_schema_table;
+1 -1
View File
@@ -5,7 +5,7 @@ use crate::{
common_component::{CommonComponent, CommonComponentParts},
},
};
use anyhow::{bail, Result};
use anyhow::{Result, bail};
use validator_derive::Validate;
use yew::prelude::*;
use yew_form::Form;
+3 -3
View File
@@ -8,7 +8,7 @@ use crate::{
common_component::{CommonComponent, CommonComponentParts},
},
};
use anyhow::{bail, Result};
use anyhow::{Result, bail};
use lldap_auth::{
opaque::client::registration as opaque_registration,
password_reset::ServerPasswordResetResponse, registration,
@@ -148,7 +148,7 @@ impl Component for ResetPasswordStep2Form {
(None, None) => {
return html! {
{"Validating token"}
}
};
}
(None, Some(e)) => {
return html! {
@@ -163,7 +163,7 @@ impl Component for ResetPasswordStep2Form {
{"Back"}
</Link>
</>
}
};
}
_ => (),
};
+4 -6
View File
@@ -5,13 +5,13 @@ use crate::{
router::{AppRoute, Link},
user_details_form::UserDetailsForm,
},
convert_attribute_type,
infra::{
common_component::{CommonComponent, CommonComponentParts},
form_utils::GraphQlAttributeSchema,
schema::AttributeType,
},
};
use anyhow::{bail, Error, Result};
use anyhow::{Error, Result, bail};
use graphql_client::GraphQLQuery;
use yew::prelude::*;
@@ -20,7 +20,8 @@ use yew::prelude::*;
schema_path = "../schema.graphql",
query_path = "queries/get_user_details.graphql",
response_derives = "Debug, Hash, PartialEq, Eq, Clone",
custom_scalars_module = "crate::infra::graphql"
custom_scalars_module = "crate::infra::graphql",
extern_enums("AttributeType")
)]
pub struct GetUserDetails;
@@ -28,9 +29,6 @@ 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 {
+15 -4
View File
@@ -9,11 +9,12 @@ use crate::{
},
infra::{
common_component::{CommonComponent, CommonComponentParts},
form_utils::{read_all_form_attributes, AttributeValue, EmailIsRequired, IsAdmin},
form_utils::{AttributeValue, EmailIsRequired, IsAdmin, read_all_form_attributes},
schema::AttributeType,
},
};
use anyhow::{Ok, Result};
use gloo_console::console;
use graphql_client::GraphQLQuery;
use yew::prelude::*;
@@ -168,7 +169,7 @@ fn get_custom_attribute_input(
html! {
<ListAttributeInput
name={attribute_schema.name.clone()}
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
attribute_type={attribute_schema.attribute_type}
values={values}
/>
}
@@ -176,7 +177,7 @@ fn get_custom_attribute_input(
html! {
<SingleAttributeInput
name={attribute_schema.name.clone()}
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
attribute_type={attribute_schema.attribute_type}
value={values.first().cloned().unwrap_or_default()}
/>
}
@@ -192,9 +193,19 @@ fn get_custom_attribute_static(
.find(|a| a.name == attribute_schema.name)
.map(|attribute| attribute.value.clone())
.unwrap_or_default();
let value_to_str = match attribute_schema.attribute_type {
AttributeType::String | AttributeType::Integer => |v: String| v,
AttributeType::DateTime => |v: String| {
console!(format!("Parsing date: {}", &v));
chrono::DateTime::parse_from_rfc3339(&v)
.map(|dt| dt.naive_utc().to_string())
.unwrap_or_else(|_| "Invalid date".to_string())
},
AttributeType::JpegPhoto => |_: String| "Unimplemented JPEG display".to_string(),
};
html! {
<StaticValue label={attribute_schema.name.clone()} id={attribute_schema.name.clone()}>
{values.into_iter().map(|x| html!{<div>{x}</div>}).collect::<Vec<_>>()}
{values.into_iter().map(|x| html!{<div>{value_to_str(x)}</div>}).collect::<Vec<_>>()}
</StaticValue>
}
}
+22 -21
View File
@@ -1,15 +1,16 @@
use crate::{
components::{
delete_user_attribute::DeleteUserAttribute,
fragments::attribute_schema::render_attribute_name,
router::{AppRoute, Link},
},
convert_attribute_type,
infra::{
attributes::user,
common_component::{CommonComponent, CommonComponentParts},
schema::AttributeType,
},
};
use anyhow::{anyhow, Error, Result};
use anyhow::{Error, Result, anyhow};
use gloo_console::log;
use graphql_client::GraphQLQuery;
use yew::prelude::*;
@@ -19,7 +20,8 @@ use yew::prelude::*;
schema_path = "../schema.graphql",
query_path = "queries/get_user_attributes_schema.graphql",
response_derives = "Debug,Clone,PartialEq,Eq",
custom_scalars_module = "crate::infra::graphql"
custom_scalars_module = "crate::infra::graphql",
extern_enums("AttributeType")
)]
pub struct GetUserAttributesSchema;
@@ -27,8 +29,6 @@ use get_user_attributes_schema::ResponseData;
pub type Attribute = get_user_attributes_schema::GetUserAttributesSchemaSchemaUserSchemaAttributes;
convert_attribute_type!(get_user_attributes_schema::AttributeType);
#[derive(yew::Properties, Clone, PartialEq, Eq)]
pub struct Props {
pub hardcoded: bool,
@@ -53,21 +53,21 @@ impl CommonComponent<UserSchemaTable> for UserSchemaTable {
Ok(true)
}
Msg::OnError(e) => Err(e),
Msg::OnAttributeDeleted(attribute_name) => {
match self.attributes {
None => {
log!(format!("Attribute {attribute_name} was deleted but component has no attributes"));
Err(anyhow!("invalid state"))
}
Some(_) => {
self.attributes
.as_mut()
.unwrap()
.retain(|a| a.name != attribute_name);
Ok(true)
}
Msg::OnAttributeDeleted(attribute_name) => match self.attributes {
None => {
log!(format!(
"Attribute {attribute_name} was deleted but component has no attributes"
));
Err(anyhow!("invalid state"))
}
}
Some(_) => {
self.attributes
.as_mut()
.unwrap()
.retain(|a| a.name != attribute_name);
Ok(true)
}
},
}
}
@@ -144,16 +144,17 @@ impl UserSchemaTable {
fn view_attribute(&self, ctx: &Context<Self>, attribute: &Attribute) -> Html {
let link = ctx.link();
let attribute_type = AttributeType::from(attribute.attribute_type.clone());
let attribute_type = attribute.attribute_type;
let checkmark = html! {
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check" viewBox="0 0 16 16">
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z"></path>
</svg>
};
let hardcoded = ctx.props().hardcoded;
let desc = user::resolve_user_attribute_description_or_default(&attribute.name);
html! {
<tr key={attribute.name.clone()}>
<td>{&attribute.name}</td>
<td>{render_attribute_name(hardcoded, &desc)}</td>
<td>{if attribute.is_list { format!("List<{attribute_type}>")} else {attribute_type.to_string()}}</td>
<td>{if attribute.is_editable {checkmark.clone()} else {html!{}}}</td>
<td>{if attribute.is_visible {checkmark.clone()} else {html!{}}}</td>
+13 -14
View File
@@ -1,10 +1,11 @@
use super::cookies::set_cookie;
use anyhow::{anyhow, Context, Result};
use anyhow::{Context, Result, anyhow};
use gloo_net::http::{Method, RequestBuilder};
use graphql_client::GraphQLQuery;
use lldap_auth::{login, registration, JWTClaims};
use lldap_auth::{JWTClaims, login, registration};
use serde::{de::DeserializeOwned, Serialize};
use lldap_frontend_options::Options;
use serde::{Serialize, de::DeserializeOwned};
use web_sys::RequestCredentials;
#[derive(Default)]
@@ -137,6 +138,15 @@ impl HostService {
.and_then(set_cookies_from_jwt)
}
pub async fn get_settings() -> Result<Options> {
call_server_json_with_error_message::<Options, _>(
&(base_url() + "/settings"),
GET_REQUEST,
"Could not fetch settings: ",
)
.await
}
pub async fn register_start(
request: registration::ClientRegistrationStartRequest,
) -> Result<Box<registration::ServerRegistrationStartResponse>> {
@@ -202,15 +212,4 @@ impl HostService {
)
.await
}
pub async fn probe_password_reset() -> Result<bool> {
Ok(gloo_net::http::Request::post(
&(base_url() + "/auth/reset/step1/lldap_unlikely_very_long_user_name"),
)
.header("Content-Type", "application/json")
.send()
.await?
.status()
!= http::StatusCode::NOT_FOUND)
}
}
+109
View File
@@ -0,0 +1,109 @@
pub struct AttributeDescription<'a> {
pub attribute_identifier: &'a str,
pub attribute_name: &'a str,
pub aliases: Vec<&'a str>,
}
pub mod group {
use super::AttributeDescription;
pub fn resolve_group_attribute_description(name: &str) -> Option<AttributeDescription> {
match name {
"creation_date" => Some(AttributeDescription {
attribute_identifier: name,
attribute_name: "creationdate",
aliases: vec![name, "createtimestamp", "modifytimestamp"],
}),
"display_name" => Some(AttributeDescription {
attribute_identifier: name,
attribute_name: "displayname",
aliases: vec![name, "cn", "uid", "id"],
}),
"group_id" => Some(AttributeDescription {
attribute_identifier: name,
attribute_name: "groupid",
aliases: vec![name],
}),
"uuid" => Some(AttributeDescription {
attribute_identifier: name,
attribute_name: name,
aliases: vec!["entryuuid"],
}),
_ => None,
}
}
pub fn resolve_group_attribute_description_or_default(name: &str) -> AttributeDescription {
match resolve_group_attribute_description(name) {
Some(d) => d,
None => AttributeDescription {
attribute_identifier: name,
attribute_name: name,
aliases: vec![],
},
}
}
}
pub mod user {
use super::AttributeDescription;
pub fn resolve_user_attribute_description(name: &str) -> Option<AttributeDescription> {
match name {
"avatar" => Some(AttributeDescription {
attribute_identifier: name,
attribute_name: name,
aliases: vec!["jpegphoto"],
}),
"creation_date" => Some(AttributeDescription {
attribute_identifier: name,
attribute_name: "creationdate",
aliases: vec![name, "createtimestamp", "modifytimestamp"],
}),
"display_name" => Some(AttributeDescription {
attribute_identifier: name,
attribute_name: "displayname",
aliases: vec![name, "cn"],
}),
"first_name" => Some(AttributeDescription {
attribute_identifier: name,
attribute_name: "firstname",
aliases: vec![name, "givenname"],
}),
"last_name" => Some(AttributeDescription {
attribute_identifier: name,
attribute_name: "lastname",
aliases: vec![name, "sn"],
}),
"mail" => Some(AttributeDescription {
attribute_identifier: name,
attribute_name: name,
aliases: vec!["email"],
}),
"user_id" => Some(AttributeDescription {
attribute_identifier: name,
attribute_name: "uid",
aliases: vec![name, "id"],
}),
"uuid" => Some(AttributeDescription {
attribute_identifier: name,
attribute_name: name,
aliases: vec!["entryuuid"],
}),
_ => None,
}
}
pub fn resolve_user_attribute_description_or_default(name: &str) -> AttributeDescription {
match resolve_user_attribute_description(name) {
Some(d) => d,
None => AttributeDescription {
attribute_identifier: name,
attribute_name: name,
aliases: vec![],
},
}
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
use anyhow::{anyhow, Result};
use anyhow::{Result, anyhow};
use chrono::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::HtmlDocument;
+11 -13
View File
@@ -1,4 +1,4 @@
use anyhow::{anyhow, ensure, Result};
use anyhow::{Result, anyhow, ensure};
use validator::validate_email;
use web_sys::{FormData, HtmlFormElement};
use yew::NodeRef;
@@ -16,18 +16,14 @@ pub struct GraphQlAttributeSchema {
pub is_editable: bool,
}
fn validate_attributes(
all_values: &[AttributeValue],
email_is_required: EmailIsRequired,
) -> Result<()> {
fn validate_email_attributes(all_values: &[AttributeValue]) -> 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");
}
let email_values = &maybe_email_values
.ok_or_else(|| anyhow!("Email is required"))?
.values;
ensure!(!email_values.is_empty(), "Email is required");
ensure!(email_values.len() == 1, "Multiple emails are not supported");
ensure!(validate_email(&email_values[0]), "Email is not valid");
Ok(())
}
@@ -65,6 +61,8 @@ pub fn read_all_form_attributes(
})
})
.collect::<Result<Vec<_>>>()?;
validate_attributes(&all_values, email_is_required)?;
if email_is_required.0 {
validate_email_attributes(&all_values)?;
}
Ok(all_values)
}
+1 -1
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::{UseStateHandle, use_effect_with_deps, use_state_eq};
// Enum to represent a result that is fetched asynchronously.
#[derive(Debug)]
+1
View File
@@ -1,4 +1,5 @@
pub mod api;
pub mod attributes;
pub mod common_component;
pub mod cookies;
pub mod form_utils;
+31 -55
View File
@@ -1,66 +1,42 @@
use anyhow::Result;
use std::{fmt::Display, str::FromStr};
use derive_more::Display;
use serde::{Deserialize, Serialize};
use strum::EnumString;
use validator::ValidationError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AttributeType {
#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq, Hash, EnumString, Display)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[strum(ascii_case_insensitive)]
pub(crate) enum AttributeType {
String,
Integer,
#[strum(serialize = "DATE_TIME", serialize = "DATETIME")]
DateTime,
Jpeg,
}
impl Display for AttributeType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
impl FromStr for AttributeType {
type Err = ();
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"String" => Ok(AttributeType::String),
"Integer" => Ok(AttributeType::Integer),
"DateTime" => Ok(AttributeType::DateTime),
"Jpeg" => Ok(AttributeType::Jpeg),
_ => Err(()),
}
}
}
// Macro to generate traits for converting between AttributeType and the
// graphql generated equivalents.
#[macro_export]
macro_rules! convert_attribute_type {
($source_type:ty) => {
impl From<$source_type> for $crate::infra::schema::AttributeType {
fn from(value: $source_type) -> Self {
match value {
<$source_type>::STRING => $crate::infra::schema::AttributeType::String,
<$source_type>::INTEGER => $crate::infra::schema::AttributeType::Integer,
<$source_type>::DATE_TIME => $crate::infra::schema::AttributeType::DateTime,
<$source_type>::JPEG_PHOTO => $crate::infra::schema::AttributeType::Jpeg,
_ => panic!("Unknown attribute type"),
}
}
}
impl From<$crate::infra::schema::AttributeType> for $source_type {
fn from(value: $crate::infra::schema::AttributeType) -> Self {
match value {
$crate::infra::schema::AttributeType::String => <$source_type>::STRING,
$crate::infra::schema::AttributeType::Integer => <$source_type>::INTEGER,
$crate::infra::schema::AttributeType::DateTime => <$source_type>::DATE_TIME,
$crate::infra::schema::AttributeType::Jpeg => <$source_type>::JPEG_PHOTO,
}
}
}
};
#[strum(serialize = "JPEG_PHOTO", serialize = "JPEGPHOTO")]
JpegPhoto,
}
pub fn validate_attribute_type(attribute_type: &str) -> Result<(), ValidationError> {
AttributeType::from_str(attribute_type)
AttributeType::try_from(attribute_type)
.map_err(|_| ValidationError::new("Invalid attribute type"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_deserialize_attribute_type() {
let attr_type: AttributeType = "STRING".try_into().unwrap();
assert_eq!(attr_type, AttributeType::String);
let attr_type: AttributeType = "Integer".try_into().unwrap();
assert_eq!(attr_type, AttributeType::Integer);
let attr_type: AttributeType = "DATE_TIME".try_into().unwrap();
assert_eq!(attr_type, AttributeType::DateTime);
let attr_type: AttributeType = "JpegPhoto".try_into().unwrap();
assert_eq!(attr_type, AttributeType::JpegPhoto);
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
pub mod components;
pub mod infra;
use wasm_bindgen::prelude::{wasm_bindgen, JsValue};
use wasm_bindgen::prelude::{JsValue, wasm_bindgen};
#[wasm_bindgen]
pub fn run_app() -> Result<(), JsValue> {
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "lldap_access_control"
version = "0.1.0"
description = "Access control wrappers for LLDAP"
authors.workspace = true
edition.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
tracing = "*"
async-trait = "0.1"
[dependencies.lldap_auth]
path = "../auth"
features = ["opaque_server", "opaque_client", "sea_orm"]
[dependencies.lldap_domain]
path = "../domain"
[dependencies.lldap_domain_handlers]
path = "../domain-handlers"
[dependencies.lldap_domain_model]
path = "../domain-model"
@@ -1,78 +1,25 @@
use std::collections::HashSet;
use async_trait::async_trait;
use tracing::info;
use crate::domain::{
error::Result,
handler::{
AttributeSchema, BackendHandler, CreateAttributeRequest, CreateGroupRequest,
CreateUserRequest, GroupBackendHandler, GroupListerBackendHandler, GroupRequestFilter,
ReadSchemaBackendHandler, Schema, SchemaBackendHandler, UpdateGroupRequest,
UpdateUserRequest, UserBackendHandler, UserListerBackendHandler, UserRequestFilter,
use lldap_auth::access_control::{Permission, ValidationResults};
use lldap_domain::{
public_schema::PublicSchema,
requests::{
CreateAttributeRequest, CreateGroupRequest, CreateUserRequest, UpdateGroupRequest,
UpdateUserRequest,
},
schema::PublicSchema,
schema::{AttributeSchema, Schema},
types::{
AttributeName, Group, GroupDetails, GroupId, GroupName, LdapObjectClass, User,
UserAndGroups, UserId,
},
};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Permission {
Admin,
PasswordManager,
Readonly,
Regular,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationResults {
pub user: UserId,
pub permission: Permission,
}
impl ValidationResults {
#[cfg(test)]
pub fn admin() -> Self {
Self {
user: UserId::new("admin"),
permission: Permission::Admin,
}
}
#[must_use]
pub fn is_admin(&self) -> bool {
self.permission == Permission::Admin
}
#[must_use]
pub fn can_read_all(&self) -> bool {
self.permission == Permission::Admin
|| self.permission == Permission::Readonly
|| self.permission == Permission::PasswordManager
}
#[must_use]
pub fn can_read(&self, user: &UserId) -> bool {
self.permission == Permission::Admin
|| self.permission == Permission::PasswordManager
|| self.permission == Permission::Readonly
|| &self.user == user
}
#[must_use]
pub fn can_change_password(&self, user: &UserId, user_is_admin: bool) -> bool {
self.permission == Permission::Admin
|| (self.permission == Permission::PasswordManager && !user_is_admin)
|| &self.user == user
}
#[must_use]
pub fn can_write(&self, user: &UserId) -> bool {
self.permission == Permission::Admin || &self.user == user
}
}
use lldap_domain_handlers::handler::{
BackendHandler, GroupBackendHandler, GroupListerBackendHandler, GroupRequestFilter,
ReadSchemaBackendHandler, SchemaBackendHandler, UserBackendHandler, UserListerBackendHandler,
UserRequestFilter,
};
use lldap_domain_model::error::Result;
use std::collections::HashSet;
use tracing::info;
#[async_trait]
pub trait UserReadableBackendHandler: ReadSchemaBackendHandler {
@@ -231,17 +178,24 @@ impl<Handler: BackendHandler> AccessControlledBackendHandler<Handler> {
Self { handler }
}
pub fn get_schema_only_handler(
&self,
_validation_result: &ValidationResults,
) -> Option<&impl ReadSchemaBackendHandler> {
Some(&self.handler)
}
pub fn get_admin_handler(
&self,
validation_result: &ValidationResults,
) -> Option<&impl AdminBackendHandler> {
) -> Option<&(impl AdminBackendHandler + use<Handler>)> {
validation_result.is_admin().then_some(&self.handler)
}
pub fn get_readonly_handler(
&self,
validation_result: &ValidationResults,
) -> Option<&impl ReadonlyBackendHandler> {
) -> Option<&(impl ReadonlyBackendHandler + use<Handler>)> {
validation_result.can_read_all().then_some(&self.handler)
}
@@ -249,7 +203,7 @@ impl<Handler: BackendHandler> AccessControlledBackendHandler<Handler> {
&self,
validation_result: &ValidationResults,
user_id: &UserId,
) -> Option<&impl UserWriteableBackendHandler> {
) -> Option<&(impl UserWriteableBackendHandler + use<Handler>)> {
validation_result
.can_write(user_id)
.then_some(&self.handler)
@@ -259,7 +213,7 @@ impl<Handler: BackendHandler> AccessControlledBackendHandler<Handler> {
&self,
validation_result: &ValidationResults,
user_id: &UserId,
) -> Option<&impl UserReadableBackendHandler> {
) -> Option<&(impl UserReadableBackendHandler + use<Handler>)> {
validation_result.can_read(user_id).then_some(&self.handler)
}
@@ -310,12 +264,12 @@ impl<Handler: BackendHandler> AccessControlledBackendHandler<Handler> {
pub struct UserRestrictedListerBackendHandler<'a, Handler> {
handler: &'a Handler,
pub user_filter: Option<UserId>,
user_filter: Option<UserId>,
}
#[async_trait]
impl<'a, Handler: ReadSchemaBackendHandler + Sync> ReadSchemaBackendHandler
for UserRestrictedListerBackendHandler<'a, Handler>
impl<Handler: ReadSchemaBackendHandler + Sync> ReadSchemaBackendHandler
for UserRestrictedListerBackendHandler<'_, Handler>
{
async fn get_schema(&self) -> Result<Schema> {
let mut schema = self.handler.get_schema().await?;
@@ -331,8 +285,8 @@ impl<'a, Handler: ReadSchemaBackendHandler + Sync> ReadSchemaBackendHandler
}
#[async_trait]
impl<'a, Handler: UserListerBackendHandler + Sync> UserListerBackendHandler
for UserRestrictedListerBackendHandler<'a, Handler>
impl<Handler: UserListerBackendHandler + Sync> UserListerBackendHandler
for UserRestrictedListerBackendHandler<'_, Handler>
{
async fn list_users(
&self,
@@ -354,8 +308,8 @@ impl<'a, Handler: UserListerBackendHandler + Sync> UserListerBackendHandler
}
#[async_trait]
impl<'a, Handler: GroupListerBackendHandler + Sync> GroupListerBackendHandler
for UserRestrictedListerBackendHandler<'a, Handler>
impl<Handler: GroupListerBackendHandler + Sync> GroupListerBackendHandler
for UserRestrictedListerBackendHandler<'_, Handler>
{
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>> {
let group_filter = self
@@ -376,10 +330,14 @@ impl<'a, Handler: GroupListerBackendHandler + Sync> GroupListerBackendHandler
pub trait UserAndGroupListerBackendHandler:
UserListerBackendHandler + GroupListerBackendHandler
{
fn user_filter(&self) -> &Option<UserId>;
}
#[async_trait]
impl<'a, Handler: GroupListerBackendHandler + UserListerBackendHandler + Sync>
UserAndGroupListerBackendHandler for UserRestrictedListerBackendHandler<'a, Handler>
impl<Handler: GroupListerBackendHandler + UserListerBackendHandler + Sync>
UserAndGroupListerBackendHandler for UserRestrictedListerBackendHandler<'_, Handler>
{
fn user_filter(&self) -> &Option<UserId> {
&self.user_filter
}
}
+13 -11
View File
@@ -1,12 +1,12 @@
[package]
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
description = "Authentication protocol for LLDAP"
edition = "2021"
homepage = "https://github.com/lldap/lldap"
license = "GPL-3.0-only"
name = "lldap_auth"
repository = "https://github.com/lldap/lldap"
version = "0.6.0"
description = "Authentication protocol for LLDAP"
edition.workspace = true
authors.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
[features]
default = ["opaque_server", "opaque_client"]
@@ -14,16 +14,16 @@ opaque_server = []
opaque_client = []
js = []
sea_orm = ["dep:sea-orm"]
test = []
[dependencies]
rust-argon2 = "0.8"
rust-argon2 = "2"
curve25519-dalek = "3"
digest = "0.9"
generic-array = "0.14"
rand = "0.8"
serde = "*"
sha2 = "0.9"
thiserror = "*"
thiserror = "2"
[dependencies.derive_more]
features = ["debug", "display"]
@@ -38,11 +38,13 @@ version = "*"
features = ["serde"]
[dependencies.sea-orm]
version = "0.12"
default-features = false
workspace = true
features = ["macros"]
optional = true
[dependencies.serde]
workspace = true
# For WASM targets, use the JS getrandom.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.getrandom]
version = "0.2"
+58
View File
@@ -0,0 +1,58 @@
use crate::types::UserId;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub enum Permission {
Admin,
PasswordManager,
Readonly,
Regular,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ValidationResults {
pub user: UserId,
pub permission: Permission,
}
impl ValidationResults {
#[cfg(feature = "test")]
pub fn admin() -> Self {
Self {
user: UserId::new("admin"),
permission: Permission::Admin,
}
}
#[must_use]
pub fn is_admin(&self) -> bool {
self.permission == Permission::Admin
}
#[must_use]
pub fn can_read_all(&self) -> bool {
self.permission == Permission::Admin
|| self.permission == Permission::Readonly
|| self.permission == Permission::PasswordManager
}
#[must_use]
pub fn can_read(&self, user: &UserId) -> bool {
self.permission == Permission::Admin
|| self.permission == Permission::PasswordManager
|| self.permission == Permission::Readonly
|| &self.user == user
}
#[must_use]
pub fn can_change_password(&self, user: &UserId, user_is_admin: bool) -> bool {
self.permission == Permission::Admin
|| (self.permission == Permission::PasswordManager && !user_is_admin)
|| &self.user == user
}
#[must_use]
pub fn can_write(&self, user: &UserId) -> bool {
self.permission == Permission::Admin || &self.user == user
}
}
@@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fmt;
pub mod access_control;
pub mod opaque;
/// The messages for the 3-step OPAQUE and simple login process.
@@ -32,7 +32,6 @@ impl ArgonHasher {
lanes: 1,
mem_cost: 50 * 1024, // 50 MB, in KB
secret: &[],
thread_mode: argon2::ThreadMode::Sequential,
time_cost: 1,
variant: argon2::Variant::Argon2id,
version: argon2::Version::Version13,
@@ -133,6 +132,12 @@ pub mod server {
pub use super::*;
pub type ServerRegistration = opaque_ke::ServerRegistration<DefaultSuite>;
pub type ServerSetup = opaque_ke::ServerSetup<DefaultSuite>;
pub fn generate_random_private_key() -> ServerSetup {
let mut rng = rand::rngs::OsRng;
ServerSetup::new(&mut rng)
}
/// Methods to register a new user, from the server side.
pub mod registration {
pub use super::*;
+46
View File
@@ -0,0 +1,46 @@
[package]
name = "lldap_domain_handlers"
version = "0.1.0"
edition.workspace = true
authors.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
[features]
test = []
[dependencies]
async-trait = "0.1"
base64 = "0.21"
ldap3_proto = "0.6.0"
serde_bytes = "0.11"
[dev-dependencies]
pretty_assertions = "1"
[dependencies.chrono]
features = ["serde"]
version = "0.4"
[dependencies.derive_more]
features = ["debug", "display", "from", "from_str"]
default-features = false
version = "1"
[dependencies.lldap_auth]
path = "../auth"
features = ["opaque_server", "opaque_client", "sea_orm"]
[dependencies.lldap_domain]
path = "../domain"
[dependencies.lldap_domain_model]
path = "../domain-model"
[dependencies.serde]
workspace = true
[dependencies.uuid]
features = ["v1", "v3"]
version = "1"
@@ -1,12 +1,17 @@
use crate::domain::{
error::Result,
use async_trait::async_trait;
use ldap3_proto::proto::LdapSubstringFilter;
use lldap_domain::{
requests::{
CreateAttributeRequest, CreateGroupRequest, CreateUserRequest, UpdateGroupRequest,
UpdateUserRequest,
},
schema::Schema,
types::{
AttributeName, AttributeType, AttributeValue, Email, Group, GroupDetails, GroupId,
GroupName, JpegPhoto, LdapObjectClass, Serialized, User, UserAndGroups, UserColumn, UserId,
Uuid,
AttributeName, AttributeValue, Group, GroupDetails, GroupId, GroupName, LdapObjectClass,
User, UserAndGroups, UserId, Uuid,
},
};
use async_trait::async_trait;
use lldap_domain_model::{error::Result, model::UserColumn};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
@@ -47,15 +52,33 @@ impl SubStringFilter {
}
}
impl From<LdapSubstringFilter> for SubStringFilter {
fn from(
LdapSubstringFilter {
initial,
any,
final_,
}: LdapSubstringFilter,
) -> Self {
Self {
initial,
any,
final_,
}
}
}
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
pub enum UserRequestFilter {
True,
False,
And(Vec<UserRequestFilter>),
Or(Vec<UserRequestFilter>),
Not(Box<UserRequestFilter>),
UserId(UserId),
UserIdSubString(SubStringFilter),
Equality(UserColumn, String),
AttributeEquality(AttributeName, Serialized),
AttributeEquality(AttributeName, AttributeValue),
SubString(UserColumn, SubStringFilter),
// Check if a user belongs to a group identified by name.
MemberOf(GroupName),
@@ -66,16 +89,14 @@ pub enum UserRequestFilter {
impl From<bool> for UserRequestFilter {
fn from(val: bool) -> Self {
if val {
Self::And(vec![])
} else {
Self::Not(Box::new(Self::And(vec![])))
}
if val { Self::True } else { Self::False }
}
}
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
pub enum GroupRequestFilter {
True,
False,
And(Vec<GroupRequestFilter>),
Or(Vec<GroupRequestFilter>),
Not(Box<GroupRequestFilter>),
@@ -85,104 +106,16 @@ pub enum GroupRequestFilter {
GroupId(GroupId),
// Check if the group contains a user identified by uid.
Member(UserId),
AttributeEquality(AttributeName, Serialized),
AttributeEquality(AttributeName, AttributeValue),
CustomAttributePresent(AttributeName),
}
impl From<bool> for GroupRequestFilter {
fn from(val: bool) -> Self {
if val {
Self::And(vec![])
} else {
Self::Not(Box::new(Self::And(vec![])))
}
if val { Self::True } else { Self::False }
}
}
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Default)]
pub struct CreateUserRequest {
// Same fields as User, but no creation_date, and with password.
pub user_id: UserId,
pub email: Email,
pub display_name: Option<String>,
pub first_name: Option<String>,
pub last_name: Option<String>,
pub avatar: Option<JpegPhoto>,
pub attributes: Vec<AttributeValue>,
}
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Default)]
pub struct UpdateUserRequest {
// Same fields as CreateUserRequest, but no with an extra layer of Option.
pub user_id: UserId,
pub email: Option<Email>,
pub display_name: Option<String>,
pub first_name: Option<String>,
pub last_name: Option<String>,
pub avatar: Option<JpegPhoto>,
pub delete_attributes: Vec<AttributeName>,
pub insert_attributes: Vec<AttributeValue>,
}
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Default)]
pub struct CreateGroupRequest {
pub display_name: GroupName,
pub attributes: Vec<AttributeValue>,
}
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
pub struct UpdateGroupRequest {
pub group_id: GroupId,
pub display_name: Option<GroupName>,
pub delete_attributes: Vec<AttributeName>,
pub insert_attributes: Vec<AttributeValue>,
}
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
pub struct AttributeSchema {
pub name: AttributeName,
//TODO: pub aliases: Vec<String>,
pub attribute_type: AttributeType,
pub is_list: bool,
pub is_visible: bool,
pub is_editable: bool,
pub is_hardcoded: bool,
pub is_readonly: bool,
}
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
pub struct CreateAttributeRequest {
pub name: AttributeName,
pub attribute_type: AttributeType,
pub is_list: bool,
pub is_visible: bool,
pub is_editable: bool,
}
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
pub struct AttributeList {
pub attributes: Vec<AttributeSchema>,
}
impl AttributeList {
pub fn get_attribute_schema(&self, name: &AttributeName) -> Option<&AttributeSchema> {
self.attributes.iter().find(|a| a.name == *name)
}
pub fn get_attribute_type(&self, name: &AttributeName) -> Option<(AttributeType, bool)> {
self.get_attribute_schema(name)
.map(|a| (a.attribute_type, a.is_list))
}
}
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
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]
pub trait LoginHandler: Send + Sync {
async fn bind(&self, request: BindRequest) -> Result<()>;
@@ -257,6 +190,7 @@ pub trait BackendHandler:
mod tests {
use super::*;
use base64::Engine;
use lldap_domain::types::JpegPhoto;
use pretty_assertions::assert_ne;
#[test]
+1
View File
@@ -0,0 +1 @@
pub mod handler;
+48
View File
@@ -0,0 +1,48 @@
[package]
name = "lldap_domain_model"
version = "0.1.0"
edition.workspace = true
authors.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
[features]
test = []
[dependencies]
base64 = "0.21"
bincode = "1.3"
orion = "0.17"
serde_bytes = "0.11"
thiserror = "2"
[dev-dependencies]
pretty_assertions = "1"
[dependencies.chrono]
features = ["serde"]
version = "0.4"
[dependencies.derive_more]
features = ["debug", "display", "from", "from_str"]
default-features = false
version = "1"
[dependencies.lldap_auth]
path = "../auth"
features = ["opaque_server", "opaque_client", "sea_orm"]
[dependencies.lldap_domain]
path = "../domain"
[dependencies.sea-orm]
workspace = true
features = ["macros"]
[dependencies.serde]
workspace = true
[dependencies.uuid]
features = ["v1", "v3"]
version = "1"
+2
View File
@@ -0,0 +1,2 @@
pub mod error;
pub mod model;
@@ -0,0 +1,57 @@
use crate::error::DomainError;
use lldap_domain::{
schema::AttributeList,
types::{Attribute, AttributeName, AttributeType, AttributeValue, Cardinality, Serialized},
};
// Value must be a serialized attribute value of the type denoted by typ,
// and either a singleton or unbounded list, depending on is_list.
pub fn deserialize_attribute_value(
value: &Serialized,
typ: AttributeType,
is_list: bool,
) -> AttributeValue {
match (typ, is_list) {
(AttributeType::String, false) => {
AttributeValue::String(Cardinality::Singleton(value.unwrap()))
}
(AttributeType::String, true) => {
AttributeValue::String(Cardinality::Unbounded(value.unwrap()))
}
(AttributeType::Integer, false) => {
AttributeValue::Integer(Cardinality::Singleton(value.unwrap::<i64>()))
}
(AttributeType::Integer, true) => {
AttributeValue::Integer(Cardinality::Unbounded(value.unwrap()))
}
(AttributeType::DateTime, false) => {
AttributeValue::DateTime(Cardinality::Singleton(value.unwrap()))
}
(AttributeType::DateTime, true) => {
AttributeValue::DateTime(Cardinality::Unbounded(value.unwrap()))
}
(AttributeType::JpegPhoto, false) => {
AttributeValue::JpegPhoto(Cardinality::Singleton(value.unwrap()))
}
(AttributeType::JpegPhoto, true) => {
AttributeValue::JpegPhoto(Cardinality::Unbounded(value.unwrap()))
}
}
}
pub fn deserialize_attribute(
name: AttributeName,
value: &Serialized,
schema: &AttributeList,
) -> Result<Attribute, DomainError> {
match schema.get_attribute_type(&name) {
Some((typ, is_list)) => Ok(Attribute {
name,
value: deserialize_attribute_value(value, typ, is_list),
}),
None => Err(DomainError::InternalError(format!(
"Unable to find schema for attribute named '{}'",
name.into_string()
))),
}
}
@@ -1,8 +1,8 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use crate::domain::{
handler::AttributeSchema,
use lldap_domain::{
schema::AttributeSchema,
types::{AttributeName, AttributeType},
};
@@ -1,7 +1,7 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use crate::domain::types::{AttributeName, AttributeValue, GroupId, Serialized};
use lldap_domain::types::{AttributeName, GroupId, Serialized};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "group_attributes")]
@@ -55,18 +55,3 @@ impl Related<super::GroupAttributeSchema> for Entity {
}
impl ActiveModelBehavior for ActiveModel {}
impl From<Model> for AttributeValue {
fn from(
Model {
group_id: _,
attribute_name,
value,
}: Model,
) -> Self {
Self {
name: attribute_name,
value,
}
}
}
@@ -1,7 +1,7 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use crate::domain::types::LdapObjectClass;
use lldap_domain::types::LdapObjectClass;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "group_object_classes")]
@@ -3,7 +3,7 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use crate::domain::types::{GroupId, GroupName, Uuid};
use lldap_domain::types::{GroupId, GroupName, Uuid};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "groups")]
@@ -30,7 +30,7 @@ impl Related<super::memberships::Entity> for Entity {
impl ActiveModelBehavior for ActiveModel {}
impl From<Model> for crate::domain::types::Group {
impl From<Model> for lldap_domain::types::Group {
fn from(group: Model) -> Self {
Self {
id: group.group_id,
@@ -43,7 +43,7 @@ impl From<Model> for crate::domain::types::Group {
}
}
impl From<Model> for crate::domain::types::GroupDetails {
impl From<Model> for lldap_domain::types::GroupDetails {
fn from(group: Model) -> Self {
Self {
group_id: group.group_id,
@@ -3,7 +3,7 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use crate::domain::types::UserId;
use lldap_domain::types::UserId;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "jwt_refresh_storage")]
@@ -3,7 +3,7 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use crate::domain::types::UserId;
use lldap_domain::types::UserId;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "jwt_storage")]
@@ -3,7 +3,7 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use crate::domain::types::{GroupId, UserId};
use lldap_domain::types::{GroupId, UserId};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "memberships")]
@@ -1,5 +1,6 @@
pub mod prelude;
pub mod deserialize;
pub mod groups;
pub mod jwt_refresh_storage;
pub mod jwt_storage;
@@ -3,7 +3,7 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use crate::domain::types::UserId;
use lldap_domain::types::UserId;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "password_reset_tokens")]
@@ -1,8 +1,8 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use crate::domain::{
handler::AttributeSchema,
use lldap_domain::{
schema::AttributeSchema,
types::{AttributeName, AttributeType},
};
@@ -1,7 +1,7 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use crate::domain::types::{AttributeName, AttributeValue, Serialized, UserId};
use lldap_domain::types::{AttributeName, Serialized, UserId};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "user_attributes")]
@@ -55,18 +55,3 @@ impl Related<super::UserAttributeSchema> for Entity {
}
impl ActiveModelBehavior for ActiveModel {}
impl From<Model> for AttributeValue {
fn from(
Model {
user_id: _,
attribute_name,
value,
}: Model,
) -> Self {
Self {
name: attribute_name,
value,
}
}
}
@@ -1,7 +1,7 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use crate::domain::types::LdapObjectClass;
use lldap_domain::types::LdapObjectClass;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "user_object_classes")]
@@ -1,9 +1,9 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.10.3
use sea_orm::{entity::prelude::*, sea_query::BlobSize};
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use crate::domain::types::{Email, UserId, Uuid};
use lldap_domain::types::{Email, UserId, Uuid};
#[derive(Copy, Clone, Default, Debug, DeriveEntity)]
pub struct Entity;
@@ -47,15 +47,15 @@ impl ColumnTrait for Column {
fn def(&self) -> ColumnDef {
match self {
Column::UserId => ColumnType::String(Some(255)),
Column::Email => ColumnType::String(Some(255)),
Column::LowercaseEmail => ColumnType::String(Some(255)),
Column::DisplayName => ColumnType::String(Some(255)),
Column::UserId => ColumnType::String(StringLen::N(255)),
Column::Email => ColumnType::String(StringLen::N(255)),
Column::LowercaseEmail => ColumnType::String(StringLen::N(255)),
Column::DisplayName => ColumnType::String(StringLen::N(255)),
Column::CreationDate => ColumnType::DateTime,
Column::PasswordHash => ColumnType::Binary(BlobSize::Medium),
Column::TotpSecret => ColumnType::String(Some(64)),
Column::MfaType => ColumnType::String(Some(64)),
Column::Uuid => ColumnType::String(Some(36)),
Column::PasswordHash => ColumnType::Blob,
Column::TotpSecret => ColumnType::String(StringLen::N(64)),
Column::MfaType => ColumnType::String(StringLen::N(64)),
Column::Uuid => ColumnType::String(StringLen::N(36)),
}
.def()
}
@@ -112,7 +112,7 @@ impl Related<super::password_reset_tokens::Entity> for Entity {
impl ActiveModelBehavior for ActiveModel {}
impl From<Model> for crate::domain::types::User {
impl From<Model> for lldap_domain::types::User {
fn from(user: Model) -> Self {
Self {
user_id: user.user_id,
+64
View File
@@ -0,0 +1,64 @@
[package]
name = "lldap_domain"
version = "0.1.0"
authors = [
"Valentin Tolmer <valentin@tolmer.fr>",
"Simon Broeng Jensen <sbj@cwconsult.dk>",
]
edition.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
[features]
test = []
[dependencies]
anyhow = "*"
base64 = "0.21"
bincode = "1.3"
itertools = "0.10"
juniper = "0.15"
serde_bytes = "0.11"
[dev-dependencies]
pretty_assertions = "1"
[dependencies.chrono]
features = ["serde"]
version = "*"
[dependencies.derive_more]
features = ["debug", "display", "from", "from_str"]
default-features = false
version = "1"
[dependencies.image]
features = ["jpeg"]
default-features = false
version = "0.24"
[dependencies.lldap_auth]
path = "../auth"
features = ["opaque_server", "opaque_client", "sea_orm"]
[dependencies.sea-orm]
workspace = true
features = [
"macros",
"with-chrono",
"with-uuid",
"sqlx-all",
"runtime-actix-rustls",
]
[dependencies.serde]
workspace = true
[dependencies.strum]
features = ["derive"]
version = "0.25"
[dependencies.uuid]
features = ["v1", "v3"]
version = "1"
+41
View File
@@ -0,0 +1,41 @@
use crate::types::{AttributeType, AttributeValue, JpegPhoto};
use anyhow::{Context as AnyhowContext, Result, bail};
pub fn deserialize_attribute_value(
value: &[String],
typ: AttributeType,
is_list: bool,
) -> Result<AttributeValue> {
if !is_list && value.len() != 1 {
bail!("Attribute is not a list, but multiple values were provided",);
}
let parse_int = |value: &String| -> Result<i64> {
value
.parse::<i64>()
.with_context(|| format!("Invalid integer value {value}"))
};
let parse_date = |value: &String| -> Result<chrono::NaiveDateTime> {
Ok(chrono::DateTime::parse_from_rfc3339(value)
.with_context(|| format!("Invalid date value {value}"))?
.naive_utc())
};
let parse_photo = |value: &String| -> Result<JpegPhoto> {
JpegPhoto::try_from(value.as_str()).context("Provided image is not a valid JPEG")
};
Ok(match (typ, is_list) {
(AttributeType::String, false) => value[0].clone().into(),
(AttributeType::String, true) => value.to_vec().into(),
(AttributeType::Integer, false) => (parse_int(&value[0])?).into(),
(AttributeType::Integer, true) => {
(value.iter().map(parse_int).collect::<Result<Vec<_>>>()?).into()
}
(AttributeType::DateTime, false) => (parse_date(&value[0])?).into(),
(AttributeType::DateTime, true) => {
(value.iter().map(parse_date).collect::<Result<Vec<_>>>()?).into()
}
(AttributeType::JpegPhoto, false) => (parse_photo(&value[0])?).into(),
(AttributeType::JpegPhoto, true) => {
(value.iter().map(parse_photo).collect::<Result<Vec<_>>>()?).into()
}
})
}
+5
View File
@@ -0,0 +1,5 @@
pub mod deserialize;
pub mod public_schema;
pub mod requests;
pub mod schema;
pub mod types;
@@ -1,5 +1,5 @@
use crate::domain::{
handler::{AttributeList, AttributeSchema, Schema},
use crate::{
schema::{AttributeSchema, Schema},
types::AttributeType,
};
use serde::{Deserialize, Serialize};
@@ -13,26 +13,6 @@ impl PublicSchema {
}
}
pub trait SchemaAttributeExtractor: std::marker::Send {
fn get_attributes(schema: &PublicSchema) -> &AttributeList;
}
pub struct SchemaUserAttributeExtractor;
impl SchemaAttributeExtractor for SchemaUserAttributeExtractor {
fn get_attributes(schema: &PublicSchema) -> &AttributeList {
&schema.get_schema().user_attributes
}
}
pub struct SchemaGroupAttributeExtractor;
impl SchemaAttributeExtractor for SchemaGroupAttributeExtractor {
fn get_attributes(schema: &PublicSchema) -> &AttributeList {
&schema.get_schema().group_attributes
}
}
impl From<Schema> for PublicSchema {
fn from(mut schema: Schema) -> Self {
schema.user_attributes.attributes.extend_from_slice(&[
+45
View File
@@ -0,0 +1,45 @@
use serde::{Deserialize, Serialize};
use crate::types::{Attribute, AttributeName, AttributeType, Email, GroupId, GroupName, UserId};
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Default)]
pub struct CreateUserRequest {
// Same fields as User, but no creation_date, and with password.
pub user_id: UserId,
pub email: Email,
pub display_name: Option<String>,
pub attributes: Vec<Attribute>,
}
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Default)]
pub struct UpdateUserRequest {
// Same fields as CreateUserRequest, but no with an extra layer of Option.
pub user_id: UserId,
pub email: Option<Email>,
pub display_name: Option<String>,
pub delete_attributes: Vec<AttributeName>,
pub insert_attributes: Vec<Attribute>,
}
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Default)]
pub struct CreateGroupRequest {
pub display_name: GroupName,
pub attributes: Vec<Attribute>,
}
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
pub struct UpdateGroupRequest {
pub group_id: GroupId,
pub display_name: Option<GroupName>,
pub delete_attributes: Vec<AttributeName>,
pub insert_attributes: Vec<Attribute>,
}
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
pub struct CreateAttributeRequest {
pub name: AttributeName,
pub attribute_type: AttributeType,
pub is_list: bool,
pub is_visible: bool,
pub is_editable: bool,
}
+47
View File
@@ -0,0 +1,47 @@
use serde::{Deserialize, Serialize};
use crate::types::{AttributeName, AttributeType, LdapObjectClass};
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
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>,
}
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
pub struct AttributeSchema {
pub name: AttributeName,
//TODO: pub aliases: Vec<String>,
pub attribute_type: AttributeType,
pub is_list: bool,
pub is_visible: bool,
pub is_editable: bool,
pub is_hardcoded: bool,
pub is_readonly: bool,
}
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
pub struct AttributeList {
pub attributes: Vec<AttributeSchema>,
}
impl AttributeList {
pub fn get_attribute_schema(&self, name: &AttributeName) -> Option<&AttributeSchema> {
self.attributes.iter().find(|a| a.name == *name)
}
pub fn get_attribute_type(&self, name: &AttributeName) -> Option<(AttributeType, bool)> {
self.get_attribute_schema(name)
.map(|a| (a.attribute_type, a.is_list))
}
pub fn format_for_ldap_schema_description(&self) -> String {
self.attributes
.iter()
.map(|a| a.name.as_str())
.collect::<Vec<_>>()
.join(" $ ")
}
}
@@ -4,14 +4,16 @@ use base64::Engine;
use chrono::{NaiveDateTime, TimeZone};
use lldap_auth::types::CaseInsensitiveString;
use sea_orm::{
entity::IntoActiveValue,
sea_query::{value::ValueType, ArrayType, BlobSize, ColumnType, Nullable, ValueTypeErr},
DbErr, DeriveValueType, QueryResult, TryFromU64, TryGetError, TryGetable, Value,
entity::IntoActiveValue,
sea_query::{
ArrayType, ColumnType, Nullable, SeaRc, StringLen, ValueTypeErr,
extension::mysql::MySqlType, value::ValueType,
},
};
use serde::{Deserialize, Serialize};
use strum::{EnumString, IntoStaticStr};
pub use super::model::UserColumn;
pub use lldap_auth::types::UserId;
#[derive(
@@ -27,7 +29,7 @@ pub use lldap_auth::types::UserId;
derive_more::Display,
)]
#[serde(try_from = "&str")]
#[sea_orm(column_type = "String(Some(36))")]
#[sea_orm(column_type = "String(StringLen::N(36))")]
#[debug(r#""{_0}""#)]
#[display("{_0}")]
pub struct Uuid(String);
@@ -66,16 +68,19 @@ impl<'a> std::convert::TryFrom<&'a str> for Uuid {
}
}
#[cfg(test)]
#[cfg(feature = "test")]
#[macro_export]
macro_rules! uuid {
($s:literal) => {
<$crate::domain::types::Uuid as std::convert::TryFrom<_>>::try_from($s).unwrap()
<lldap_domain::types::Uuid as std::convert::TryFrom<_>>::try_from($s).unwrap()
};
}
#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize, DeriveValueType)]
#[sea_orm(column_type = "Binary(BlobSize::Long)", array_type = "Bytes")]
#[sea_orm(
column_type = "Custom(SeaRc::new(MySqlType::LongBlob))",
array_type = "Bytes"
)]
pub struct Serialized(Vec<u8>);
const SERIALIZED_I64_LEN: usize = 8;
@@ -131,6 +136,21 @@ impl Serialized {
}
}
impl From<AttributeValue> for Serialized {
fn from(val: AttributeValue) -> Serialized {
match &val {
AttributeValue::String(Cardinality::Singleton(s)) => Serialized::from(s),
AttributeValue::String(Cardinality::Unbounded(l)) => Serialized::from(l),
AttributeValue::Integer(Cardinality::Singleton(i)) => Serialized::from(i),
AttributeValue::Integer(Cardinality::Unbounded(l)) => Serialized::from(l),
AttributeValue::JpegPhoto(Cardinality::Singleton(p)) => Serialized::from(p),
AttributeValue::JpegPhoto(Cardinality::Unbounded(l)) => Serialized::from(l),
AttributeValue::DateTime(Cardinality::Singleton(dt)) => Serialized::from(dt),
AttributeValue::DateTime(Cardinality::Unbounded(l)) => Serialized::from(l),
}
}
}
fn compare_str_case_insensitive(s1: &str, s2: &str) -> Ordering {
let mut it_1 = s1.chars().flat_map(|c| c.to_lowercase());
let mut it_2 = s2.chars().flat_map(|c| c.to_lowercase());
@@ -293,8 +313,11 @@ impl AsRef<GroupName> for GroupName {
}
}
#[derive(PartialEq, Eq, Clone, Serialize, Deserialize, DeriveValueType)]
#[sea_orm(column_type = "Binary(BlobSize::Long)", array_type = "Bytes")]
#[derive(PartialEq, Eq, Clone, Serialize, Deserialize, DeriveValueType, Hash)]
#[sea_orm(
column_type = "Custom(SeaRc::new(MySqlType::LongBlob))",
array_type = "Bytes"
)]
pub struct JpegPhoto(#[serde(with = "serde_bytes")] Vec<u8>);
impl From<&JpegPhoto> for Value {
@@ -354,7 +377,7 @@ impl std::fmt::Debug for JpegPhoto {
encoded.push_str(" ...");
};
f.debug_tuple("JpegPhoto")
.field(&format!("b64[{}]", encoded))
.field(&format!("b64[{encoded}]"))
.finish()
}
}
@@ -372,7 +395,7 @@ impl JpegPhoto {
self.0
}
#[cfg(test)]
#[cfg(any(feature = "test", test))]
pub fn for_tests() -> Self {
use image::{ImageOutputFormat, Rgb, RgbImage};
let img = RgbImage::from_fn(32, 32, |x, y| {
@@ -408,10 +431,111 @@ impl IntoActiveValue<Serialized> for JpegPhoto {
}
}
// Represents values that can be either a singleton or a list of a specific type
// Used by AttributeValue to model attributes with types that might be a list.
#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, Hash)]
pub struct AttributeValue {
pub enum Cardinality<T: Clone> {
Singleton(T),
Unbounded(Vec<T>),
}
impl<T: Clone> Cardinality<T> {
pub fn into_vec(self) -> Vec<T> {
match self {
Self::Singleton(v) => vec![v],
Self::Unbounded(l) => l,
}
}
}
#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, Hash)]
pub enum AttributeValue {
String(Cardinality<String>),
Integer(Cardinality<i64>),
JpegPhoto(Cardinality<JpegPhoto>),
DateTime(Cardinality<NaiveDateTime>),
}
impl AttributeValue {
pub fn get_attribute_type(&self) -> AttributeType {
match self {
Self::String(_) => AttributeType::String,
Self::Integer(_) => AttributeType::Integer,
Self::JpegPhoto(_) => AttributeType::JpegPhoto,
Self::DateTime(_) => AttributeType::DateTime,
}
}
pub fn as_str(&self) -> Option<&str> {
if let AttributeValue::String(Cardinality::Singleton(s)) = self {
Some(s.as_str())
} else {
None
}
}
pub fn into_string(self) -> Option<String> {
if let AttributeValue::String(Cardinality::Singleton(s)) = self {
Some(s)
} else {
None
}
}
pub fn as_jpeg_photo(&self) -> Option<&JpegPhoto> {
if let AttributeValue::JpegPhoto(Cardinality::Singleton(p)) = self {
Some(p)
} else {
None
}
}
}
impl From<String> for AttributeValue {
fn from(s: String) -> Self {
AttributeValue::String(Cardinality::Singleton(s))
}
}
impl From<Vec<String>> for AttributeValue {
fn from(l: Vec<String>) -> Self {
AttributeValue::String(Cardinality::Unbounded(l))
}
}
impl From<i64> for AttributeValue {
fn from(i: i64) -> Self {
AttributeValue::Integer(Cardinality::Singleton(i))
}
}
impl From<Vec<i64>> for AttributeValue {
fn from(l: Vec<i64>) -> Self {
AttributeValue::Integer(Cardinality::Unbounded(l))
}
}
impl From<JpegPhoto> for AttributeValue {
fn from(j: JpegPhoto) -> Self {
AttributeValue::JpegPhoto(Cardinality::Singleton(j))
}
}
impl From<Vec<JpegPhoto>> for AttributeValue {
fn from(l: Vec<JpegPhoto>) -> Self {
AttributeValue::JpegPhoto(Cardinality::Unbounded(l))
}
}
impl From<NaiveDateTime> for AttributeValue {
fn from(dt: NaiveDateTime) -> Self {
AttributeValue::DateTime(Cardinality::Singleton(dt))
}
}
impl From<Vec<NaiveDateTime>> for AttributeValue {
fn from(l: Vec<NaiveDateTime>) -> Self {
AttributeValue::DateTime(Cardinality::Unbounded(l))
}
}
#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, Hash)]
pub struct Attribute {
pub name: AttributeName,
pub value: Serialized,
pub value: AttributeValue,
}
#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)]
@@ -421,10 +545,10 @@ pub struct User {
pub display_name: Option<String>,
pub creation_date: NaiveDateTime,
pub uuid: Uuid,
pub attributes: Vec<AttributeValue>,
pub attributes: Vec<Attribute>,
}
#[cfg(test)]
#[cfg(feature = "test")]
impl Default for User {
fn default() -> Self {
let epoch = chrono::Utc.timestamp_opt(0, 0).unwrap().naive_utc();
@@ -518,7 +642,7 @@ impl ValueType for AttributeType {
}
fn column_type() -> ColumnType {
ColumnType::String(Some(64))
ColumnType::String(StringLen::N(64))
}
}
@@ -529,7 +653,7 @@ pub struct Group {
pub creation_date: NaiveDateTime,
pub uuid: Uuid,
pub users: Vec<UserId>,
pub attributes: Vec<AttributeValue>,
pub attributes: Vec<Attribute>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -538,7 +662,7 @@ pub struct GroupDetails {
pub display_name: GroupName,
pub creation_date: NaiveDateTime,
pub uuid: Uuid,
pub attributes: Vec<AttributeValue>,
pub attributes: Vec<Attribute>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "lldap_frontend_options"
version = "0.1.0"
description = "Frontend options for LLDAP"
authors.workspace = true
edition.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
[dependencies.serde]
workspace = true
+6
View File
@@ -0,0 +1,6 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct Options {
pub password_reset_enabled: bool,
}
+75
View File
@@ -0,0 +1,75 @@
[package]
name = "lldap_graphql_server"
version = "0.1.0"
description = "GraphQL server for LLDAP"
edition.workspace = true
authors.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
anyhow = "*"
juniper = "0.15"
serde_json = "1"
tracing = "*"
urlencoding = "2"
[dependencies.chrono]
features = ["serde"]
version = "*"
[dependencies.lldap_access_control]
path = "../access-control"
[dependencies.lldap_auth]
path = "../auth"
features = ["opaque_server", "opaque_client", "sea_orm"]
[dependencies.lldap_domain]
path = "../domain"
[dependencies.lldap_domain_model]
path = "../domain-model"
[dependencies.lldap_domain_handlers]
path = "../domain-handlers"
[dependencies.lldap_ldap]
path = "../ldap"
[dependencies.lldap_sql_backend_handler]
path = "../sql-backend-handler"
[dependencies.lldap_validation]
path = "../validation"
[dependencies.serde]
workspace = true
[dependencies.uuid]
features = ["v1", "v3"]
version = "1"
[dev-dependencies]
mockall = "0.11.4"
pretty_assertions = "1"
#[dev-dependencies.lldap_auth]
#path = "../auth"
#features = ["test"]
#
#[dev-dependencies.lldap_opaque_handler]
#path = "../opaque-handler"
#features = ["test"]
[dev-dependencies.lldap_test_utils]
path = "../test-utils"
#
#[dev-dependencies.lldap_sql_backend_handler]
#path = "../sql-backend-handler"
#features = ["test"]
[dev-dependencies.tokio]
features = ["full"]
version = "1.25"
+91
View File
@@ -0,0 +1,91 @@
use crate::{mutation::Mutation, query::Query};
use juniper::{EmptySubscription, FieldError, RootNode};
use lldap_access_control::{
AccessControlledBackendHandler, AdminBackendHandler, ReadonlyBackendHandler,
UserReadableBackendHandler, UserWriteableBackendHandler,
};
use lldap_auth::{access_control::ValidationResults, types::UserId};
use lldap_domain_handlers::handler::BackendHandler;
use tracing::debug;
pub struct Context<Handler: BackendHandler> {
pub handler: AccessControlledBackendHandler<Handler>,
pub validation_result: ValidationResults,
}
pub fn field_error_callback<'a>(
span: &'a tracing::Span,
error_message: &'a str,
) -> impl 'a + FnOnce() -> FieldError {
move || {
span.in_scope(|| debug!("Unauthorized"));
FieldError::from(error_message)
}
}
impl<Handler: BackendHandler> Context<Handler> {
#[cfg(test)]
pub fn new_for_tests(handler: Handler, validation_result: ValidationResults) -> Self {
Self {
handler: AccessControlledBackendHandler::new(handler),
validation_result,
}
}
pub fn get_admin_handler(&self) -> Option<&(impl AdminBackendHandler + use<Handler>)> {
self.handler.get_admin_handler(&self.validation_result)
}
pub fn get_readonly_handler(&self) -> Option<&(impl ReadonlyBackendHandler + use<Handler>)> {
self.handler.get_readonly_handler(&self.validation_result)
}
pub fn get_writeable_handler(
&self,
user_id: &UserId,
) -> Option<&(impl UserWriteableBackendHandler + use<Handler>)> {
self.handler
.get_writeable_handler(&self.validation_result, user_id)
}
pub fn get_readable_handler(
&self,
user_id: &UserId,
) -> Option<&(impl UserReadableBackendHandler + use<Handler>)> {
self.handler
.get_readable_handler(&self.validation_result, user_id)
}
}
impl<Handler: BackendHandler> juniper::Context for Context<Handler> {}
type Schema<Handler> =
RootNode<'static, Query<Handler>, Mutation<Handler>, EmptySubscription<Context<Handler>>>;
pub fn schema<Handler: BackendHandler>() -> Schema<Handler> {
Schema::new(
Query::<Handler>::new(),
Mutation::<Handler>::new(),
EmptySubscription::<Context<Handler>>::new(),
)
}
pub fn export_schema(output_file: Option<String>) -> anyhow::Result<()> {
use anyhow::Context;
use lldap_sql_backend_handler::SqlBackendHandler;
let output = schema::<SqlBackendHandler>().as_schema_language();
match output_file {
None => println!("{output}"),
Some(path) => {
use std::fs::File;
use std::io::prelude::*;
use std::path::Path;
let path = Path::new(&path);
let mut file =
File::create(path).context(format!("unable to open '{}'", path.display()))?;
file.write_all(output.as_bytes())
.context(format!("unable to write in '{}'", path.display()))?;
}
}
Ok(())
}
@@ -1,30 +1,27 @@
use std::sync::Arc;
use crate::{
domain::{
deserialize::deserialize_attribute_value,
handler::{
AttributeList, BackendHandler, CreateAttributeRequest, CreateGroupRequest,
CreateUserRequest, UpdateGroupRequest, UpdateUserRequest,
},
schema::PublicSchema,
types::{
AttributeName, AttributeType, AttributeValue as DomainAttributeValue, Email, GroupId,
JpegPhoto, LdapObjectClass, UserId,
},
use crate::api::{Context, field_error_callback};
use anyhow::{Context as AnyhowContext, anyhow};
use juniper::{FieldError, FieldResult, GraphQLInputObject, GraphQLObject, graphql_object};
use lldap_access_control::{
AdminBackendHandler, ReadonlyBackendHandler, UserReadableBackendHandler,
UserWriteableBackendHandler,
};
use lldap_domain::{
deserialize::deserialize_attribute_value,
public_schema::PublicSchema,
requests::{
CreateAttributeRequest, CreateGroupRequest, CreateUserRequest, UpdateGroupRequest,
UpdateUserRequest,
},
infra::{
access_control::{
AdminBackendHandler, ReadonlyBackendHandler, UserReadableBackendHandler,
UserWriteableBackendHandler,
},
graphql::api::{field_error_callback, Context},
schema::AttributeList,
types::{
Attribute as DomainAttribute, AttributeName, AttributeType, Email, GroupId,
LdapObjectClass, UserId,
},
};
use anyhow::{anyhow, Context as AnyhowContext};
use base64::Engine;
use juniper::{graphql_object, FieldResult, GraphQLInputObject, GraphQLObject};
use tracing::{debug, debug_span, Instrument, Span};
use lldap_domain_handlers::handler::BackendHandler;
use lldap_validation::attributes::{ALLOWED_CHARACTERS_DESCRIPTION, validate_attribute_name};
use std::{collections::BTreeMap, sync::Arc};
use tracing::{Instrument, Span, debug, debug_span};
#[derive(PartialEq, Eq, Debug)]
/// The top-level GraphQL mutation type.
@@ -32,6 +29,12 @@ pub struct Mutation<Handler: BackendHandler> {
_phantom: std::marker::PhantomData<Box<Handler>>,
}
impl<Handler: BackendHandler> Default for Mutation<Handler> {
fn default() -> Self {
Self::new()
}
}
impl<Handler: BackendHandler> Mutation<Handler> {
pub fn new() -> Self {
Self {
@@ -62,11 +65,16 @@ pub struct CreateUserInput {
// The email can be specified as an attribute, but one of the two is required.
email: Option<String>,
display_name: Option<String>,
/// First name of user. Deprecated: use attribute instead.
/// If both field and corresponding attribute is supplied, the attribute will take precedence.
first_name: Option<String>,
/// Last name of user. Deprecated: use attribute instead.
/// If both field and corresponding attribute is supplied, the attribute will take precedence.
last_name: Option<String>,
/// Base64 encoded JpegPhoto.
/// Base64 encoded JpegPhoto. Deprecated: use attribute instead.
/// If both field and corresponding attribute is supplied, the attribute will take precedence.
avatar: Option<String>,
/// User-defined attributes.
/// Attributes.
attributes: Option<Vec<AttributeValue>>,
}
@@ -84,9 +92,14 @@ pub struct UpdateUserInput {
id: String,
email: Option<String>,
display_name: Option<String>,
/// First name of user. Deprecated: use attribute instead.
/// If both field and corresponding attribute is supplied, the attribute will take precedence.
first_name: Option<String>,
/// Last name of user. Deprecated: use attribute instead.
/// If both field and corresponding attribute is supplied, the attribute will take precedence.
last_name: Option<String>,
/// Base64 encoded JpegPhoto.
/// Base64 encoded JpegPhoto. Deprecated: use attribute instead.
/// If both field and corresponding attribute is supplied, the attribute will take precedence.
avatar: Option<String>,
/// Attribute names to remove.
/// They are processed before insertions.
@@ -125,7 +138,7 @@ impl Success {
struct UnpackedAttributes {
email: Option<Email>,
display_name: Option<String>,
attributes: Vec<DomainAttributeValue>,
attributes: Vec<DomainAttribute>,
}
fn unpack_attributes(
@@ -139,7 +152,7 @@ fn unpack_attributes(
.cloned()
.map(|attr| deserialize_attribute(&schema.get_schema().user_attributes, attr, is_admin))
.transpose()?
.map(|attr| attr.value.unwrap::<String>())
.map(|attr| attr.value.into_string().unwrap())
.map(Email::from);
let display_name = attributes
.iter()
@@ -147,7 +160,7 @@ fn unpack_attributes(
.cloned()
.map(|attr| deserialize_attribute(&schema.get_schema().user_attributes, attr, is_admin))
.transpose()?
.map(|attr| attr.value.unwrap::<String>());
.map(|attr| attr.value.into_string().unwrap());
let attributes = attributes
.into_iter()
.filter(|attr| attr.name != "mail" && attr.name != "display_name")
@@ -160,6 +173,52 @@ fn unpack_attributes(
})
}
/// Consolidates caller supplied user fields and attributes into a list of attributes.
///
/// A number of user fields are internally represented as attributes, but are still also
/// available as fields on user objects. This function consolidates these fields and the
/// given attributes into a resulting attribute list. If a value is supplied for both a
/// field and the corresponding attribute, the attribute will take precedence.
fn consolidate_attributes(
attributes: Vec<AttributeValue>,
first_name: Option<String>,
last_name: Option<String>,
avatar: Option<String>,
) -> Vec<AttributeValue> {
// Prepare map of the client provided attributes
let mut provided_attributes: BTreeMap<AttributeName, AttributeValue> = attributes
.into_iter()
.map(|x| {
(
x.name.clone().into(),
AttributeValue {
name: x.name.to_ascii_lowercase(),
value: x.value,
},
)
})
.collect::<BTreeMap<_, _>>();
// Prepare list of fallback attribute values
let field_attrs = [
("first_name", first_name),
("last_name", last_name),
("avatar", avatar),
];
for (name, value) in field_attrs.into_iter() {
if let Some(val) = value {
let attr_name: AttributeName = name.into();
provided_attributes
.entry(attr_name)
.or_insert_with(|| AttributeValue {
name: name.to_string(),
value: vec![val],
});
}
}
// Return the values of the resulting map
provided_attributes.into_values().collect()
}
#[graphql_object(context = Context<Handler>)]
impl<Handler: BackendHandler> Mutation<Handler> {
async fn create_user(
@@ -174,20 +233,18 @@ impl<Handler: BackendHandler> Mutation<Handler> {
.get_admin_handler()
.ok_or_else(field_error_callback(&span, "Unauthorized user creation"))?;
let user_id = UserId::new(&user.id);
let avatar = user
.avatar
.map(|bytes| base64::engine::general_purpose::STANDARD.decode(bytes))
.transpose()
.context("Invalid base64 image")?
.map(JpegPhoto::try_from)
.transpose()
.context("Provided image is not a valid JPEG")?;
let schema = handler.get_schema().await?;
let consolidated_attributes = consolidate_attributes(
user.attributes.unwrap_or_default(),
user.first_name,
user.last_name,
user.avatar,
);
let UnpackedAttributes {
email,
display_name,
attributes,
} = unpack_attributes(user.attributes.unwrap_or_default(), &schema, true)?;
} = unpack_attributes(consolidated_attributes, &schema, true)?;
handler
.create_user(CreateUserRequest {
user_id: user_id.clone(),
@@ -197,9 +254,6 @@ impl<Handler: BackendHandler> Mutation<Handler> {
.or(email)
.ok_or_else(|| anyhow!("Email is required when creating a new user"))?,
display_name: user.display_name.or(display_name),
first_name: user.first_name,
last_name: user.last_name,
avatar,
attributes,
})
.instrument(span.clone())
@@ -250,26 +304,32 @@ impl<Handler: BackendHandler> Mutation<Handler> {
.get_writeable_handler(&user_id)
.ok_or_else(field_error_callback(&span, "Unauthorized user update"))?;
let is_admin = context.validation_result.is_admin();
let avatar = user
.avatar
.map(|bytes| base64::engine::general_purpose::STANDARD.decode(bytes))
.transpose()
.context("Invalid base64 image")?
.map(JpegPhoto::try_from)
.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();
// Consolidate attributes and fields into a combined attribute list
let consolidated_attributes = consolidate_attributes(
user.insert_attributes.unwrap_or_default(),
user.first_name,
user.last_name,
user.avatar,
);
// Extract any empty attributes into a list of attributes for deletion
let (delete_attrs, insert_attrs): (Vec<_>, Vec<_>) = consolidated_attributes
.into_iter()
.partition(|a| a.value == vec!["".to_string()]);
// Combine lists of attributes for removal
let mut delete_attributes: Vec<String> =
delete_attrs.iter().map(|a| a.name.to_owned()).collect();
delete_attributes.extend(user.remove_attributes.unwrap_or_default());
// Unpack attributes for update
let UnpackedAttributes {
email,
display_name,
attributes: insert_attributes,
} = unpack_attributes(user_insert_attributes, &schema, is_admin)?;
} = unpack_attributes(insert_attrs, &schema, is_admin)?;
let display_name = display_name.or_else(|| {
// If the display name is not inserted, but removed, reset it.
user.remove_attributes
delete_attributes
.iter()
.flatten()
.find(|attr| *attr == "display_name")
.map(|_| String::new())
});
@@ -278,12 +338,7 @@ impl<Handler: BackendHandler> Mutation<Handler> {
user_id,
email: user.email.map(Into::into).or(email),
display_name: user.display_name.or(display_name),
first_name: user.first_name,
last_name: user.last_name,
avatar,
delete_attributes: user
.remove_attributes
.unwrap_or_default()
delete_attributes: delete_attributes
.into_iter()
.filter(|attr| attr != "mail" && attr != "display_name")
.map(Into::into)
@@ -440,6 +495,22 @@ impl<Handler: BackendHandler> Mutation<Handler> {
span.in_scope(|| {
debug!(?name, ?attribute_type, is_list, is_visible, is_editable);
});
validate_attribute_name(&name).map_err(|invalid_chars: Vec<char>| -> FieldError {
let chars = String::from_iter(invalid_chars);
span.in_scope(|| {
debug!(
"Cannot create attribute with invalid name. Valid characters: {}. Invalid chars found: {}",
ALLOWED_CHARACTERS_DESCRIPTION,
chars
)
});
anyhow!(
"Cannot create attribute with invalid name. Valid characters: {}. Invalid chars found: {}",
ALLOWED_CHARACTERS_DESCRIPTION,
chars
)
.into()
})?;
let handler = context
.get_admin_handler()
.ok_or_else(field_error_callback(
@@ -471,6 +542,20 @@ impl<Handler: BackendHandler> Mutation<Handler> {
span.in_scope(|| {
debug!(?name, ?attribute_type, is_list, is_visible, is_editable);
});
validate_attribute_name(&name).map_err(|invalid_chars: Vec<char>| -> FieldError {
let chars = String::from_iter(invalid_chars);
span.in_scope(|| {
debug!(
"Cannot create attribute with invalid name. Invalid chars found: {}",
chars
)
});
anyhow!(
"Cannot create attribute with invalid name. Valid characters: {}",
ALLOWED_CHARACTERS_DESCRIPTION
)
.into()
})?;
let handler = context
.get_admin_handler()
.ok_or_else(field_error_callback(
@@ -665,7 +750,7 @@ fn deserialize_attribute(
attribute_schema: &AttributeList,
attribute: AttributeValue,
is_admin: bool,
) -> FieldResult<DomainAttributeValue> {
) -> FieldResult<DomainAttribute> {
let attribute_name = AttributeName::from(attribute.name.as_str());
let attribute_schema = attribute_schema
.get_attribute_schema(&attribute_name)
@@ -690,8 +775,344 @@ fn deserialize_attribute(
attribute_schema.is_list,
)
.context(format!("While deserializing attribute {}", attribute.name))?;
Ok(DomainAttributeValue {
Ok(DomainAttribute {
name: attribute_name,
value: deserialized_values,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::query::Query;
use juniper::{
DefaultScalarValue, EmptySubscription, GraphQLType, InputValue, RootNode, Variables,
execute, graphql_value,
};
use lldap_auth::access_control::{Permission, ValidationResults};
use lldap_domain::types::{AttributeName, AttributeType};
use lldap_test_utils::MockTestBackendHandler;
use mockall::predicate::eq;
use pretty_assertions::assert_eq;
fn mutation_schema<'q, C, Q, M>(
query_root: Q,
mutation_root: M,
) -> RootNode<'q, Q, M, EmptySubscription<C>>
where
Q: GraphQLType<DefaultScalarValue, Context = C, TypeInfo = ()> + 'q,
M: GraphQLType<DefaultScalarValue, Context = C, TypeInfo = ()> + 'q,
{
RootNode::new(query_root, mutation_root, EmptySubscription::<C>::new())
}
#[tokio::test]
async fn test_create_user_attribute_valid() {
const QUERY: &str = r#"
mutation CreateUserAttribute($name: String!, $attributeType: AttributeType!, $isList: Boolean!, $isVisible: Boolean!, $isEditable: Boolean!) {
addUserAttribute(name: $name, attributeType: $attributeType, isList: $isList, isVisible: $isVisible, isEditable: $isEditable) {
ok
}
}
"#;
let mut mock = MockTestBackendHandler::new();
mock.expect_add_user_attribute()
.with(eq(CreateAttributeRequest {
name: AttributeName::new("AttrName0"),
attribute_type: AttributeType::String,
is_list: false,
is_visible: false,
is_editable: false,
}))
.return_once(|_| Ok(()));
let context = Context::<MockTestBackendHandler>::new_for_tests(
mock,
ValidationResults {
user: UserId::new("bob"),
permission: Permission::Admin,
},
);
let vars = Variables::from([
("name".to_string(), InputValue::scalar("AttrName0")),
(
"attributeType".to_string(),
InputValue::enum_value("STRING"),
),
("isList".to_string(), InputValue::scalar(false)),
("isVisible".to_string(), InputValue::scalar(false)),
("isEditable".to_string(), InputValue::scalar(false)),
]);
let schema = mutation_schema(
Query::<MockTestBackendHandler>::new(),
Mutation::<MockTestBackendHandler>::new(),
);
assert_eq!(
execute(QUERY, None, &schema, &vars, &context).await,
Ok((
graphql_value!(
{
"addUserAttribute": {
"ok": true
}
} ),
vec![]
))
);
}
#[tokio::test]
async fn test_create_user_attribute_invalid() {
const QUERY: &str = r#"
mutation CreateUserAttribute($name: String!, $attributeType: AttributeType!, $isList: Boolean!, $isVisible: Boolean!, $isEditable: Boolean!) {
addUserAttribute(name: $name, attributeType: $attributeType, isList: $isList, isVisible: $isVisible, isEditable: $isEditable) {
ok
}
}
"#;
let mock = MockTestBackendHandler::new();
let context = Context::<MockTestBackendHandler>::new_for_tests(
mock,
ValidationResults {
user: UserId::new("bob"),
permission: Permission::Admin,
},
);
let vars = Variables::from([
("name".to_string(), InputValue::scalar("AttrName_0")),
(
"attributeType".to_string(),
InputValue::enum_value("STRING"),
),
("isList".to_string(), InputValue::scalar(false)),
("isVisible".to_string(), InputValue::scalar(false)),
("isEditable".to_string(), InputValue::scalar(false)),
]);
let schema = mutation_schema(
Query::<MockTestBackendHandler>::new(),
Mutation::<MockTestBackendHandler>::new(),
);
let result = execute(QUERY, None, &schema, &vars, &context).await;
match result {
Ok(res) => {
let (response, errors) = res;
assert!(response.is_null());
let expected_error_msg =
"Cannot create attribute with invalid name. Valid characters: a-z, A-Z, 0-9, and dash (-). Invalid chars found: _"
.to_string();
assert!(
errors
.iter()
.all(|e| e.error().message() == expected_error_msg)
);
}
Err(_) => {
panic!();
}
}
}
#[tokio::test]
async fn test_create_group_attribute_valid() {
const QUERY: &str = r#"
mutation CreateGroupAttribute($name: String!, $attributeType: AttributeType!, $isList: Boolean!, $isVisible: Boolean!) {
addGroupAttribute(name: $name, attributeType: $attributeType, isList: $isList, isVisible: $isVisible, isEditable: false) {
ok
}
}
"#;
let mut mock = MockTestBackendHandler::new();
mock.expect_add_group_attribute()
.with(eq(CreateAttributeRequest {
name: AttributeName::new("AttrName0"),
attribute_type: AttributeType::String,
is_list: false,
is_visible: false,
is_editable: false,
}))
.return_once(|_| Ok(()));
let context = Context::<MockTestBackendHandler>::new_for_tests(
mock,
ValidationResults {
user: UserId::new("bob"),
permission: Permission::Admin,
},
);
let vars = Variables::from([
("name".to_string(), InputValue::scalar("AttrName0")),
(
"attributeType".to_string(),
InputValue::enum_value("STRING"),
),
("isList".to_string(), InputValue::scalar(false)),
("isVisible".to_string(), InputValue::scalar(false)),
("isEditable".to_string(), InputValue::scalar(false)),
]);
let schema = mutation_schema(
Query::<MockTestBackendHandler>::new(),
Mutation::<MockTestBackendHandler>::new(),
);
assert_eq!(
execute(QUERY, None, &schema, &vars, &context).await,
Ok((
graphql_value!(
{
"addGroupAttribute": {
"ok": true
}
} ),
vec![]
))
);
}
#[tokio::test]
async fn test_create_group_attribute_invalid() {
const QUERY: &str = r#"
mutation CreateUserAttribute($name: String!, $attributeType: AttributeType!, $isList: Boolean!, $isVisible: Boolean!, $isEditable: Boolean!) {
addUserAttribute(name: $name, attributeType: $attributeType, isList: $isList, isVisible: $isVisible, isEditable: $isEditable) {
ok
}
}
"#;
let mock = MockTestBackendHandler::new();
let context = Context::<MockTestBackendHandler>::new_for_tests(
mock,
ValidationResults {
user: UserId::new("bob"),
permission: Permission::Admin,
},
);
let vars = Variables::from([
("name".to_string(), InputValue::scalar("AttrName_0")),
(
"attributeType".to_string(),
InputValue::enum_value("STRING"),
),
("isList".to_string(), InputValue::scalar(false)),
("isVisible".to_string(), InputValue::scalar(false)),
("isEditable".to_string(), InputValue::scalar(false)),
]);
let schema = mutation_schema(
Query::<MockTestBackendHandler>::new(),
Mutation::<MockTestBackendHandler>::new(),
);
let result = execute(QUERY, None, &schema, &vars, &context).await;
match result {
Ok(res) => {
let (response, errors) = res;
assert!(response.is_null());
let expected_error_msg =
"Cannot create attribute with invalid name. Valid characters: a-z, A-Z, 0-9, and dash (-). Invalid chars found: _"
.to_string();
assert!(
errors
.iter()
.all(|e| e.error().message() == expected_error_msg)
);
}
Err(_) => {
panic!();
}
}
}
#[tokio::test]
async fn test_attribute_consolidation_attr_precedence() {
let attributes = vec![
AttributeValue {
name: "first_name".to_string(),
value: vec!["expected-first".to_string()],
},
AttributeValue {
name: "last_name".to_string(),
value: vec!["expected-last".to_string()],
},
AttributeValue {
name: "avatar".to_string(),
value: vec!["expected-avatar".to_string()],
},
];
let res = consolidate_attributes(
attributes.clone(),
Some("overridden-first".to_string()),
Some("overridden-last".to_string()),
Some("overriden-avatar".to_string()),
);
assert_eq!(
res,
vec![
AttributeValue {
name: "avatar".to_string(),
value: vec!["expected-avatar".to_string()],
},
AttributeValue {
name: "first_name".to_string(),
value: vec!["expected-first".to_string()],
},
AttributeValue {
name: "last_name".to_string(),
value: vec!["expected-last".to_string()],
},
]
);
}
#[tokio::test]
async fn test_attribute_consolidation_field_fallback() {
let attributes = Vec::new();
let res = consolidate_attributes(
attributes.clone(),
Some("expected-first".to_string()),
Some("expected-last".to_string()),
Some("expected-avatar".to_string()),
);
assert_eq!(
res,
vec![
AttributeValue {
name: "avatar".to_string(),
value: vec!["expected-avatar".to_string()],
},
AttributeValue {
name: "first_name".to_string(),
value: vec!["expected-first".to_string()],
},
AttributeValue {
name: "last_name".to_string(),
value: vec!["expected-last".to_string()],
},
]
);
}
#[tokio::test]
async fn test_attribute_consolidation_field_fallback_2() {
let attributes = vec![AttributeValue {
name: "First_Name".to_string(),
value: vec!["expected-first".to_string()],
}];
let res = consolidate_attributes(
attributes.clone(),
Some("overriden-first".to_string()),
Some("expected-last".to_string()),
Some("expected-avatar".to_string()),
);
assert_eq!(
res,
vec![
AttributeValue {
name: "avatar".to_string(),
value: vec!["expected-avatar".to_string()],
},
AttributeValue {
name: "first_name".to_string(),
value: vec!["expected-first".to_string()],
},
AttributeValue {
name: "last_name".to_string(),
value: vec!["expected-last".to_string()],
},
]
);
}
}
@@ -1,34 +1,31 @@
use std::sync::Arc;
use crate::{
domain::{
deserialize::deserialize_attribute_value,
handler::{BackendHandler, ReadSchemaBackendHandler},
ldap::utils::{map_user_field, UserFieldType},
model::UserColumn,
schema::PublicSchema,
types::{
AttributeType, GroupDetails, GroupId, JpegPhoto, LdapObjectClass, Serialized, UserId,
},
},
infra::{
access_control::{ReadonlyBackendHandler, UserReadableBackendHandler},
graphql::api::{field_error_callback, Context},
},
};
use crate::api::{Context, field_error_callback};
use anyhow::Context as AnyhowContext;
use chrono::{NaiveDateTime, TimeZone};
use juniper::{graphql_object, FieldResult, GraphQLInputObject};
use chrono::TimeZone;
use juniper::{FieldResult, GraphQLInputObject, graphql_object};
use lldap_access_control::{ReadonlyBackendHandler, UserReadableBackendHandler};
use lldap_domain::{
deserialize::deserialize_attribute_value,
public_schema::PublicSchema,
types::{AttributeType, Cardinality, GroupDetails, GroupId, LdapObjectClass, UserId},
};
use lldap_domain_handlers::handler::{BackendHandler, ReadSchemaBackendHandler};
use lldap_domain_model::model::UserColumn;
use lldap_ldap::{
UserFieldType, get_default_group_object_classes, get_default_user_object_classes,
map_user_field,
};
use serde::{Deserialize, Serialize};
use tracing::{debug, debug_span, Instrument, Span};
use std::sync::Arc;
use tracing::{Instrument, Span, debug, debug_span};
type DomainRequestFilter = crate::domain::handler::UserRequestFilter;
type DomainUser = crate::domain::types::User;
type DomainGroup = crate::domain::types::Group;
type DomainUserAndGroups = crate::domain::types::UserAndGroups;
type DomainAttributeList = crate::domain::handler::AttributeList;
type DomainAttributeSchema = crate::domain::handler::AttributeSchema;
type DomainAttributeValue = crate::domain::types::AttributeValue;
type DomainRequestFilter = lldap_domain_handlers::handler::UserRequestFilter;
type DomainUser = lldap_domain::types::User;
type DomainGroup = lldap_domain::types::Group;
type DomainUserAndGroups = lldap_domain::types::UserAndGroups;
type DomainAttributeList = lldap_domain::schema::AttributeList;
type DomainAttributeSchema = lldap_domain::schema::AttributeSchema;
type DomainAttribute = lldap_domain::types::Attribute;
type DomainAttributeValue = lldap_domain::types::AttributeValue;
#[derive(PartialEq, Eq, Debug, GraphQLInputObject)]
/// A filter for requests, specifying a boolean expression based on field constraints. Only one of
@@ -116,6 +113,12 @@ pub struct Query<Handler: BackendHandler> {
_phantom: std::marker::PhantomData<Box<Handler>>,
}
impl<Handler: BackendHandler> Default for Query<Handler> {
fn default() -> Self {
Self::new()
}
}
impl<Handler: BackendHandler> Query<Handler> {
pub fn new() -> Self {
Self {
@@ -296,23 +299,30 @@ impl<Handler: BackendHandler> User<Handler> {
self.attributes
.iter()
.find(|a| a.attribute.name.as_str() == "first_name")
.map(|a| a.attribute.value.unwrap())
.unwrap_or("")
.map(|a| a.attribute.value.as_str().unwrap_or_default())
.unwrap_or_default()
}
fn last_name(&self) -> &str {
self.attributes
.iter()
.find(|a| a.attribute.name.as_str() == "last_name")
.map(|a| a.attribute.value.unwrap())
.unwrap_or("")
.map(|a| a.attribute.value.as_str().unwrap_or_default())
.unwrap_or_default()
}
fn avatar(&self) -> Option<String> {
self.attributes
.iter()
.find(|a| a.attribute.name.as_str() == "avatar")
.map(|a| String::from(&a.attribute.value.unwrap::<JpegPhoto>()))
.map(|a| {
String::from(
a.attribute
.value
.as_jpeg_photo()
.expect("Invalid JPEG returned by the DB"),
)
})
}
fn creation_date(&self) -> chrono::DateTime<chrono::Utc> {
@@ -515,10 +525,28 @@ impl<Handler: BackendHandler> From<DomainAttributeSchema> for AttributeSchema<Ha
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)]
pub struct AttributeList<Handler: BackendHandler> {
attributes: DomainAttributeList,
default_classes: Vec<LdapObjectClass>,
extra_classes: Vec<LdapObjectClass>,
_phantom: std::marker::PhantomData<Box<Handler>>,
}
#[derive(Clone)]
pub struct ObjectClassInfo {
object_class: String,
is_hardcoded: bool,
}
#[graphql_object]
impl ObjectClassInfo {
fn object_class(&self) -> &str {
&self.object_class
}
fn is_hardcoded(&self) -> bool {
self.is_hardcoded
}
}
#[graphql_object(context = Context<Handler>)]
impl<Handler: BackendHandler> AttributeList<Handler> {
fn attributes(&self) -> Vec<AttributeSchema<Handler>> {
@@ -533,12 +561,35 @@ impl<Handler: BackendHandler> AttributeList<Handler> {
fn extra_ldap_object_classes(&self) -> Vec<String> {
self.extra_classes.iter().map(|c| c.to_string()).collect()
}
fn ldap_object_classes(&self) -> Vec<ObjectClassInfo> {
let mut all_object_classes: Vec<ObjectClassInfo> = self
.default_classes
.iter()
.map(|c| ObjectClassInfo {
object_class: c.to_string(),
is_hardcoded: true,
})
.collect();
all_object_classes.extend(self.extra_classes.iter().map(|c| ObjectClassInfo {
object_class: c.to_string(),
is_hardcoded: false,
}));
all_object_classes
}
}
impl<Handler: BackendHandler> AttributeList<Handler> {
fn new(attributes: DomainAttributeList, extra_classes: Vec<LdapObjectClass>) -> Self {
fn new(
attributes: DomainAttributeList,
default_classes: Vec<LdapObjectClass>,
extra_classes: Vec<LdapObjectClass>,
) -> Self {
Self {
attributes,
default_classes,
extra_classes,
_phantom: std::marker::PhantomData,
}
@@ -556,12 +607,14 @@ impl<Handler: BackendHandler> Schema<Handler> {
fn user_schema(&self) -> AttributeList<Handler> {
AttributeList::<Handler>::new(
self.schema.get_schema().user_attributes.clone(),
get_default_user_object_classes(),
self.schema.get_schema().extra_user_object_classes.clone(),
)
}
fn group_schema(&self) -> AttributeList<Handler> {
AttributeList::<Handler>::new(
self.schema.get_schema().group_attributes.clone(),
get_default_group_object_classes(),
self.schema.get_schema().extra_group_object_classes.clone(),
)
}
@@ -578,7 +631,7 @@ impl<Handler: BackendHandler> From<PublicSchema> for Schema<Handler> {
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)]
pub struct AttributeValue<Handler: BackendHandler> {
attribute: DomainAttributeValue,
attribute: DomainAttribute,
schema: AttributeSchema<Handler>,
_phantom: std::marker::PhantomData<Box<Handler>>,
}
@@ -590,7 +643,7 @@ impl<Handler: BackendHandler> AttributeValue<Handler> {
}
fn value(&self) -> FieldResult<Vec<String>> {
Ok(serialize_attribute(&self.attribute, &self.schema.schema))
Ok(serialize_attribute_to_graphql(&self.attribute.value))
}
fn schema(&self) -> &AttributeSchema<Handler> {
@@ -599,9 +652,9 @@ impl<Handler: BackendHandler> AttributeValue<Handler> {
}
impl<Handler: BackendHandler> AttributeValue<Handler> {
fn from_domain(value: DomainAttributeValue, schema: DomainAttributeSchema) -> Self {
fn from_value(attr: DomainAttribute, schema: DomainAttributeSchema) -> Self {
Self {
attribute: value,
attribute: attr,
schema: AttributeSchema::<Handler> {
schema,
_phantom: std::marker::PhantomData,
@@ -621,54 +674,31 @@ impl<Handler: BackendHandler> Clone for AttributeValue<Handler> {
}
}
pub fn serialize_attribute(
attribute: &DomainAttributeValue,
attribute_schema: &DomainAttributeSchema,
) -> Vec<String> {
let convert_date = |date| chrono::Utc.from_utc_datetime(&date).to_rfc3339();
match (attribute_schema.attribute_type, attribute_schema.is_list) {
(AttributeType::String, false) => vec![attribute.value.unwrap::<String>()],
(AttributeType::Integer, false) => {
// LDAP integers are encoded as strings.
vec![attribute.value.unwrap::<i64>().to_string()]
pub fn serialize_attribute_to_graphql(attribute_value: &DomainAttributeValue) -> Vec<String> {
let convert_date = |&date| chrono::Utc.from_utc_datetime(&date).to_rfc3339();
match attribute_value {
DomainAttributeValue::String(Cardinality::Singleton(s)) => vec![s.clone()],
DomainAttributeValue::String(Cardinality::Unbounded(l)) => l.clone(),
DomainAttributeValue::Integer(Cardinality::Singleton(i)) => vec![i.to_string()],
DomainAttributeValue::Integer(Cardinality::Unbounded(l)) => {
l.iter().map(|i| i.to_string()).collect()
}
(AttributeType::JpegPhoto, false) => {
vec![String::from(&attribute.value.unwrap::<JpegPhoto>())]
DomainAttributeValue::DateTime(Cardinality::Singleton(dt)) => vec![convert_date(dt)],
DomainAttributeValue::DateTime(Cardinality::Unbounded(l)) => {
l.iter().map(convert_date).collect()
}
(AttributeType::DateTime, false) => {
vec![convert_date(attribute.value.unwrap::<NaiveDateTime>())]
DomainAttributeValue::JpegPhoto(Cardinality::Singleton(p)) => vec![String::from(p)],
DomainAttributeValue::JpegPhoto(Cardinality::Unbounded(l)) => {
l.iter().map(String::from).collect()
}
(AttributeType::String, true) => attribute
.value
.unwrap::<Vec<String>>()
.into_iter()
.collect(),
(AttributeType::Integer, true) => attribute
.value
.unwrap::<Vec<i64>>()
.into_iter()
.map(|i| i.to_string())
.collect(),
(AttributeType::JpegPhoto, true) => attribute
.value
.unwrap::<Vec<JpegPhoto>>()
.iter()
.map(String::from)
.collect(),
(AttributeType::DateTime, true) => attribute
.value
.unwrap::<Vec<NaiveDateTime>>()
.into_iter()
.map(convert_date)
.collect(),
}
}
impl<Handler: BackendHandler> AttributeValue<Handler> {
fn from_schema(a: DomainAttributeValue, schema: &DomainAttributeList) -> Option<Self> {
fn from_schema(a: DomainAttribute, schema: &DomainAttributeList) -> Option<Self> {
schema
.get_attribute_schema(&a.name)
.map(|s| AttributeValue::<Handler>::from_domain(a, s.clone()))
.map(|s| AttributeValue::<Handler>::from_value(a, s.clone()))
}
fn user_attributes_from_schema(
@@ -682,25 +712,25 @@ impl<Handler: BackendHandler> AttributeValue<Handler> {
.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),
.flat_map(|attribute_schema| {
let value: Option<DomainAttributeValue> = match attribute_schema.name.as_str() {
"user_id" => Some(user.user_id.clone().into_string().into()),
"creation_date" => Some(user.creation_date.into()),
"mail" => Some(user.email.clone().into_string().into()),
"uuid" => Some(user.uuid.clone().into_string().into()),
"display_name" => user.display_name.as_ref().map(|d| d.clone().into()),
"avatar" | "first_name" | "last_name" => None,
_ => panic!("Unexpected hardcoded attribute: {}", attribute.name),
_ => panic!("Unexpected hardcoded attribute: {}", attribute_schema.name),
};
value.map(|v| (attribute, v))
value.map(|v| (attribute_schema, v))
})
.map(|(attribute, value)| {
AttributeValue::<Handler>::from_domain(
DomainAttributeValue {
name: attribute.name.clone(),
.map(|(attribute_schema, value)| {
AttributeValue::<Handler>::from_value(
DomainAttribute {
name: attribute_schema.name.clone(),
value,
},
attribute.clone(),
attribute_schema.clone(),
)
})
.collect::<Vec<_>>();
@@ -724,25 +754,25 @@ impl<Handler: BackendHandler> AttributeValue<Handler> {
.attributes
.iter()
.filter(|a| a.is_hardcoded)
.map(|attribute| {
.map(|attribute_schema| {
(
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),
attribute_schema,
match attribute_schema.name.as_str() {
"group_id" => (group.id.0 as i64).into(),
"creation_date" => group.creation_date.into(),
"uuid" => group.uuid.clone().into_string().into(),
"display_name" => group.display_name.clone().into_string().into(),
_ => panic!("Unexpected hardcoded attribute: {}", attribute_schema.name),
},
)
})
.map(|(attribute, value)| {
AttributeValue::<Handler>::from_domain(
DomainAttributeValue {
name: attribute.name.clone(),
.map(|(attribute_schema, value)| {
AttributeValue::<Handler>::from_value(
DomainAttribute {
name: attribute_schema.name.clone(),
value,
},
attribute.clone(),
attribute_schema.clone(),
)
})
.collect::<Vec<_>>();
@@ -766,25 +796,25 @@ impl<Handler: BackendHandler> AttributeValue<Handler> {
.attributes
.iter()
.filter(|a| a.is_hardcoded)
.map(|attribute| {
.map(|attribute_schema| {
(
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),
attribute_schema,
match attribute_schema.name.as_str() {
"group_id" => (group.group_id.0 as i64).into(),
"creation_date" => group.creation_date.into(),
"uuid" => group.uuid.clone().into_string().into(),
"display_name" => group.display_name.clone().into_string().into(),
_ => panic!("Unexpected hardcoded attribute: {}", attribute_schema.name),
},
)
})
.map(|(attribute, value)| {
AttributeValue::<Handler>::from_domain(
DomainAttributeValue {
name: attribute.name.clone(),
.map(|(attribute_schema, value)| {
AttributeValue::<Handler>::from_value(
DomainAttribute {
name: attribute_schema.name.clone(),
value,
},
attribute.clone(),
attribute_schema.clone(),
)
})
.collect::<Vec<_>>();
@@ -801,21 +831,17 @@ impl<Handler: BackendHandler> AttributeValue<Handler> {
#[cfg(test)]
mod tests {
use super::*;
use crate::{
domain::{
handler::AttributeList,
types::{AttributeName, AttributeType, LdapObjectClass, Serialized},
},
infra::{
access_control::{Permission, ValidationResults},
test_utils::{setup_default_schema, MockTestBackendHandler},
},
};
use chrono::TimeZone;
use juniper::{
execute, graphql_value, DefaultScalarValue, EmptyMutation, EmptySubscription, GraphQLType,
RootNode, Variables,
DefaultScalarValue, EmptyMutation, EmptySubscription, GraphQLType, RootNode, Variables,
execute, graphql_value,
};
use lldap_auth::access_control::{Permission, ValidationResults};
use lldap_domain::{
schema::{AttributeList, Schema},
types::{AttributeName, AttributeType, LdapObjectClass},
};
use lldap_test_utils::{MockTestBackendHandler, setup_default_schema};
use mockall::predicate::eq;
use pretty_assertions::assert_eq;
use std::collections::HashSet;
@@ -860,7 +886,7 @@ mod tests {
let mut mock = MockTestBackendHandler::new();
mock.expect_get_schema().returning(|| {
Ok(crate::domain::handler::Schema {
Ok(Schema {
user_attributes: DomainAttributeList {
attributes: vec![
DomainAttributeSchema {
@@ -908,15 +934,15 @@ mod tests {
user_id: UserId::new("bob"),
email: "bob@bobbers.on".into(),
creation_date: chrono::Utc.timestamp_millis_opt(42).unwrap().naive_utc(),
uuid: crate::uuid!("b1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
uuid: lldap_domain::uuid!("b1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
attributes: vec![
DomainAttributeValue {
DomainAttribute {
name: "first_name".into(),
value: Serialized::from("Bob"),
value: "Bob".to_string().into(),
},
DomainAttributeValue {
DomainAttribute {
name: "last_name".into(),
value: Serialized::from("Bobberson"),
value: "Bobberson".to_string().into(),
},
],
..Default::default()
@@ -927,17 +953,17 @@ mod tests {
group_id: GroupId(3),
display_name: "Bobbersons".into(),
creation_date: chrono::Utc.timestamp_nanos(42).naive_utc(),
uuid: crate::uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
attributes: vec![DomainAttributeValue {
uuid: lldap_domain::uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
attributes: vec![DomainAttribute {
name: "club_name".into(),
value: Serialized::from("Gang of Four"),
value: "Gang of Four".to_string().into(),
}],
});
groups.insert(GroupDetails {
group_id: GroupId(7),
display_name: "Jefferees".into(),
creation_date: chrono::Utc.timestamp_nanos(12).naive_utc(),
uuid: crate::uuid!("b1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
uuid: lldap_domain::uuid!("b1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
attributes: Vec::new(),
});
mock.expect_get_user_groups()
@@ -1076,7 +1102,7 @@ mod tests {
),
DomainRequestFilter::AttributeEquality(
AttributeName::from("first_name"),
Serialized::from("robert"),
"robert".to_string().into(),
),
]))),
eq(false),
@@ -1299,7 +1325,7 @@ mod tests {
let mut mock = MockTestBackendHandler::new();
mock.expect_get_schema().times(1).return_once(|| {
Ok(crate::domain::handler::Schema {
Ok(Schema {
user_attributes: AttributeList {
attributes: vec![DomainAttributeSchema {
name: "invisible".into(),
+66
View File
@@ -0,0 +1,66 @@
[package]
name = "lldap_ldap"
version = "0.1.0"
description = "LDAP operations support"
authors.workspace = true
edition.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
anyhow = "*"
ldap3_proto = "0.6.0"
tracing = "*"
itertools = "0.10"
[dependencies.derive_more]
features = ["from"]
default-features = false
version = "1"
[dependencies.chrono]
features = ["serde"]
version = "*"
[dependencies.rand]
features = ["small_rng", "getrandom"]
version = "0.8"
[dependencies.uuid]
version = "1"
features = ["v1", "v3"]
[dependencies.lldap_access_control]
path = "../access-control"
[dependencies.lldap_auth]
path = "../auth"
features = ["opaque_server", "opaque_client", "sea_orm"]
[dependencies.lldap_domain]
path = "../domain"
[dependencies.lldap_domain_handlers]
path = "../domain-handlers"
[dependencies.lldap_domain_model]
path = "../domain-model"
[dependencies.lldap_opaque_handler]
path = "../opaque-handler"
[dev-dependencies.lldap_test_utils]
path = "../test-utils"
[dev-dependencies]
mockall = "0.11.4"
pretty_assertions = "1"
[dev-dependencies.tokio]
features = ["full"]
version = "1.25"
[dev-dependencies.lldap_domain]
path = "../domain"
features = ["test"]
+240
View File
@@ -0,0 +1,240 @@
use crate::core::error::{LdapError, LdapResult};
use ldap3_proto::proto::{LdapCompareRequest, LdapOp, LdapResult as LdapResultOp, LdapResultCode};
use lldap_domain::types::AttributeName;
pub fn compare(
request: LdapCompareRequest,
search_results: Vec<LdapOp>,
base_dn_str: &str,
) -> LdapResult<Vec<LdapOp>> {
if search_results.len() > 2 {
// SearchResultEntry + SearchResultDone
return Err(LdapError {
code: LdapResultCode::OperationsError,
message: "Too many search results".to_string(),
});
}
let requested_attribute = AttributeName::from(&request.atype);
match search_results.first() {
Some(LdapOp::SearchResultEntry(entry)) => {
let available = entry.attributes.iter().any(|attr| {
AttributeName::from(&attr.atype) == requested_attribute
&& attr.vals.contains(&request.val)
});
Ok(vec![LdapOp::CompareResult(LdapResultOp {
code: if available {
LdapResultCode::CompareTrue
} else {
LdapResultCode::CompareFalse
},
matcheddn: request.dn,
message: "".to_string(),
referral: vec![],
})])
}
Some(LdapOp::SearchResultDone(_)) => Ok(vec![LdapOp::CompareResult(LdapResultOp {
code: LdapResultCode::NoSuchObject,
matcheddn: base_dn_str.to_string(),
message: "".to_string(),
referral: vec![],
})]),
None => Err(LdapError {
code: LdapResultCode::OperationsError,
message: "Search request returned nothing".to_string(),
}),
_ => Err(LdapError {
code: LdapResultCode::OperationsError,
message: "Unexpected results from search".to_string(),
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::handler::tests::setup_bound_admin_handler;
use chrono::TimeZone;
use lldap_domain::{
types::{Group, GroupId, User, UserAndGroups, UserId},
uuid,
};
use lldap_domain_handlers::handler::{GroupRequestFilter, UserRequestFilter};
use lldap_test_utils::MockTestBackendHandler;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn test_compare_user() {
let mut mock = MockTestBackendHandler::new();
mock.expect_list_users().returning(|f, g| {
assert_eq!(f, Some(UserRequestFilter::UserId(UserId::new("bob"))));
assert!(!g);
Ok(vec![UserAndGroups {
user: User {
user_id: UserId::new("bob"),
email: "bob@bobmail.bob".into(),
..Default::default()
},
groups: None,
}])
});
mock.expect_list_groups().returning(|_| Ok(vec![]));
let ldap_handler = setup_bound_admin_handler(mock).await;
let dn = "uid=bob,ou=people,dc=example,dc=com";
let request = LdapCompareRequest {
dn: dn.to_string(),
atype: "uid".to_owned(),
val: b"bob".to_vec(),
};
assert_eq!(
ldap_handler.do_compare(request).await,
Ok(vec![LdapOp::CompareResult(LdapResultOp {
code: LdapResultCode::CompareTrue,
matcheddn: dn.to_string(),
message: "".to_string(),
referral: vec![],
})])
);
// Non-canonical attribute.
let request = LdapCompareRequest {
dn: dn.to_string(),
atype: "eMail".to_owned(),
val: b"bob@bobmail.bob".to_vec(),
};
assert_eq!(
ldap_handler.do_compare(request).await,
Ok(vec![LdapOp::CompareResult(LdapResultOp {
code: LdapResultCode::CompareTrue,
matcheddn: dn.to_string(),
message: "".to_string(),
referral: vec![],
})])
);
}
#[tokio::test]
async fn test_compare_group() {
let mut mock = MockTestBackendHandler::new();
mock.expect_list_users().returning(|_, _| Ok(vec![]));
mock.expect_list_groups().returning(|f| {
assert_eq!(f, Some(GroupRequestFilter::DisplayName("group".into())));
Ok(vec![Group {
id: GroupId(1),
display_name: "group".into(),
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
users: vec![UserId::new("bob")],
uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"),
attributes: Vec::new(),
}])
});
let ldap_handler = setup_bound_admin_handler(mock).await;
let dn = "uid=group,ou=groups,dc=example,dc=com";
let request = LdapCompareRequest {
dn: dn.to_string(),
atype: "uid".to_owned(),
val: b"group".to_vec(),
};
assert_eq!(
ldap_handler.do_compare(request).await,
Ok(vec![LdapOp::CompareResult(LdapResultOp {
code: LdapResultCode::CompareTrue,
matcheddn: dn.to_string(),
message: "".to_string(),
referral: vec![],
})])
);
}
#[tokio::test]
async fn test_compare_not_found() {
let mut mock = MockTestBackendHandler::new();
mock.expect_list_users().returning(|f, g| {
assert_eq!(f, Some(UserRequestFilter::UserId(UserId::new("bob"))));
assert!(!g);
Ok(vec![])
});
mock.expect_list_groups().returning(|_| Ok(vec![]));
let ldap_handler = setup_bound_admin_handler(mock).await;
let dn = "uid=bob,ou=people,dc=example,dc=com";
let request = LdapCompareRequest {
dn: dn.to_string(),
atype: "uid".to_owned(),
val: b"bob".to_vec(),
};
assert_eq!(
ldap_handler.do_compare(request).await,
Ok(vec![LdapOp::CompareResult(LdapResultOp {
code: LdapResultCode::NoSuchObject,
matcheddn: "dc=example,dc=com".to_owned(),
message: "".to_string(),
referral: vec![],
})])
);
}
#[tokio::test]
async fn test_compare_no_match() {
let mut mock = MockTestBackendHandler::new();
mock.expect_list_users().returning(|f, g| {
assert_eq!(f, Some(UserRequestFilter::UserId(UserId::new("bob"))));
assert!(!g);
Ok(vec![UserAndGroups {
user: User {
user_id: UserId::new("bob"),
email: "bob@bobmail.bob".into(),
..Default::default()
},
groups: None,
}])
});
mock.expect_list_groups().returning(|_| Ok(vec![]));
let ldap_handler = setup_bound_admin_handler(mock).await;
let dn = "uid=bob,ou=people,dc=example,dc=com";
let request = LdapCompareRequest {
dn: dn.to_string(),
atype: "mail".to_owned(),
val: b"bob@bob".to_vec(),
};
assert_eq!(
ldap_handler.do_compare(request).await,
Ok(vec![LdapOp::CompareResult(LdapResultOp {
code: LdapResultCode::CompareFalse,
matcheddn: dn.to_string(),
message: "".to_string(),
referral: vec![],
})])
);
}
#[tokio::test]
async fn test_compare_group_member() {
let mut mock = MockTestBackendHandler::new();
mock.expect_list_users().returning(|_, _| Ok(vec![]));
mock.expect_list_groups().returning(|f| {
assert_eq!(f, Some(GroupRequestFilter::DisplayName("group".into())));
Ok(vec![Group {
id: GroupId(1),
display_name: "group".into(),
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
users: vec![UserId::new("bob")],
uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"),
attributes: Vec::new(),
}])
});
let ldap_handler = setup_bound_admin_handler(mock).await;
let dn = "uid=group,ou=groups,dc=example,dc=com";
let request = LdapCompareRequest {
dn: dn.to_string(),
atype: "uniqueMember".to_owned(),
val: b"uid=bob,ou=people,dc=example,dc=com".to_vec(),
};
assert_eq!(
ldap_handler.do_compare(request).await,
Ok(vec![LdapOp::CompareResult(LdapResultOp {
code: LdapResultCode::CompareTrue,
matcheddn: dn.to_owned(),
message: "".to_string(),
referral: vec![],
})])
);
}
}
@@ -1,24 +1,40 @@
use crate::core::{
error::{LdapError, LdapResult},
utils::{
ExpandedAttributes, GroupFieldType, LdapInfo, 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,
},
};
use chrono::TimeZone;
use ldap3_proto::{
proto::LdapOp, LdapFilter, LdapPartialAttribute, LdapResultCode, LdapSearchResultEntry,
LdapFilter, LdapPartialAttribute, LdapResultCode, LdapSearchResultEntry, proto::LdapOp,
};
use lldap_domain::{
deserialize::deserialize_attribute_value,
public_schema::PublicSchema,
types::{AttributeName, AttributeType, Group, GroupId, LdapObjectClass, UserId, Uuid},
};
use lldap_domain_handlers::handler::{GroupListerBackendHandler, GroupRequestFilter};
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,
},
},
schema::{PublicSchema, SchemaGroupAttributeExtractor},
types::{AttributeName, AttributeType, Group, LdapObjectClass, UserId, Uuid},
};
pub const REQUIRED_GROUP_ATTRIBUTES: &[&str] = &["display_name"];
const DEFAULT_GROUP_OBJECT_CLASSES: &[&str] = &["groupOfUniqueNames", "groupOfNames"];
fn get_default_group_object_classes_as_bytes() -> Vec<Vec<u8>> {
DEFAULT_GROUP_OBJECT_CLASSES
.iter()
.map(|c| c.as_bytes().to_vec())
.collect()
}
pub fn get_default_group_object_classes() -> Vec<LdapObjectClass> {
DEFAULT_GROUP_OBJECT_CLASSES
.iter()
.map(|&c| LdapObjectClass::from(c))
.collect()
}
pub fn get_group_attribute(
group: &Group,
@@ -30,7 +46,8 @@ pub fn get_group_attribute(
) -> Option<Vec<Vec<u8>>> {
let attribute_values = match map_group_field(attribute, schema) {
GroupFieldType::ObjectClass => {
let mut classes = vec![b"groupOfUniqueNames".to_vec()];
let mut classes: Vec<Vec<u8>> = get_default_group_object_classes_as_bytes();
classes.extend(
schema
.get_schema()
@@ -45,42 +62,42 @@ pub fn get_group_attribute(
GroupFieldType::EntryDn => {
vec![format!("uid={},ou=groups,{}", group.display_name, base_dn_str).into_bytes()]
}
GroupFieldType::GroupId => {
vec![group.id.0.to_string().into_bytes()]
}
GroupFieldType::DisplayName => vec![group.display_name.to_string().into_bytes()],
GroupFieldType::CreationDate => vec![chrono::Utc
.from_utc_datetime(&group.creation_date)
.to_rfc3339()
.into_bytes()],
GroupFieldType::CreationDate => vec![
chrono::Utc
.from_utc_datetime(&group.creation_date)
.to_rfc3339()
.into_bytes(),
],
GroupFieldType::Member => group
.users
.iter()
.filter(|u| user_filter.as_ref().map(|f| *u == f).unwrap_or(true))
.map(|u| format!("uid={},ou=people,{}", u, base_dn_str).into_bytes())
.map(|u| format!("uid={u},ou=people,{base_dn_str}").into_bytes())
.collect(),
GroupFieldType::Uuid => vec![group.uuid.to_string().into_bytes()],
GroupFieldType::Attribute(attr, _, _) => {
get_custom_attribute::<SchemaGroupAttributeExtractor>(&group.attributes, &attr, schema)?
}
GroupFieldType::Attribute(attr, _, _) => get_custom_attribute(&group.attributes, &attr)?,
GroupFieldType::NoMatch => match attribute.as_str() {
"1.1" => return None,
// We ignore the operational attribute wildcard
"+" => return None,
"*" => {
panic!(
"Matched {}, * should have been expanded into attribute list and * removed",
attribute
"Matched {attribute}, * should have been expanded into attribute list and * removed"
)
}
_ => {
if ignored_group_attributes.contains(attribute) {
return None;
}
get_custom_attribute::<SchemaGroupAttributeExtractor>(
get_custom_attribute(
&group.attributes,
attribute,
schema,
).or_else(||{warn!(
r#"Ignoring unrecognized group attribute: {}\n\
To disable this warning, add it to "ignored_group_attributes" in the config."#,
r#"Ignoring unrecognized group attribute: {}. To disable this warning, add it to "ignored_group_attributes" in the config."#,
attribute
);None})?
}
@@ -151,12 +168,23 @@ fn get_group_attribute_equality_filter(
is_list: bool,
value: &str,
) -> GroupRequestFilter {
deserialize_attribute_value(&[value.to_owned()], typ, is_list)
.map(|v| GroupRequestFilter::AttributeEquality(field.clone(), v))
.unwrap_or_else(|e| {
let value_lc = value.to_ascii_lowercase();
let serialized_value = deserialize_attribute_value(&[value.to_owned()], typ, is_list);
let serialized_value_lc = deserialize_attribute_value(&[value_lc.to_owned()], typ, is_list);
match (serialized_value, serialized_value_lc) {
(Ok(v), Ok(v_lc)) => GroupRequestFilter::Or(vec![
GroupRequestFilter::AttributeEquality(field.clone(), v),
GroupRequestFilter::AttributeEquality(field.clone(), v_lc),
]),
(Ok(_), Err(e)) => {
warn!("Invalid value for attribute {} (lowercased): {}", field, e);
GroupRequestFilter::from(false)
}
(Err(e), _) => {
warn!("Invalid value for attribute {}: {}", field, e);
GroupRequestFilter::from(false)
})
}
}
}
fn convert_group_filter(
@@ -168,17 +196,24 @@ fn convert_group_filter(
match filter {
LdapFilter::Equality(field, value) => {
let field = AttributeName::from(field.as_str());
let value = value.to_ascii_lowercase();
let value_lc = 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())
GroupFieldType::GroupId => Ok(value_lc
.parse::<i32>()
.map(|id| GroupRequestFilter::GroupId(GroupId(id)))
.unwrap_or_else(|_| {
warn!("Given group id is not a valid integer: {}", value_lc);
GroupRequestFilter::from(false)
})),
GroupFieldType::DisplayName => Ok(GroupRequestFilter::DisplayName(value_lc.into())),
GroupFieldType::Uuid => Uuid::try_from(value_lc.as_str())
.map(GroupRequestFilter::Uuid)
.map_err(|e| LdapError {
code: LdapResultCode::Other,
message: format!("Invalid UUID: {:#}", e),
message: format!("Invalid UUID: {e:#}"),
}),
GroupFieldType::Member => Ok(get_user_id_from_distinguished_name_or_plain_name(
&value,
&value_lc,
&ldap_info.base_dn,
&ldap_info.base_dn_str,
)
@@ -188,21 +223,23 @@ fn convert_group_filter(
GroupRequestFilter::from(false)
})),
GroupFieldType::ObjectClass => Ok(GroupRequestFilter::from(
matches!(value.as_str(), "groupofuniquenames" | "groupofnames")
get_default_group_object_classes()
.iter()
.any(|class| class.as_str().eq_ignore_ascii_case(value_lc.as_str()))
|| schema
.get_schema()
.extra_group_object_classes
.contains(&LdapObjectClass::from(value)),
.contains(&LdapObjectClass::from(value_lc)),
)),
GroupFieldType::Dn | GroupFieldType::EntryDn => {
Ok(get_group_id_from_distinguished_name_or_plain_name(
value.as_str(),
value_lc.as_str(),
&ldap_info.base_dn,
&ldap_info.base_dn_str,
)
.map(GroupRequestFilter::DisplayName)
.unwrap_or_else(|_| {
warn!("Invalid dn filter on group: {}", value);
warn!("Invalid dn filter on group: {}", value_lc);
GroupRequestFilter::from(false)
}))
}
@@ -217,7 +254,7 @@ 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),
get_group_attribute_equality_filter(&field, typ, is_list, value),
),
GroupFieldType::CreationDate => Err(LdapError {
code: LdapResultCode::UnwillingToPerform,
@@ -252,15 +289,14 @@ fn convert_group_filter(
_ => Err(LdapError {
code: LdapResultCode::UnwillingToPerform,
message: format!(
"Unsupported group attribute for substring filter: \"{}\"",
field
"Unsupported group attribute for substring filter: \"{field}\""
),
}),
}
}
_ => Err(LdapError {
code: LdapResultCode::UnwillingToPerform,
message: format!("Unsupported group filter: {:?}", filter),
message: format!("Unsupported group filter: {filter:?}"),
}),
}
}
@@ -280,7 +316,7 @@ pub async fn get_groups_list<Backend: GroupListerBackendHandler>(
.await
.map_err(|e| LdapError {
code: LdapResultCode::Other,
message: format!(r#"Error while listing groups "{}": {:#}"#, base, e),
message: format!(r#"Error while listing groups "{base}": {e:#}"#),
})
}
@@ -1,27 +1,44 @@
use crate::core::{
error::{LdapError, LdapResult},
utils::{
ExpandedAttributes, LdapInfo, UserFieldType, expand_attribute_wildcards,
get_custom_attribute, get_group_id_from_distinguished_name_or_plain_name,
get_user_id_from_distinguished_name_or_plain_name, map_user_field,
},
};
use chrono::TimeZone;
use ldap3_proto::{
proto::LdapOp, LdapFilter, LdapPartialAttribute, LdapResultCode, LdapSearchResultEntry,
LdapFilter, LdapPartialAttribute, LdapResultCode, LdapSearchResultEntry, proto::LdapOp,
};
use lldap_domain::{
deserialize::deserialize_attribute_value,
public_schema::PublicSchema,
types::{
AttributeName, AttributeType, GroupDetails, LdapObjectClass, User, UserAndGroups, UserId,
},
};
use lldap_domain_handlers::handler::{UserListerBackendHandler, UserRequestFilter};
use lldap_domain_model::model::UserColumn;
use tracing::{debug, instrument, warn};
use crate::domain::{
deserialize::deserialize_attribute_value,
handler::{UserListerBackendHandler, UserRequestFilter},
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,
},
},
schema::{PublicSchema, SchemaUserAttributeExtractor},
types::{
AttributeName, AttributeType, GroupDetails, LdapObjectClass, User, UserAndGroups,
UserColumn, UserId,
},
};
pub const REQUIRED_USER_ATTRIBUTES: &[&str] = &["user_id", "mail"];
const DEFAULT_USER_OBJECT_CLASSES: &[&str] =
&["inetOrgPerson", "posixAccount", "mailAccount", "person"];
fn get_default_user_object_classes_vec_u8() -> Vec<Vec<u8>> {
DEFAULT_USER_OBJECT_CLASSES
.iter()
.map(|c| c.as_bytes().to_vec())
.collect()
}
pub fn get_default_user_object_classes() -> Vec<LdapObjectClass> {
DEFAULT_USER_OBJECT_CLASSES
.iter()
.map(|&c| LdapObjectClass::from(c))
.collect()
}
pub fn get_user_attribute(
user: &User,
@@ -33,12 +50,8 @@ pub fn get_user_attribute(
) -> 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(),
];
let mut classes: Vec<Vec<u8>> = get_default_user_object_classes_vec_u8();
classes.extend(
schema
.get_schema()
@@ -74,36 +87,29 @@ pub fn get_user_attribute(
UserFieldType::PrimaryField(UserColumn::DisplayName) => {
vec![user.display_name.clone()?.into_bytes()]
}
UserFieldType::PrimaryField(UserColumn::CreationDate) => vec![chrono::Utc
.from_utc_datetime(&user.creation_date)
.to_rfc3339()
.into_bytes()],
UserFieldType::Attribute(attr, _, _) => {
get_custom_attribute::<SchemaUserAttributeExtractor>(&user.attributes, &attr, schema)?
}
UserFieldType::PrimaryField(UserColumn::CreationDate) => vec![
chrono::Utc
.from_utc_datetime(&user.creation_date)
.to_rfc3339()
.into_bytes(),
],
UserFieldType::Attribute(attr, _, _) => get_custom_attribute(&user.attributes, &attr)?,
UserFieldType::NoMatch => match attribute.as_str() {
"1.1" => return None,
// We ignore the operational attribute wildcard.
"+" => return None,
"*" => {
panic!(
"Matched {}, * should have been expanded into attribute list and * removed",
attribute
"Matched {attribute}, * should have been expanded into attribute list and * removed"
)
}
_ => {
if ignored_user_attributes.contains(attribute) {
return None;
}
get_custom_attribute::<SchemaUserAttributeExtractor>(
&user.attributes,
attribute,
schema,
)
.or_else(|| {
get_custom_attribute(&user.attributes, attribute).or_else(|| {
warn!(
r#"Ignoring unrecognized group attribute: {}\n\
To disable this warning, add it to "ignored_user_attributes" in the config."#,
r#"Ignoring unrecognized user attribute: {}. To disable this warning, add it to "ignored_user_attributes" in the config."#,
attribute
);
None
@@ -174,12 +180,23 @@ fn get_user_attribute_equality_filter(
is_list: bool,
value: &str,
) -> UserRequestFilter {
deserialize_attribute_value(&[value.to_owned()], typ, is_list)
.map(|v| UserRequestFilter::AttributeEquality(field.clone(), v))
.unwrap_or_else(|e| {
let value_lc = value.to_ascii_lowercase();
let serialized_value = deserialize_attribute_value(&[value.to_owned()], typ, is_list);
let serialized_value_lc = deserialize_attribute_value(&[value_lc.to_owned()], typ, is_list);
match (serialized_value, serialized_value_lc) {
(Ok(v), Ok(v_lc)) => UserRequestFilter::Or(vec![
UserRequestFilter::AttributeEquality(field.clone(), v),
UserRequestFilter::AttributeEquality(field.clone(), v_lc),
]),
(Ok(_), Err(e)) => {
warn!("Invalid value for attribute {} (lowercased): {}", field, e);
UserRequestFilter::from(false)
}
(Err(e), _) => {
warn!("Invalid value for attribute {}: {}", field, e);
UserRequestFilter::from(false)
})
}
}
}
fn convert_user_filter(
@@ -198,18 +215,20 @@ fn convert_user_filter(
LdapFilter::Not(filter) => Ok(UserRequestFilter::Not(Box::new(rec(filter)?))),
LdapFilter::Equality(field, value) => {
let field = AttributeName::from(field.as_str());
let value = value.to_ascii_lowercase();
let value_lc = value.to_ascii_lowercase();
match map_user_field(&field, schema) {
UserFieldType::PrimaryField(UserColumn::UserId) => {
Ok(UserRequestFilter::UserId(UserId::new(&value)))
Ok(UserRequestFilter::UserId(UserId::new(&value_lc)))
}
UserFieldType::PrimaryField(UserColumn::Email) => Ok(UserRequestFilter::Equality(
UserColumn::LowercaseEmail,
value,
value_lc,
)),
UserFieldType::PrimaryField(field) => Ok(UserRequestFilter::Equality(field, value)),
UserFieldType::PrimaryField(field) => {
Ok(UserRequestFilter::Equality(field, value_lc))
}
UserFieldType::Attribute(field, typ, is_list) => Ok(
get_user_attribute_equality_filter(&field, typ, is_list, &value),
get_user_attribute_equality_filter(&field, typ, is_list, value),
),
UserFieldType::NoMatch => {
if !ldap_info.ignored_user_attributes.contains(&field) {
@@ -222,16 +241,16 @@ 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)),
get_default_user_object_classes()
.iter()
.any(|class| class.as_str().eq_ignore_ascii_case(value_lc.as_str()))
|| schema
.get_schema()
.extra_user_object_classes
.contains(&LdapObjectClass::from(value_lc)),
)),
UserFieldType::MemberOf => Ok(get_group_id_from_distinguished_name_or_plain_name(
&value,
&value_lc,
&ldap_info.base_dn,
&ldap_info.base_dn_str,
)
@@ -242,13 +261,13 @@ fn convert_user_filter(
})),
UserFieldType::EntryDn | UserFieldType::Dn => {
Ok(get_user_id_from_distinguished_name_or_plain_name(
value.as_str(),
value_lc.as_str(),
&ldap_info.base_dn,
&ldap_info.base_dn_str,
)
.map(UserRequestFilter::UserId)
.unwrap_or_else(|_| {
warn!("Invalid dn filter on user: {}", value);
warn!("Invalid dn filter on user: {}", value_lc);
UserRequestFilter::from(false)
}))
}
@@ -278,10 +297,7 @@ fn convert_user_filter(
| UserFieldType::PrimaryField(UserColumn::CreationDate)
| UserFieldType::PrimaryField(UserColumn::Uuid) => Err(LdapError {
code: LdapResultCode::UnwillingToPerform,
message: format!(
"Unsupported user attribute for substring filter: {:?}",
field
),
message: format!("Unsupported user attribute for substring filter: {field:?}"),
}),
UserFieldType::NoMatch => Ok(UserRequestFilter::from(false)),
UserFieldType::PrimaryField(UserColumn::Email) => Ok(UserRequestFilter::SubString(
@@ -296,7 +312,7 @@ fn convert_user_filter(
}
_ => Err(LdapError {
code: LdapResultCode::UnwillingToPerform,
message: format!("Unsupported user filter: {:?}", filter),
message: format!("Unsupported user filter: {filter:?}"),
}),
}
}
@@ -321,7 +337,7 @@ pub async fn get_user_list<Backend: UserListerBackendHandler>(
.await
.map_err(|e| LdapError {
code: LdapResultCode::Other,
message: format!(r#"Error while searching user "{}": {:#}"#, base, e),
message: format!(r#"Error while searching user "{base}": {e:#}"#),
})
}
+518
View File
@@ -0,0 +1,518 @@
use crate::core::{
error::{LdapError, LdapResult},
group::{REQUIRED_GROUP_ATTRIBUTES, get_default_group_object_classes},
user::{REQUIRED_USER_ATTRIBUTES, get_default_user_object_classes},
};
use chrono::TimeZone;
use itertools::join;
use ldap3_proto::LdapResultCode;
use lldap_domain::{
public_schema::PublicSchema,
schema::{AttributeList, Schema},
types::{
Attribute, AttributeName, AttributeType, AttributeValue, Cardinality, GroupName,
LdapObjectClass, UserId,
},
};
use lldap_domain_model::model::UserColumn;
use std::collections::BTreeMap;
use tracing::{debug, instrument, warn};
fn make_dn_pair<I>(mut iter: I) -> LdapResult<(String, String)>
where
I: Iterator<Item = String>,
{
(|| {
let pair = (
iter.next().ok_or_else(|| "Empty DN element".to_string())?,
iter.next().ok_or_else(|| "Missing DN value".to_string())?,
);
if let Some(e) = iter.next() {
Err(format!(
r#"Too many elements in distinguished name: "{}", "{}", "{}""#,
pair.0, pair.1, e
))
} else {
Ok(pair)
}
})()
.map_err(|e| LdapError {
code: LdapResultCode::InvalidDNSyntax,
message: e,
})
}
pub fn parse_distinguished_name(dn: &str) -> LdapResult<Vec<(String, String)>> {
assert!(dn == dn.to_ascii_lowercase());
dn.split(',')
.map(|s| make_dn_pair(s.split('=').map(str::trim).map(String::from)))
.collect()
}
pub enum UserOrGroupName {
User(UserId),
Group(GroupName),
BadSubStree,
UnexpectedFormat,
InvalidSyntax(LdapError),
}
impl UserOrGroupName {
pub fn into_ldap_error(self, input: &str, expected_format: String) -> LdapError {
LdapError {
code: LdapResultCode::InvalidDNSyntax,
message: match self {
UserOrGroupName::BadSubStree => "Not a subtree of the base tree".to_string(),
UserOrGroupName::InvalidSyntax(err) => return err,
UserOrGroupName::UnexpectedFormat
| UserOrGroupName::User(_)
| UserOrGroupName::Group(_) => {
format!(r#"Unexpected DN format. Got "{input}", expected: {expected_format}"#)
}
},
}
}
}
pub fn get_user_or_group_id_from_distinguished_name(
dn: &str,
base_tree: &[(String, String)],
) -> UserOrGroupName {
let parts = match parse_distinguished_name(dn) {
Ok(p) => p,
Err(e) => return UserOrGroupName::InvalidSyntax(e),
};
if !is_subtree(&parts, base_tree) {
return UserOrGroupName::BadSubStree;
} else if parts.len() == base_tree.len() + 2
&& parts[1].0 == "ou"
&& (parts[0].0 == "cn" || parts[0].0 == "uid")
{
if parts[1].1 == "groups" {
return UserOrGroupName::Group(GroupName::from(parts[0].1.clone()));
} else if parts[1].1 == "people" {
return UserOrGroupName::User(UserId::from(parts[0].1.clone()));
}
}
UserOrGroupName::UnexpectedFormat
}
pub fn get_user_id_from_distinguished_name(
dn: &str,
base_tree: &[(String, String)],
base_dn_str: &str,
) -> LdapResult<UserId> {
match get_user_or_group_id_from_distinguished_name(dn, base_tree) {
UserOrGroupName::User(user_id) => Ok(user_id),
err => Err(err.into_ldap_error(dn, format!(r#""uid=id,ou=people,{base_dn_str}""#))),
}
}
pub fn get_group_id_from_distinguished_name(
dn: &str,
base_tree: &[(String, String)],
base_dn_str: &str,
) -> LdapResult<GroupName> {
match get_user_or_group_id_from_distinguished_name(dn, base_tree) {
UserOrGroupName::Group(group_name) => Ok(group_name),
err => Err(err.into_ldap_error(dn, format!(r#""uid=id,ou=groups,{base_dn_str}""#))),
}
}
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(
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,
}
}
pub fn is_subtree(subtree: &[(String, String)], base_tree: &[(String, String)]) -> bool {
for (k, v) in subtree {
assert!(k == &k.to_ascii_lowercase());
assert!(v == &v.to_ascii_lowercase());
}
for (k, v) in base_tree {
assert!(k == &k.to_ascii_lowercase());
assert!(v == &v.to_ascii_lowercase());
}
if subtree.len() < base_tree.len() {
return false;
}
let size_diff = subtree.len() - base_tree.len();
for i in 0..base_tree.len() {
if subtree[size_diff + i] != base_tree[i] {
return false;
}
}
true
}
pub enum UserFieldType {
NoMatch,
ObjectClass,
MemberOf,
Dn,
EntryDn,
PrimaryField(UserColumn),
Attribute(AttributeName, AttributeType, bool),
}
pub fn map_user_field(field: &AttributeName, schema: &PublicSchema) -> UserFieldType {
match field.as_str() {
"memberof" | "ismemberof" => UserFieldType::MemberOf,
"objectclass" => UserFieldType::ObjectClass,
"dn" | "distinguishedname" => UserFieldType::Dn,
"entrydn" => UserFieldType::EntryDn,
"uid" | "user_id" | "id" => UserFieldType::PrimaryField(UserColumn::UserId),
"mail" | "email" => UserFieldType::PrimaryField(UserColumn::Email),
"cn" | "displayname" | "display_name" => {
UserFieldType::PrimaryField(UserColumn::DisplayName)
}
"givenname" | "first_name" | "firstname" => UserFieldType::Attribute(
AttributeName::from("first_name"),
AttributeType::String,
false,
),
"sn" | "last_name" | "lastname" => UserFieldType::Attribute(
AttributeName::from("last_name"),
AttributeType::String,
false,
),
"avatar" | "jpegphoto" => UserFieldType::Attribute(
AttributeName::from("avatar"),
AttributeType::JpegPhoto,
false,
),
"creationdate" | "createtimestamp" | "modifytimestamp" | "creation_date" => {
UserFieldType::PrimaryField(UserColumn::CreationDate)
}
"entryuuid" | "uuid" => UserFieldType::PrimaryField(UserColumn::Uuid),
_ => schema
.get_schema()
.user_attributes
.get_attribute_type(field)
.map(|(t, is_list)| UserFieldType::Attribute(field.clone(), t, is_list))
.unwrap_or(UserFieldType::NoMatch),
}
}
pub enum GroupFieldType {
NoMatch,
GroupId,
DisplayName,
CreationDate,
ObjectClass,
Dn,
// Like Dn, but returned as part of the attributes.
EntryDn,
Member,
Uuid,
Attribute(AttributeName, AttributeType, bool),
}
pub fn map_group_field(field: &AttributeName, schema: &PublicSchema) -> GroupFieldType {
match field.as_str() {
"dn" | "distinguishedname" => GroupFieldType::Dn,
"entrydn" => GroupFieldType::EntryDn,
"objectclass" => GroupFieldType::ObjectClass,
"cn" | "displayname" | "uid" | "display_name" | "id" => GroupFieldType::DisplayName,
"creationdate" | "createtimestamp" | "modifytimestamp" | "creation_date" => {
GroupFieldType::CreationDate
}
"member" | "uniquemember" => GroupFieldType::Member,
"entryuuid" | "uuid" => GroupFieldType::Uuid,
"group_id" | "groupid" => GroupFieldType::GroupId,
_ => schema
.get_schema()
.group_attributes
.get_attribute_type(field)
.map(|(t, is_list)| GroupFieldType::Attribute(field.clone(), t, is_list))
.unwrap_or(GroupFieldType::NoMatch),
}
}
pub struct LdapInfo {
pub base_dn: Vec<(String, String)>,
pub base_dn_str: String,
pub ignored_user_attributes: Vec<AttributeName>,
pub ignored_group_attributes: Vec<AttributeName>,
}
pub fn get_custom_attribute(
attributes: &[Attribute],
attribute_name: &AttributeName,
) -> Option<Vec<Vec<u8>>> {
let convert_date = |date| {
chrono::Utc
.from_utc_datetime(date)
.to_rfc3339()
.into_bytes()
};
attributes
.iter()
.find(|a| &a.name == attribute_name)
.map(|attribute| match &attribute.value {
AttributeValue::String(Cardinality::Singleton(s)) => {
vec![s.clone().into_bytes()]
}
AttributeValue::String(Cardinality::Unbounded(l)) => {
l.iter().map(|s| s.clone().into_bytes()).collect()
}
AttributeValue::Integer(Cardinality::Singleton(i)) => {
// LDAP integers are encoded as strings.
vec![i.to_string().into_bytes()]
}
AttributeValue::Integer(Cardinality::Unbounded(l)) => l
.iter()
// LDAP integers are encoded as strings.
.map(|i| i.to_string().into_bytes())
.collect(),
AttributeValue::JpegPhoto(Cardinality::Singleton(p)) => {
vec![p.clone().into_bytes()]
}
AttributeValue::JpegPhoto(Cardinality::Unbounded(l)) => {
l.iter().map(|p| p.clone().into_bytes()).collect()
}
AttributeValue::DateTime(Cardinality::Singleton(dt)) => vec![convert_date(dt)],
AttributeValue::DateTime(Cardinality::Unbounded(l)) => {
l.iter().map(convert_date).collect()
}
})
}
#[derive(derive_more::From)]
pub struct ObjectClassList(Vec<LdapObjectClass>);
// See RFC4512 section 4.2.1 "objectClasses"
impl ObjectClassList {
pub fn format_for_ldap_schema_description(&self) -> String {
join(self.0.iter().map(|c| format!("'{c}'")), " ")
}
}
// See RFC4512 section 4.2 "Subschema Subentries"
// This struct holds all information on what attributes and objectclasses are present on the server.
// It can be used to 'index' a server using a LDAP subschema call.
pub struct LdapSchemaDescription {
base: PublicSchema,
user_object_classes: ObjectClassList,
group_object_classes: ObjectClassList,
}
impl LdapSchemaDescription {
pub fn from(schema: PublicSchema) -> Self {
let mut user_object_classes = get_default_user_object_classes();
user_object_classes.extend(schema.get_schema().extra_user_object_classes.clone());
let mut group_object_classes = get_default_group_object_classes();
group_object_classes.extend(schema.get_schema().extra_group_object_classes.clone());
Self {
base: schema,
user_object_classes: ObjectClassList(user_object_classes),
group_object_classes: ObjectClassList(group_object_classes),
}
}
fn schema(&self) -> &Schema {
self.base.get_schema()
}
pub fn user_object_classes(&self) -> &ObjectClassList {
&self.user_object_classes
}
pub fn group_object_classes(&self) -> &ObjectClassList {
&self.group_object_classes
}
pub fn required_user_attributes(&self) -> AttributeList {
let attributes = self
.schema()
.user_attributes
.attributes
.iter()
.filter(|a| REQUIRED_USER_ATTRIBUTES.contains(&a.name.as_str()))
.cloned()
.collect();
AttributeList { attributes }
}
pub fn optional_user_attributes(&self) -> AttributeList {
let attributes = self
.schema()
.user_attributes
.attributes
.iter()
.filter(|a| !REQUIRED_USER_ATTRIBUTES.contains(&a.name.as_str()))
.cloned()
.collect();
AttributeList { attributes }
}
pub fn required_group_attributes(&self) -> AttributeList {
let attributes = self
.schema()
.group_attributes
.attributes
.iter()
.filter(|a| REQUIRED_GROUP_ATTRIBUTES.contains(&a.name.as_str()))
.cloned()
.collect();
AttributeList { attributes }
}
pub fn optional_group_attributes(&self) -> AttributeList {
let attributes = self
.schema()
.group_attributes
.attributes
.iter()
.filter(|a| !REQUIRED_GROUP_ATTRIBUTES.contains(&a.name.as_str()))
.cloned()
.collect();
AttributeList { attributes }
}
// See RFC4512 section 4.2.2 "attributeTypes"
// Parameter 'index_offset' is an offset for the enumeration of this list of attributes,
// it has been preceeded by the list of hardcoded attributes.
pub fn formatted_attribute_list(
&self,
index_offset: usize,
exclude_attributes: Vec<&str>,
) -> Vec<Vec<u8>> {
let mut formatted_list: Vec<Vec<u8>> = Vec::new();
for (index, attribute) in self
.all_attributes()
.attributes
.into_iter()
.filter(|attr| !exclude_attributes.contains(&attr.name.as_str()))
.enumerate()
{
formatted_list.push(
format!(
"( 10.{} NAME '{}' DESC 'LLDAP: {}' SUP {:?} )",
(index + index_offset),
attribute.name,
if attribute.is_hardcoded {
"builtin attribute"
} else {
"custom attribute"
},
attribute.attribute_type
)
.into_bytes()
.to_vec(),
)
}
formatted_list
}
pub fn all_attributes(&self) -> AttributeList {
let mut combined_attributes = self.schema().user_attributes.attributes.clone();
combined_attributes.extend_from_slice(&self.schema().group_attributes.attributes);
AttributeList {
attributes: combined_attributes,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_subtree() {
let subtree1 = &[
("ou".to_string(), "people".to_string()),
("dc".to_string(), "example".to_string()),
("dc".to_string(), "com".to_string()),
];
let root = &[
("dc".to_string(), "example".to_string()),
("dc".to_string(), "com".to_string()),
];
assert!(is_subtree(subtree1, root));
assert!(!is_subtree(&[], root));
}
#[test]
fn test_parse_distinguished_name() {
let parsed_dn = &[
("ou".to_string(), "people".to_string()),
("dc".to_string(), "example".to_string()),
("dc".to_string(), "com".to_string()),
];
assert_eq!(
parse_distinguished_name("ou=people,dc=example,dc=com").expect("parsing failed"),
parsed_dn
);
assert_eq!(
parse_distinguished_name(" ou = people , dc = example , dc = com ")
.expect("parsing failed"),
parsed_dn
);
}
}
+264
View File
@@ -0,0 +1,264 @@
use crate::{
core::{
error::{LdapError, LdapResult},
utils::{LdapInfo, UserOrGroupName, get_user_or_group_id_from_distinguished_name},
},
handler::make_add_response,
};
use ldap3_proto::proto::{
LdapAddRequest, LdapAttribute, LdapOp, LdapPartialAttribute, LdapResultCode,
};
use lldap_access_control::AdminBackendHandler;
use lldap_domain::{
deserialize,
requests::{CreateGroupRequest, CreateUserRequest},
types::{Attribute, AttributeName, AttributeType, Email, GroupName, UserId},
};
use std::collections::HashMap;
use tracing::instrument;
#[instrument(skip_all, level = "debug")]
pub(crate) async fn create_user_or_group(
backend_handler: &impl AdminBackendHandler,
ldap_info: &LdapInfo,
request: LdapAddRequest,
) -> LdapResult<Vec<LdapOp>> {
let base_dn_str = &ldap_info.base_dn_str;
match get_user_or_group_id_from_distinguished_name(&request.dn, &ldap_info.base_dn) {
UserOrGroupName::User(user_id) => {
create_user(backend_handler, user_id, request.attributes).await
}
UserOrGroupName::Group(group_name) => {
create_group(backend_handler, group_name, request.attributes).await
}
err => Err(err.into_ldap_error(
&request.dn,
format!(r#""uid=id,ou=people,{base_dn_str}" or "uid=id,ou=groups,{base_dn_str}""#),
)),
}
}
#[instrument(skip_all, level = "debug")]
async fn create_user(
backend_handler: &impl AdminBackendHandler,
user_id: UserId,
attributes: Vec<LdapAttribute>,
) -> LdapResult<Vec<LdapOp>> {
fn parse_attribute(mut attr: LdapPartialAttribute) -> LdapResult<(String, Vec<u8>)> {
if attr.vals.len() > 1 {
Err(LdapError {
code: LdapResultCode::ConstraintViolation,
message: format!("Expected a single value for attribute {}", attr.atype),
})
} else {
attr.atype.make_ascii_lowercase();
match attr.vals.pop() {
Some(val) => Ok((attr.atype, val)),
None => Err(LdapError {
code: LdapResultCode::ConstraintViolation,
message: format!("Missing value for attribute {}", attr.atype),
}),
}
}
}
let attributes: HashMap<String, Vec<u8>> = attributes
.into_iter()
.filter(|a| !a.atype.eq_ignore_ascii_case("objectclass"))
.map(parse_attribute)
.collect::<LdapResult<_>>()?;
fn decode_attribute_value(val: &[u8]) -> LdapResult<String> {
std::str::from_utf8(val)
.map_err(|e| LdapError {
code: LdapResultCode::ConstraintViolation,
message: format!("Attribute value is invalid UTF-8: {e:#?} (value {val:?})"),
})
.map(str::to_owned)
}
let get_attribute = |name| {
attributes
.get(name)
.map(Vec::as_slice)
.map(decode_attribute_value)
};
let make_encoded_attribute = |name: &str, typ: AttributeType, value: String| {
Ok(Attribute {
name: AttributeName::from(name),
value: deserialize::deserialize_attribute_value(&[value], typ, false).map_err(|e| {
LdapError {
code: LdapResultCode::ConstraintViolation,
message: format!("Invalid attribute value: {e}"),
}
})?,
})
};
let mut new_user_attributes: Vec<Attribute> = Vec::new();
if let Some(first_name) = get_attribute("givenname").transpose()? {
new_user_attributes.push(make_encoded_attribute(
"first_name",
AttributeType::String,
first_name,
)?);
}
if let Some(last_name) = get_attribute("sn").transpose()? {
new_user_attributes.push(make_encoded_attribute(
"last_name",
AttributeType::String,
last_name,
)?);
}
if let Some(avatar) = get_attribute("avatar").transpose()? {
new_user_attributes.push(make_encoded_attribute(
"avatar",
AttributeType::JpegPhoto,
avatar,
)?);
}
backend_handler
.create_user(CreateUserRequest {
user_id,
email: Email::from(
get_attribute("mail")
.or_else(|| get_attribute("email"))
.transpose()?
.unwrap_or_default(),
),
display_name: get_attribute("cn").transpose()?,
attributes: new_user_attributes,
})
.await
.map_err(|e| LdapError {
code: LdapResultCode::OperationsError,
message: format!("Could not create user: {e:#?}"),
})?;
Ok(vec![make_add_response(
LdapResultCode::Success,
String::new(),
)])
}
#[instrument(skip_all, level = "debug")]
async fn create_group(
backend_handler: &impl AdminBackendHandler,
group_name: GroupName,
_attributes: Vec<LdapAttribute>,
) -> LdapResult<Vec<LdapOp>> {
backend_handler
.create_group(CreateGroupRequest {
display_name: group_name,
attributes: Vec::new(),
})
.await
.map_err(|e| LdapError {
code: LdapResultCode::OperationsError,
message: format!("Could not create group: {e:#?}"),
})?;
Ok(vec![make_add_response(
LdapResultCode::Success,
String::new(),
)])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::handler::tests::setup_bound_admin_handler;
use lldap_domain::types::*;
use lldap_test_utils::MockTestBackendHandler;
use mockall::predicate::eq;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn test_create_user() {
let mut mock = MockTestBackendHandler::new();
mock.expect_create_user()
.with(eq(CreateUserRequest {
user_id: UserId::new("bob"),
email: "".into(),
display_name: Some("Bob".to_string()),
..Default::default()
}))
.times(1)
.return_once(|_| Ok(()));
let ldap_handler = setup_bound_admin_handler(mock).await;
let request = LdapAddRequest {
dn: "uid=bob,ou=people,dc=example,dc=com".to_owned(),
attributes: vec![LdapPartialAttribute {
atype: "cn".to_owned(),
vals: vec![b"Bob".to_vec()],
}],
};
assert_eq!(
ldap_handler.create_user_or_group(request).await,
Ok(vec![make_add_response(
LdapResultCode::Success,
String::new()
)])
);
}
#[tokio::test]
async fn test_create_group() {
let mut mock = MockTestBackendHandler::new();
mock.expect_create_group()
.with(eq(CreateGroupRequest {
display_name: GroupName::new("bob"),
..Default::default()
}))
.times(1)
.return_once(|_| Ok(GroupId(5)));
let ldap_handler = setup_bound_admin_handler(mock).await;
let request = LdapAddRequest {
dn: "uid=bob,ou=groups,dc=example,dc=com".to_owned(),
attributes: vec![LdapPartialAttribute {
atype: "cn".to_owned(),
vals: vec![b"Bobby".to_vec()],
}],
};
assert_eq!(
ldap_handler.create_user_or_group(request).await,
Ok(vec![make_add_response(
LdapResultCode::Success,
String::new()
)])
);
}
#[tokio::test]
async fn test_create_user_multiple_object_class() {
let mut mock = MockTestBackendHandler::new();
mock.expect_create_user()
.with(eq(CreateUserRequest {
user_id: UserId::new("bob"),
email: "".into(),
display_name: Some("Bob".to_string()),
..Default::default()
}))
.times(1)
.return_once(|_| Ok(()));
let ldap_handler = setup_bound_admin_handler(mock).await;
let request = LdapAddRequest {
dn: "uid=bob,ou=people,dc=example,dc=com".to_owned(),
attributes: vec![
LdapPartialAttribute {
atype: "cn".to_owned(),
vals: vec![b"Bob".to_vec()],
},
LdapPartialAttribute {
atype: "objectClass".to_owned(),
vals: vec![
b"top".to_vec(),
b"person".to_vec(),
b"inetOrgPerson".to_vec(),
],
},
],
};
assert_eq!(
ldap_handler.create_user_or_group(request).await,
Ok(vec![make_add_response(
LdapResultCode::Success,
String::new()
)])
);
}
}
+303
View File
@@ -0,0 +1,303 @@
use crate::core::{
error::{LdapError, LdapResult},
utils::{LdapInfo, UserOrGroupName, get_user_or_group_id_from_distinguished_name},
};
use ldap3_proto::proto::{LdapOp, LdapResult as LdapResultOp, LdapResultCode};
use lldap_access_control::AdminBackendHandler;
use lldap_domain::types::{GroupName, UserId};
use lldap_domain_handlers::handler::GroupRequestFilter;
use lldap_domain_model::error::DomainError;
use tracing::instrument;
pub(crate) fn make_del_response(code: LdapResultCode, message: String) -> LdapOp {
LdapOp::DelResponse(LdapResultOp {
code,
matcheddn: "".to_string(),
message,
referral: vec![],
})
}
#[instrument(skip_all, level = "debug")]
pub(crate) async fn delete_user_or_group(
backend_handler: &impl AdminBackendHandler,
ldap_info: &LdapInfo,
request: String,
) -> LdapResult<Vec<LdapOp>> {
let base_dn_str = &ldap_info.base_dn_str;
match get_user_or_group_id_from_distinguished_name(&request, &ldap_info.base_dn) {
UserOrGroupName::User(user_id) => delete_user(backend_handler, user_id).await,
UserOrGroupName::Group(group_name) => delete_group(backend_handler, group_name).await,
err => Err(err.into_ldap_error(
&request,
format!(r#""uid=id,ou=people,{base_dn_str}" or "uid=id,ou=groups,{base_dn_str}""#),
)),
}
}
#[instrument(skip_all, level = "debug")]
async fn delete_user(
backend_handler: &impl AdminBackendHandler,
user_id: UserId,
) -> LdapResult<Vec<LdapOp>> {
backend_handler
.get_user_details(&user_id)
.await
.map_err(|err| match err {
DomainError::EntityNotFound(_) => LdapError {
code: LdapResultCode::NoSuchObject,
message: "Could not find user".to_string(),
},
e => LdapError {
code: LdapResultCode::OperationsError,
message: format!("Error while finding user: {e:?}"),
},
})?;
backend_handler
.delete_user(&user_id)
.await
.map_err(|e| LdapError {
code: LdapResultCode::OperationsError,
message: format!("Error while deleting user: {e:?}"),
})?;
Ok(vec![make_del_response(
LdapResultCode::Success,
String::new(),
)])
}
#[instrument(skip_all, level = "debug")]
async fn delete_group(
backend_handler: &impl AdminBackendHandler,
group_name: GroupName,
) -> LdapResult<Vec<LdapOp>> {
let groups = backend_handler
.list_groups(Some(GroupRequestFilter::DisplayName(group_name.clone())))
.await
.map_err(|e| LdapError {
code: LdapResultCode::OperationsError,
message: format!("Error while finding group: {e:?}"),
})?;
let group_id = groups
.iter()
.find(|g| g.display_name == group_name)
.map(|g| g.id)
.ok_or_else(|| LdapError {
code: LdapResultCode::NoSuchObject,
message: "Could not find group".to_string(),
})?;
backend_handler
.delete_group(group_id)
.await
.map_err(|e| LdapError {
code: LdapResultCode::OperationsError,
message: format!("Error while deleting group: {e:?}"),
})?;
Ok(vec![make_del_response(
LdapResultCode::Success,
String::new(),
)])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::handler::tests::setup_bound_admin_handler;
use chrono::TimeZone;
use lldap_domain::{
types::{Group, GroupId, User},
uuid,
};
use lldap_domain_model::error::DomainError;
use lldap_test_utils::MockTestBackendHandler;
use mockall::predicate::eq;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn test_delete_user() {
let mut mock = MockTestBackendHandler::new();
mock.expect_get_user_details()
.with(eq(UserId::new("bob")))
.return_once(|_| {
Ok(User {
user_id: UserId::new("bob"),
..Default::default()
})
});
mock.expect_delete_user()
.with(eq(UserId::new("bob")))
.times(1)
.return_once(|_| Ok(()));
let mut ldap_handler = setup_bound_admin_handler(mock).await;
let request = LdapOp::DelRequest("uid=bob,ou=people,dc=example,dc=com".to_owned());
assert_eq!(
ldap_handler.handle_ldap_message(request).await,
Some(vec![make_del_response(
LdapResultCode::Success,
String::new()
)])
);
}
#[tokio::test]
async fn test_delete_group() {
let mut mock = MockTestBackendHandler::new();
mock.expect_list_groups()
.with(eq(Some(GroupRequestFilter::DisplayName(GroupName::from(
"bob",
)))))
.return_once(|_| {
Ok(vec![Group {
id: GroupId(34),
display_name: GroupName::from("bob"),
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
users: Vec::new(),
attributes: Vec::new(),
}])
});
mock.expect_delete_group()
.with(eq(GroupId(34)))
.times(1)
.return_once(|_| Ok(()));
let mut ldap_handler = setup_bound_admin_handler(mock).await;
let request = LdapOp::DelRequest("uid=bob,ou=groups,dc=example,dc=com".to_owned());
assert_eq!(
ldap_handler.handle_ldap_message(request).await,
Some(vec![make_del_response(
LdapResultCode::Success,
String::new()
)])
);
}
#[tokio::test]
async fn test_delete_user_not_found() {
let mut mock = MockTestBackendHandler::new();
mock.expect_get_user_details()
.with(eq(UserId::new("bob")))
.return_once(|_| Err(DomainError::EntityNotFound("No such user".to_string())));
let mut ldap_handler = setup_bound_admin_handler(mock).await;
let request = LdapOp::DelRequest("uid=bob,ou=people,dc=example,dc=com".to_owned());
assert_eq!(
ldap_handler.handle_ldap_message(request).await,
Some(vec![make_del_response(
LdapResultCode::NoSuchObject,
"Could not find user".to_string()
)])
);
}
#[tokio::test]
async fn test_delete_user_lookup_error() {
let mut mock = MockTestBackendHandler::new();
mock.expect_get_user_details()
.with(eq(UserId::new("bob")))
.return_once(|_| Err(DomainError::InternalError("WTF?".to_string())));
let mut ldap_handler = setup_bound_admin_handler(mock).await;
let request = LdapOp::DelRequest("uid=bob,ou=people,dc=example,dc=com".to_owned());
assert_eq!(
ldap_handler.handle_ldap_message(request).await,
Some(vec![make_del_response(
LdapResultCode::OperationsError,
r#"Error while finding user: InternalError("WTF?")"#.to_string()
)])
);
}
#[tokio::test]
async fn test_delete_user_deletion_error() {
let mut mock = MockTestBackendHandler::new();
mock.expect_get_user_details()
.with(eq(UserId::new("bob")))
.return_once(|_| {
Ok(User {
user_id: UserId::new("bob"),
..Default::default()
})
});
mock.expect_delete_user()
.with(eq(UserId::new("bob")))
.times(1)
.return_once(|_| Err(DomainError::InternalError("WTF?".to_string())));
let mut ldap_handler = setup_bound_admin_handler(mock).await;
let request = LdapOp::DelRequest("uid=bob,ou=people,dc=example,dc=com".to_owned());
assert_eq!(
ldap_handler.handle_ldap_message(request).await,
Some(vec![make_del_response(
LdapResultCode::OperationsError,
r#"Error while deleting user: InternalError("WTF?")"#.to_string()
)])
);
}
#[tokio::test]
async fn test_delete_group_not_found() {
let mut mock = MockTestBackendHandler::new();
mock.expect_list_groups()
.with(eq(Some(GroupRequestFilter::DisplayName(GroupName::from(
"bob",
)))))
.return_once(|_| Ok(vec![]));
let mut ldap_handler = setup_bound_admin_handler(mock).await;
let request = LdapOp::DelRequest("uid=bob,ou=groups,dc=example,dc=com".to_owned());
assert_eq!(
ldap_handler.handle_ldap_message(request).await,
Some(vec![make_del_response(
LdapResultCode::NoSuchObject,
"Could not find group".to_string()
)])
);
}
#[tokio::test]
async fn test_delete_group_lookup_error() {
let mut mock = MockTestBackendHandler::new();
mock.expect_list_groups()
.with(eq(Some(GroupRequestFilter::DisplayName(GroupName::from(
"bob",
)))))
.return_once(|_| Err(DomainError::InternalError("WTF?".to_string())));
let mut ldap_handler = setup_bound_admin_handler(mock).await;
let request = LdapOp::DelRequest("uid=bob,ou=groups,dc=example,dc=com".to_owned());
assert_eq!(
ldap_handler.handle_ldap_message(request).await,
Some(vec![make_del_response(
LdapResultCode::OperationsError,
r#"Error while finding group: InternalError("WTF?")"#.to_string()
)])
);
}
#[tokio::test]
async fn test_delete_group_deletion_error() {
let mut mock = MockTestBackendHandler::new();
mock.expect_list_groups()
.with(eq(Some(GroupRequestFilter::DisplayName(GroupName::from(
"bob",
)))))
.return_once(|_| {
Ok(vec![Group {
id: GroupId(34),
display_name: GroupName::from("bob"),
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
users: Vec::new(),
attributes: Vec::new(),
}])
});
mock.expect_delete_group()
.with(eq(GroupId(34)))
.times(1)
.return_once(|_| Err(DomainError::InternalError("WTF?".to_string())));
let mut ldap_handler = setup_bound_admin_handler(mock).await;
let request = LdapOp::DelRequest("uid=bob,ou=groups,dc=example,dc=com".to_owned());
assert_eq!(
ldap_handler.handle_ldap_message(request).await,
Some(vec![make_del_response(
LdapResultCode::OperationsError,
r#"Error while deleting group: InternalError("WTF?")"#.to_string()
)])
);
}
}
+459
View File
@@ -0,0 +1,459 @@
use crate::{
compare,
core::{
error::{LdapError, LdapResult},
utils::{LdapInfo, parse_distinguished_name},
},
create, delete, modify,
password::{self, do_password_modification},
search::{
self, is_root_dse_request, is_subschema_entry_request, make_ldap_subschema_entry,
make_search_error, make_search_request, make_search_success, root_dse_response,
},
};
use ldap3_proto::proto::{
LdapAddRequest, LdapBindRequest, LdapBindResponse, LdapCompareRequest, LdapExtendedRequest,
LdapExtendedResponse, LdapFilter, LdapModifyRequest, LdapOp, LdapPasswordModifyRequest,
LdapResult as LdapResultOp, LdapResultCode, LdapSearchRequest, OID_PASSWORD_MODIFY, OID_WHOAMI,
};
use lldap_access_control::AccessControlledBackendHandler;
use lldap_auth::access_control::ValidationResults;
use lldap_domain::{public_schema::PublicSchema, types::AttributeName};
use lldap_domain_handlers::handler::{BackendHandler, LoginHandler, ReadSchemaBackendHandler};
use lldap_opaque_handler::OpaqueHandler;
use tracing::{debug, instrument};
use super::delete::make_del_response;
pub(crate) fn make_add_response(code: LdapResultCode, message: String) -> LdapOp {
LdapOp::AddResponse(LdapResultOp {
code,
matcheddn: "".to_string(),
message,
referral: vec![],
})
}
pub(crate) fn make_extended_response(code: LdapResultCode, message: String) -> LdapOp {
LdapOp::ExtendedResponse(LdapExtendedResponse {
res: LdapResultOp {
code,
matcheddn: "".to_string(),
message,
referral: vec![],
},
name: None,
value: None,
})
}
pub(crate) fn make_modify_response(code: LdapResultCode, message: String) -> LdapOp {
LdapOp::ModifyResponse(LdapResultOp {
code,
matcheddn: "".to_string(),
message,
referral: vec![],
})
}
pub struct LdapHandler<Backend> {
user_info: Option<ValidationResults>,
backend_handler: AccessControlledBackendHandler<Backend>,
ldap_info: LdapInfo,
session_uuid: uuid::Uuid,
}
impl<Backend> LdapHandler<Backend> {
pub fn session_uuid(&self) -> &uuid::Uuid {
&self.session_uuid
}
}
impl<Backend: LoginHandler> LdapHandler<Backend> {
pub fn get_login_handler(&self) -> &(impl LoginHandler + use<Backend>) {
self.backend_handler.unsafe_get_handler()
}
}
impl<Backend: OpaqueHandler> LdapHandler<Backend> {
pub fn get_opaque_handler(&self) -> &(impl OpaqueHandler + use<Backend>) {
self.backend_handler.unsafe_get_handler()
}
}
enum Credentials<'s> {
Bound(&'s ValidationResults),
Unbound(Vec<LdapOp>),
}
impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend> {
pub fn new(
backend_handler: AccessControlledBackendHandler<Backend>,
mut ldap_base_dn: String,
ignored_user_attributes: Vec<AttributeName>,
ignored_group_attributes: Vec<AttributeName>,
session_uuid: uuid::Uuid,
) -> Self {
ldap_base_dn.make_ascii_lowercase();
Self {
user_info: None,
backend_handler,
ldap_info: LdapInfo {
base_dn: parse_distinguished_name(&ldap_base_dn).unwrap_or_else(|_| {
panic!("Invalid value for ldap_base_dn in configuration: {ldap_base_dn}")
}),
base_dn_str: ldap_base_dn,
ignored_user_attributes,
ignored_group_attributes,
},
session_uuid,
}
}
#[cfg(test)]
pub fn new_for_tests(backend_handler: Backend, ldap_base_dn: &str) -> Self {
Self::new(
AccessControlledBackendHandler::new(backend_handler),
ldap_base_dn.to_string(),
vec![],
vec![],
uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
)
}
fn get_credentials(&self) -> Credentials<'_> {
match self.user_info.as_ref() {
Some(user_info) => Credentials::Bound(user_info),
None => Credentials::Unbound(vec![make_extended_response(
LdapResultCode::InsufficentAccessRights,
"No user currently bound".to_string(),
)]),
}
}
pub async fn do_search_or_dse(&self, request: &LdapSearchRequest) -> LdapResult<Vec<LdapOp>> {
if is_root_dse_request(request) {
debug!("rootDSE request");
return Ok(vec![
root_dse_response(&self.ldap_info.base_dn_str),
make_search_success(),
]);
} else if is_subschema_entry_request(request) {
// See RFC4512 section 4.4 "Subschema discovery"
debug!("Schema request");
let backend_handler = self
.user_info
.as_ref()
.and_then(|u| self.backend_handler.get_schema_only_handler(u))
.ok_or_else(|| LdapError {
code: LdapResultCode::InsufficentAccessRights,
message: "No user currently bound".to_string(),
})?;
let schema = backend_handler.get_schema().await.map_err(|e| LdapError {
code: LdapResultCode::OperationsError,
message: format!("Unable to get schema: {e:#}"),
})?;
return Ok(vec![
make_ldap_subschema_entry(PublicSchema::from(schema)),
make_search_success(),
]);
}
self.do_search(request).await
}
#[instrument(skip_all, level = "debug")]
async fn do_search(&self, request: &LdapSearchRequest) -> LdapResult<Vec<LdapOp>> {
let user_info = self.user_info.as_ref().ok_or_else(|| LdapError {
code: LdapResultCode::InsufficentAccessRights,
message: "No user currently bound".to_string(),
})?;
let backend_handler = self
.backend_handler
.get_user_restricted_lister_handler(user_info);
search::do_search(&backend_handler, &self.ldap_info, request).await
}
#[instrument(skip_all, level = "debug", fields(dn = %request.dn))]
pub async fn do_bind(&mut self, request: &LdapBindRequest) -> Vec<LdapOp> {
let (code, message) =
match password::do_bind(&self.ldap_info, request, self.get_login_handler()).await {
Ok(user_id) => {
self.user_info = self
.backend_handler
.get_permissions_for_user(user_id)
.await
.ok();
debug!("Success!");
(LdapResultCode::Success, "".to_string())
}
Err(err) => (err.code, err.message),
};
vec![LdapOp::BindResponse(LdapBindResponse {
res: LdapResultOp {
code,
matcheddn: "".to_string(),
message,
referral: vec![],
},
saslcreds: None,
})]
}
#[instrument(skip_all, level = "debug")]
async fn do_extended_request(&self, request: &LdapExtendedRequest) -> Vec<LdapOp> {
match request.name.as_str() {
OID_PASSWORD_MODIFY => match LdapPasswordModifyRequest::try_from(request) {
Ok(password_request) => {
let credentials = match self.get_credentials() {
Credentials::Bound(cred) => cred,
Credentials::Unbound(err) => return err,
};
do_password_modification(
credentials,
&self.ldap_info,
&self.backend_handler,
self.get_opaque_handler(),
&password_request,
)
.await
.unwrap_or_else(|e: LdapError| vec![make_extended_response(e.code, e.message)])
}
Err(e) => vec![make_extended_response(
LdapResultCode::ProtocolError,
format!("Error while parsing password modify request: {e:#?}"),
)],
},
OID_WHOAMI => {
let authz_id = self
.user_info
.as_ref()
.map(|user_info| {
format!(
"dn:uid={},ou=people,{}",
user_info.user.as_str(),
self.ldap_info.base_dn_str
)
})
.unwrap_or_default();
vec![make_extended_response(LdapResultCode::Success, authz_id)]
}
_ => vec![make_extended_response(
LdapResultCode::UnwillingToPerform,
format!("Unsupported extended operation: {}", &request.name),
)],
}
}
#[instrument(skip_all, level = "debug", fields(dn = %request.dn))]
pub async fn do_modify_request(&self, request: &LdapModifyRequest) -> Vec<LdapOp> {
let credentials = match self.get_credentials() {
Credentials::Bound(cred) => cred,
Credentials::Unbound(err) => return err,
};
modify::handle_modify_request(
self.get_opaque_handler(),
|credentials, user_id| {
self.backend_handler
.get_readable_handler(credentials, &user_id)
},
&self.ldap_info,
credentials,
request,
)
.await
.unwrap_or_else(|e: LdapError| vec![make_modify_response(e.code, e.message)])
}
#[instrument(skip_all, level = "debug")]
pub async fn create_user_or_group(&self, request: LdapAddRequest) -> LdapResult<Vec<LdapOp>> {
let backend_handler = self
.user_info
.as_ref()
.and_then(|u| self.backend_handler.get_admin_handler(u))
.ok_or_else(|| LdapError {
code: LdapResultCode::InsufficentAccessRights,
message: "Unauthorized write".to_string(),
})?;
create::create_user_or_group(backend_handler, &self.ldap_info, request).await
}
#[instrument(skip_all, level = "debug")]
pub async fn delete_user_or_group(&self, request: String) -> LdapResult<Vec<LdapOp>> {
let backend_handler = self
.user_info
.as_ref()
.and_then(|u| self.backend_handler.get_admin_handler(u))
.ok_or_else(|| LdapError {
code: LdapResultCode::InsufficentAccessRights,
message: "Unauthorized write".to_string(),
})?;
delete::delete_user_or_group(backend_handler, &self.ldap_info, request).await
}
#[instrument(skip_all, level = "debug")]
pub async fn do_compare(&self, request: LdapCompareRequest) -> LdapResult<Vec<LdapOp>> {
let req = make_search_request::<String>(
&self.ldap_info.base_dn_str,
LdapFilter::Equality("dn".to_string(), request.dn.to_string()),
vec![request.atype.clone()],
);
compare::compare(
request,
self.do_search(&req).await?,
&self.ldap_info.base_dn_str,
)
}
pub async fn handle_ldap_message(&mut self, ldap_op: LdapOp) -> Option<Vec<LdapOp>> {
Some(match ldap_op {
LdapOp::BindRequest(request) => self.do_bind(&request).await,
LdapOp::SearchRequest(request) => self
.do_search_or_dse(&request)
.await
.unwrap_or_else(|e: LdapError| vec![make_search_error(e.code, e.message)]),
LdapOp::UnbindRequest => {
debug!(
"Unbind request for {}",
self.user_info
.as_ref()
.map(|u| u.user.as_str())
.unwrap_or("<not bound>"),
);
self.user_info = None;
// No need to notify on unbind (per rfc4511)
return None;
}
LdapOp::ModifyRequest(request) => self.do_modify_request(&request).await,
LdapOp::ExtendedRequest(request) => self.do_extended_request(&request).await,
LdapOp::AddRequest(request) => self
.create_user_or_group(request)
.await
.unwrap_or_else(|e: LdapError| vec![make_add_response(e.code, e.message)]),
LdapOp::DelRequest(request) => self
.delete_user_or_group(request)
.await
.unwrap_or_else(|e: LdapError| vec![make_del_response(e.code, e.message)]),
LdapOp::CompareRequest(request) => self
.do_compare(request)
.await
.unwrap_or_else(|e: LdapError| vec![make_search_error(e.code, e.message)]),
op => vec![make_extended_response(
LdapResultCode::UnwillingToPerform,
format!("Unsupported operation: {op:#?}"),
)],
})
}
}
#[cfg(test)]
pub mod tests {
use super::*;
use crate::password::tests::make_bind_success;
use chrono::TimeZone;
use ldap3_proto::proto::{LdapBindCred, LdapWhoamiRequest};
use lldap_domain::{
types::{GroupDetails, GroupId, UserId},
uuid,
};
use lldap_domain_handlers::handler::*;
use lldap_test_utils::{MockTestBackendHandler, setup_default_schema};
use mockall::predicate::eq;
use pretty_assertions::assert_eq;
use std::collections::HashSet;
use tokio;
pub fn make_user_search_request<S: Into<String>>(
filter: LdapFilter,
attrs: Vec<S>,
) -> LdapSearchRequest {
make_search_request::<S>("ou=people,Dc=example,dc=com", filter, attrs)
}
pub fn make_group_search_request<S: Into<String>>(
filter: LdapFilter,
attrs: Vec<S>,
) -> LdapSearchRequest {
make_search_request::<S>("ou=groups,dc=example,dc=com", filter, attrs)
}
pub async fn setup_bound_handler_with_group(
mut mock: MockTestBackendHandler,
group: &str,
) -> LdapHandler<MockTestBackendHandler> {
mock.expect_bind()
.with(eq(BindRequest {
name: UserId::new("test"),
password: "pass".to_string(),
}))
.return_once(|_| Ok(()));
let group = group.to_string();
mock.expect_get_user_groups()
.with(eq(UserId::new("test")))
.return_once(|_| {
let mut set = HashSet::new();
set.insert(GroupDetails {
group_id: GroupId(42),
display_name: group.into(),
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
attributes: Vec::new(),
});
Ok(set)
});
setup_default_schema(&mut mock);
let mut ldap_handler = LdapHandler::new_for_tests(mock, "dc=Example,dc=com");
let request = LdapBindRequest {
dn: "uid=test,ou=people,dc=example,dc=coM".to_string(),
cred: LdapBindCred::Simple("pass".to_string()),
};
assert_eq!(ldap_handler.do_bind(&request).await, make_bind_success());
ldap_handler
}
pub async fn setup_bound_readonly_handler(
mock: MockTestBackendHandler,
) -> LdapHandler<MockTestBackendHandler> {
setup_bound_handler_with_group(mock, "lldap_strict_readonly").await
}
pub async fn setup_bound_password_manager_handler(
mock: MockTestBackendHandler,
) -> LdapHandler<MockTestBackendHandler> {
setup_bound_handler_with_group(mock, "lldap_password_manager").await
}
pub async fn setup_bound_admin_handler(
mock: MockTestBackendHandler,
) -> LdapHandler<MockTestBackendHandler> {
setup_bound_handler_with_group(mock, "lldap_admin").await
}
#[tokio::test]
async fn test_whoami_empty() {
let mut ldap_handler =
LdapHandler::new_for_tests(MockTestBackendHandler::new(), "dc=example,dc=com");
let request = LdapOp::ExtendedRequest(LdapWhoamiRequest {}.into());
assert_eq!(
ldap_handler.handle_ldap_message(request).await,
Some(vec![make_extended_response(
LdapResultCode::Success,
"".to_string(),
)])
);
}
#[tokio::test]
async fn test_whoami_bound() {
let mock = MockTestBackendHandler::new();
let mut ldap_handler = setup_bound_admin_handler(mock).await;
let request = LdapOp::ExtendedRequest(LdapWhoamiRequest {}.into());
assert_eq!(
ldap_handler.handle_ldap_message(request).await,
Some(vec![make_extended_response(
LdapResultCode::Success,
"dn:uid=test,ou=people,dc=example,dc=com".to_string(),
)])
);
}
}

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