Compare commits

...

184 Commits

Author SHA1 Message Date
Federico Scodelaro 9fb252759a chore: Better example config 2026-03-24 07:55:28 +01:00
Federico Scodelaro 3a26d2ec4c Update example_configs/stalwart.md
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-24 07:55:28 +01:00
Federico Scodelaro 86d9ea10d6 docs(stalwart): Add alias example 2026-03-24 07:55:28 +01:00
dependabot[bot] 2ad634deda build(deps): bump docker/setup-qemu-action from 3 to 4
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-04 22:13:24 +01:00
dependabot[bot] 155bda6bbf build(deps): bump actions/download-artifact from 7 to 8
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 7 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v7...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-27 22:01:45 +01:00
dependabot[bot] 7d1593e266 build(deps): bump actions/upload-artifact from 6 to 7
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-27 18:48:41 +01:00
Sertonix 8c8df11250 cargo: Update wasm-bindgen and lettre 2026-02-24 14:17:17 +01:00
Santi Gonzalez aa1384939b example_config: fix HA for blank displayName 2026-02-19 00:15:12 +01:00
lyzstrik 6f94134fdc refactor(server): migrate to rustls 0.23 and centralize TLS logic (#1389)
This commit upgrades the TLS stack to Rustls 0.23

Key changes:
- Dependencies: Updated 'rustls' (v0.23), 'tokio-rustls' (v0.26), and 'actix-web' (v4.12.1).
- Build Fix: Configured 'rustls' to use the 'ring' provider (disabling default 'aws-lc-rs') to ensure ARMv7 compatibility.
- Refactor: Created 'server/src/tls.rs' to handle certificate loading (DRY).
- LDAP: Updated 'ldap_server.rs' to use the new TLS module and Rustls APIs.
- Healthcheck: Updated 'healthcheck.rs' to use Rustls 0.23 types.
2026-01-31 09:47:11 +01:00
Valentin Tolmer d1904a2759 readme: Add a link to TrueNAS installation guide 2026-01-31 09:42:12 +01:00
Asher Densmore-Lynn 02d92c3261 example_configs: Add Apache WebDAV 2026-01-31 09:41:53 +01:00
Michael Reid 48058540ec example_configs: Installing and Configuring LLDAP on TrueNAS 2026-01-31 09:36:18 +01:00
Copilot 618e3f3062 Fix cn attribute case-insensitive matching in LDAP equality filters (#1363) 2026-01-31 09:34:10 +01:00
jakob42 cafd3732f0 example_configs: add Continuwuity 2026-01-23 13:51:26 +01:00
dependabot[bot] 8588d4b851 build(deps): bump actions/checkout from 6.0.1 to 6.0.2
Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.1 to 6.0.2.
- [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/v6.0.1...v6.0.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-22 21:28:51 +01:00
Hobbabobba 2f70e2e31f example_configs: add Semaphore 2026-01-13 19:09:36 +01:00
lwintermelon a9d04b6bdf example_config: Add gerrit 2026-01-09 08:02:06 +01:00
Osi Bluber c03f3b5498 docs(bootstrap): add password_file for user configs 2026-01-07 18:45:32 +01:00
Copilot ac55dfedc4 app: Remove password length validation from login form 2026-01-06 23:37:01 +01:00
josef 62ae1d73fa app: asterisk for mail attribute when creating a user 2025-12-24 22:53:17 +01:00
Valentin Tolmer 469f35c12c cargo: Update dependencies 2025-12-24 15:33:30 +01:00
dependabot[bot] ee9fec71a5 build(deps): bump actions/upload-artifact from 4 to 6
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-12 23:23:32 +01:00
dependabot[bot] 9cbb0c99e2 build(deps): bump actions/download-artifact from 6 to 7
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-12 23:00:34 +01:00
dependabot[bot] 81e985df48 build(deps): bump actions/cache from 4 to 5
Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-12 23:00:12 +01:00
Robert Cambridge a136a68bf4 example_configs: add sample group query to Grafana guide 2025-12-04 18:09:08 +01:00
dependabot[bot] 8f0022a9f1 build(deps): bump actions/checkout from 6.0.0 to 6.0.1
Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.0 to 6.0.1.
- [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/v6.0.0...v6.0.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-02 22:28:44 +01:00
dependabot[bot] fc7b33e4b3 build(deps): bump actions/checkout from 5.0.1 to 6.0.0
Bumps [actions/checkout](https://github.com/actions/checkout) from 5.0.1 to 6.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/v5.0.1...v6.0.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-21 00:42:49 +01:00
dependabot[bot] a9b5147a30 build(deps): bump actions/download-artifact from 4 to 6
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 6.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-17 23:42:22 +01:00
dependabot[bot] 4de069452f build(deps): bump actions/checkout from 5.0.0 to 5.0.1
Bumps [actions/checkout](https://github.com/actions/checkout) from 5.0.0 to 5.0.1.
- [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/v5.0.0...v5.0.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-17 22:47:42 +01:00
copilot-swe-agent[bot] e5c28a61d9 ldap: Fix LDAP base scope search to return NoSuchObject for non-existent entries
Added logic to return LdapResultCode::NoSuchObject (error 32) when a base scope
search returns no results, instead of returning Success with zero entries. This
aligns with RFC 4511 LDAP specification.
2025-11-16 15:20:06 +01:00
Valentin Tolmer c5e0441cae clippy: remove unnecessary elided lifetimes 2025-11-16 15:03:52 +01:00
Shawn Wilsher a959a50e07 server: allow specifying the healthcheck addresses
This change adds two new optional configuration options:
- `ldap_healthcheck_host` to pair with `ldap_host`
- `http_healthcheck_host` to pair with `http_host`

These both default to `localhost` to preserve the existing behavior.

Fixes #700
2025-11-16 15:03:40 +01:00
Tobias Jungel ab4389fc5f fix(bootstrap): set shopt nullglob
Set the `nullglob` option in the bootstrap script to handle cases where
no files match a glob pattern.

This prevents the following error when the folder exists without json
files:

```
/bootstrap/group-configs/*.json: jq: error: Could not open file /bootstrap/group-configs/*.json: No such file or directory
```
2025-11-09 22:35:50 +01:00
Tobias Jungel ddcbe383ab docs: Rename 'mail_alias' to 'mail-alias' in example config (#1346)
The example included an invalid character `_` for the attribute `name`

This resulted in:

```
Cannot create attribute with invalid name. Valid characters: a-z, A-Z, 0-9, and dash (-). Invalid chars found: _
```

This fixes the example by using a `-`.
2025-11-09 12:07:44 +01:00
Sören eee42502f3 docs: fix example_configs path
from ./example_configs to ../example_configs
2025-10-21 15:42:06 +02:00
thchha 660301eb5f example_configs: add initial gogs.md documentation
Gogs is the origin for common git forges so we add a documentation which
may be beneficial for other use cases where lldap should be used with.
It appears to be in mantenance mode - the current example may have to be
extended in the future.

We adapt the official documentation example configuration to integrate
lldap with the more elaborated example.
The reader may also be interested in a more simple example at
[upstream](https://github.com/gogs/gogs/blob/main/conf/auth.d/ldap_simple_auth.conf.example).
2025-10-21 00:07:46 +02:00
Nassim Bounouas 73f071ce89 docs: lldap password in docker install corrected 2025-10-18 12:44:59 +02:00
Copilot 28ef6e0c56 example_configs: mailserver,
fix outdated roundcube mounts and filters
2025-10-18 12:20:29 +02:00
Shawn Wilsher a32c8baa25 misc: improve vscode devcontainer experience
This change enables a better IDE experience in vscode by doing two
things:
1) Enables the rust-analyzer, which enables a bunch of features in
   vscode
2) Installs the needed deps for `cargo fmt` to work.
2025-10-14 11:54:48 +02:00
Copilot bf5b76269f server: Refactor config_overrides to use Option::inspect
To reduce cyclomatic complexity.
2025-10-12 20:14:20 +02:00
Hendrik Sievers c09e5c451c example_configs: update SSSD guide 2025-10-11 08:39:25 +02:00
Valentin Tolmer 1382c67de9 server: Extract configuration utilities 2025-10-10 23:28:35 +02:00
Copilot 0f8f9e1244 server: split up update_user_with_transaction 2025-10-10 09:01:52 +02:00
Webysther Sperandio 9a83e68667 app: Set a key for user/group creation buttons
That prevents them from jumping around when changing pages.
2025-10-10 00:28:11 +02:00
Copilot 3f9880ec11 server: Move LDAP search tests to their respective implementation files
Move user and group tests to their respective implementation files

User tests → core/user.rs:
- test_search_regular_user
- test_search_readonly_user
- test_search_member_of
- test_search_user_as_scope
- test_search_users
- test_pwd_changed_time_format

Group tests → core/group.rs:
- test_search_groups
- test_search_groups_by_groupid
- test_search_groups_filter
- test_search_groups_filter_2
- test_search_groups_filter_3
- test_search_group_as_scope

Tests remain in search.rs:
- DSE/schema tests
- General search logic tests
- Filter tests
- Error handling tests
- OU search tests
- Mixed user/group tests
2025-10-10 00:21:32 +02:00
Valentin Tolmer 94007aee58 readme: Add a link to the configuration guide's readme 2025-10-04 23:24:46 +02:00
Copilot 9e9d8e2ab5 graphql: split query.rs and mutation.rs into modular structures (#1311) 2025-10-04 23:09:36 +02:00
Lucas Sylvester 18edd4eb7d example_configs: update portainer group membership and filter attributes
The current descriptions is wrong, and will make portainer try to assign "group" to be a member of "group" instead of the assign the "user" to be a part of "group"
2025-10-04 22:16:00 +02:00
Jonas Resch 3cdf2241ea example_configs: Improve bootstrap.sh and documentation for use with Kubernetes (#1245) 2025-09-28 14:02:06 +02:00
thchha 9021066507 example_configs: Add configuration example for Open WebUI
This documents a working (LDAPS) configuration for using lldap in Open WebUI.

Environment Variables where directly taken from the logs.
The names of the GUI variables are taken from the UI.
Version v0.6.26.

The two configuration options are then put in a table and a small
elaboration + example values are provided.

Other then additionally mounting the ca chain into the container (with appropriate rights) there were not additional steps required.
The ownership of the ca chain will get changed to `chown 501:`.
2025-09-28 13:55:29 +02:00
Copilot fe063272bf chore: add Nix flake-based development environment
Co-authored-by: Kumpelinus <kumpelinus@jat.de>

- Add Nix flake and lockfile for reproducible development environments
- Document Nix-based setup in `docs/nix-development.md`
- Add `.envrc` for direnv integration and update `.gitignore` for Nix/direnv artifacts
- Reference Nix setup in CONTRIBUTING.md
2025-09-28 13:51:41 +02:00
RealSpinelle 59dee0115d example_configs: add missing fields to authentik example 2025-09-24 16:03:56 +02:00
Valentin Tolmer 622274cb1a chore: fix codecov config 2025-09-22 09:34:37 +02:00
Valentin Tolmer 4bad3a9e69 chore: reduce codecov verbosity 2025-09-22 01:01:00 +02:00
Copilot 84fb9b0fd2 Fix pwdChangedTime format to use LDAP GeneralizedTime instead of RFC3339 (#1300)
When querying for pwdChangedTime, the timestamp is returned in RFC3339 format instead of the expected LDAP GeneralizedTime format (YYYYMMDDHHMMSSZ). This causes issues when LLDAP is used with systems like Keycloak that expect proper LDAP timestamp formatting.
2025-09-22 00:42:51 +02:00
Valentin Tolmer 8a803bfb11 ldap: normalize base DN in LdapInfo, reduce memory usage
By making it a &'static, we can have a single allocation for all the threads/async contexts.

This also normalizes the whitespace from the user input; a trailing \n can cause weird issues with clients
2025-09-17 01:03:19 +02:00
Valentin Tolmer f7fe0c6ea0 ldap: fix swapped filter conditions 2025-09-16 14:58:46 +02:00
Valentin Tolmer 8f04843466 ldap: Simplify boolean expressions derived from filters 2025-09-16 01:58:41 +02:00
Hobbabobba 400beafb29 example_config: Add pocket-id 2025-09-16 01:40:08 +02:00
dependabot[bot] 963e58bf1a build(deps): bump tracing-subscriber from 0.3.18 to 0.3.20
Bumps [tracing-subscriber](https://github.com/tokio-rs/tracing) from 0.3.18 to 0.3.20.
- [Release notes](https://github.com/tokio-rs/tracing/releases)
- [Commits](https://github.com/tokio-rs/tracing/compare/tracing-subscriber-0.3.18...tracing-subscriber-0.3.20)

---
updated-dependencies:
- dependency-name: tracing-subscriber
  dependency-version: 0.3.20
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-16 01:10:06 +02:00
Kumpelinus 176c49c78d chore: upgrade Rust toolchain to 1.89 and modernize code with let-chains 2025-09-16 00:48:16 +02:00
Copilot 3d5542996f chore: Add CodeRabbit configuration to reduce agent verbosity 2025-09-16 00:12:45 +02:00
psentee 4590463cdf auth: serialize exp and iat claims as NumericDate to comply with RFC7519 (#1289)
Add `jti` claim to the JWT to avoid hashing collisions
2025-09-15 17:24:59 +02:00
lordratner 85ce481e32 Update opnsense.md
Added instruction for using/not using Constraint Groups. This option is selected by default and the current instructions do not address it, but if it is left on and the Authentication Containers are not updated, the group sync will fail.
2025-09-14 15:53:05 +02:00
Valentin Tolmer f64f8625f1 Add username to password recovey emails 2025-09-14 15:44:37 +02:00
Alexandre Foley c68f9e7cab example_configs: fix the quadlet readme
Several "podman" command should have been "systemctl" from the start.
2025-09-04 22:23:12 +02:00
Copilot 775c5c716d server: gracefully shut down database connection pool 2025-09-04 09:19:03 +02:00
Kumpelinus 89cb59919b server: Add modifyTimestamp and pwdChangedTime attributes (#1265)
Add a modifyTimestamp attribute to LDAP entries for users and groups, and expose pwdChangedTime for users.
These attributes let clients track when an entry (or its password) was last changed.

 -  modifyTimestamp is a server-maintained attribute that updates on any write to user or group entries, including membership changes (on the group side).

 -  pwdChangedTime is set when a user’s password is created or changed.
2025-08-31 14:56:07 +02:00
Valentin Tolmer 267f08f479 github: Remove CODEOWNERS 2025-08-21 22:11:35 +02:00
copilot-swe-agent[bot] b370360130 Add memberOf attribute definition to LDAP schema 2025-08-21 22:07:02 +02:00
Valentin Tolmer 7438fe92cf github: pin the CI rust version to 1.85.0 2025-08-21 02:24:05 +02:00
copilot-swe-agent[bot] cd2694d7dc Add comprehensive GitHub Copilot instructions for LLDAP repository
Add copilot-setup-steps.yml for GitHub Copilot agent environment setup
2025-08-21 01:22:31 +02:00
Valentin Tolmer 5e83ed8eb0 release: v0.6.2 2025-08-18 00:06:44 +02:00
Kirill Zhuravlev c69957690e docs: avoid bad-sounding words in secrets example 2025-08-17 23:10:45 +02:00
Linus Astel 7ef2af8beb devcontainer: Bump Rust version 2025-08-14 22:38:45 +02:00
Toby 5c9897b156 ldap: Add missing subschema entries 2025-08-14 16:04:28 +02:00
ibizaman 0b720aa082 bootstrap: fine grained cleanup 2025-08-13 09:36:21 +02:00
dependabot[bot] 3e7277e77d build(deps): bump actions/checkout from 4.2.2 to 5.0.0
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.2.2 to 5.0.0.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4.2.2...v5.0.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-12 08:02:59 +02:00
ibizaman 5241626a3a bootstrap: make password_file a standard custom attribute
Otherwise the bootstrap script tries to create the password_file
as a custom attribute which fails since it's not in the schema.
And anyway, it shouldn't be in the schema.
2025-08-06 22:13:22 +02:00
Valentin Tolmer 363ef106e2 app: Fix attribute type parsing 2025-07-30 01:02:47 +02:00
ibizaman 3c7e4c3dec bootstrap: do not leak password in process list 2025-07-22 08:51:35 +02:00
Valentin Tolmer fa196a9fd9 docker: try several GPG server
Sometimes the docker build fails because the gpg server is intermittently unavailable
2025-07-22 01:10:25 +02:00
ibizaman f02b365478 bootstrap: do not fail if no user or group defined 2025-07-21 23:35:49 +02:00
Valentin Tolmer 0b0e6ae2cd github: Fix warnings about Dockerfile syntax 2025-07-21 23:23:37 +02:00
Valentin Tolmer da525fc99b app: simplify attribute_type handling, display creation time in user details
In the user table it's still only the date, but that makes sense for an overview
2025-07-21 23:15:46 +02:00
ibizaman 78337bce72 bootstrap: allow to give password from a file 2025-07-16 23:51:21 +02:00
selfhoster1312 87e9311a44 meta: Fix cargo clippy failures (format strings) 2025-07-16 23:23:08 +02:00
Hendrik Sievers 53e62ecf5a docs: move authelia configuration to markdown file (#1205) 2025-07-13 22:29:09 +02:00
core 10d33a7537 readme: fix broken Iink 2025-07-11 00:52:03 +02:00
copilot-swe-agent[bot] ada438398e set-password: load system certificates
Fixes #1206
2025-07-08 22:46:13 +02:00
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
234 changed files with 16656 additions and 9568 deletions
+46
View File
@@ -0,0 +1,46 @@
# docs: https://docs.coderabbit.ai/reference/yaml-template for full configuration options
tone_instructions: "Be concise"
reviews:
profile: "chill"
high_level_summary: false
review_status: false
commit_status: false
collapse_walkthrough: true
changed_files_summary: false
sequence_diagrams: false
estimate_code_review_effort: false
assess_linked_issues: false
related_issues: false
related_prs: false
suggested_labels: false
suggested_reviewers: false
poem: false
auto_review:
enabled: true
auto_incremental_review: true
finishing_touches:
docstrings:
enabled: false
unit_tests:
enabled: false
pre_merge_checks:
docstrings:
mode: "off"
title:
mode: "off"
description:
mode: "off"
issue_assessment:
mode: "off"
chat:
art: false
auto_reply: false
knowledge_base:
web_search:
enabled: true
code_guidelines:
enabled: false
+1 -1
View File
@@ -1,4 +1,4 @@
FROM rust:1.74
FROM rust:1.89
ARG USERNAME=lldapdev
# We need to keep the user as 1001 to match the GitHub runner's UID.
+20 -2
View File
@@ -1,8 +1,26 @@
{
"name": "LLDAP dev",
"build": { "dockerfile": "Dockerfile" },
"build": {
"dockerfile": "Dockerfile"
},
"customizations": {
"vscode": {
"extensions": [
"rust-lang.rust-analyzer"
],
"settings": {
"rust-analyzer.linkedProjects": [
"./Cargo.toml"
]
}
}
},
"features": {
"ghcr.io/devcontainers/features/rust:1": {}
},
"forwardPorts": [
3890,
17170
]
],
"remoteUser": "lldapdev"
}
+1
View File
@@ -0,0 +1 @@
use flake
-1
View File
@@ -1 +0,0 @@
* @nitnelave
+5 -8
View File
@@ -1,19 +1,16 @@
codecov:
require_ci_to_pass: yes
comment:
layout: "header,diff,files"
require_changes: true
require_base: true
require_head: true
layout: "condensed_header, diff, condensed_files"
hide_project_coverage: true
require_changes: "coverage_drop"
coverage:
range: "70...100"
status:
project:
default:
target: "75%"
threshold: "0.1%"
removed_code_behavior: adjust_base
github_checks:
annotations: true
threshold: 5
ignore:
- "app"
- "docs"
+159
View File
@@ -0,0 +1,159 @@
# LLDAP - Light LDAP implementation for authentication
LLDAP is a lightweight LDAP authentication server written in Rust with a WebAssembly frontend. It provides an opinionated, simplified LDAP interface for authentication and integrates with many popular services.
**ALWAYS reference these instructions first and fallback to search or bash commands only when you encounter unexpected information that does not match the info here.**
## Working Effectively
### Bootstrap and Build the Repository
- Install dependencies: `sudo apt-get update && sudo apt-get install -y curl gzip binaryen`
- Install Rust if not available: `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` then `source ~/.cargo/env`
- Install wasm-pack for frontend: `cargo install wasm-pack` -- takes 90 seconds. NEVER CANCEL. Set timeout to 180+ seconds.
- Build entire workspace: `cargo build --workspace` -- takes 3-4 minutes. NEVER CANCEL. Set timeout to 300+ seconds.
- Build release server binary: `cargo build --release -p lldap` -- takes 5-6 minutes. NEVER CANCEL. Set timeout to 420+ seconds.
- Build frontend WASM: `./app/build.sh` -- takes 3-4 minutes including wasm-pack installation. NEVER CANCEL. Set timeout to 300+ seconds.
### Testing and Validation
- Run all tests: `cargo test --workspace` -- takes 2-3 minutes. NEVER CANCEL. Set timeout to 240+ seconds.
- Check formatting: `cargo fmt --all --check` -- takes <5 seconds.
- Run linting: `cargo clippy --tests --all -- -D warnings` -- takes 60-90 seconds. NEVER CANCEL. Set timeout to 120+ seconds.
- Export GraphQL schema: `./export_schema.sh` -- takes 70-80 seconds. NEVER CANCEL. Set timeout to 120+ seconds.
### Running the Application
- **ALWAYS run the build steps first before starting the server.**
- Start development server: `cargo run -- run --config-file <config_file>`
- **CRITICAL**: Server requires a valid configuration file. Use `lldap_config.docker_template.toml` as reference.
- **CRITICAL**: Avoid key conflicts by removing existing `server_key*` files when testing with `key_seed` in config.
- Server binds to:
- LDAP: port 3890 (configurable)
- Web interface: port 17170 (configurable)
- LDAPS: port 6360 (optional, disabled by default)
### Manual Validation Requirements
- **ALWAYS test both LDAP and web interfaces after making changes.**
- Test web interface: `curl -s http://localhost:17170/` should return HTML with "LLDAP Administration" title.
- Test GraphQL API: `curl -s -X POST -H "Content-Type: application/json" -d '{"query": "query { __schema { queryType { name } } }"}' http://localhost:17170/api/graphql`
- Run healthcheck: `cargo run -- healthcheck --config-file <config_file>` (requires running server)
- **ALWAYS ensure server starts without errors and serves the web interface before considering changes complete.**
## Validation Scenarios
After making code changes, ALWAYS:
1. **Build validation**: Run `cargo build --workspace` to ensure compilation succeeds.
2. **Test validation**: Run `cargo test --workspace` to ensure existing functionality works.
3. **Lint validation**: Run `cargo clippy --tests --all -- -D warnings` to catch potential issues.
4. **Format validation**: Run `cargo fmt --all --check` to ensure code style compliance.
5. **Frontend validation**: Run `./app/build.sh` to ensure WASM compilation succeeds.
6. **Runtime validation**: Start the server and verify web interface accessibility.
7. **Schema validation**: If GraphQL changes made, run `./export_schema.sh` to update schema.
### Test User Scenarios
- **Login flow**: Access web interface at `http://localhost:17170`, attempt login with admin/password (default).
- **LDAP binding**: Test LDAP connection on port 3890 with appropriate LDAP tools if available.
- **Configuration changes**: Test with different configuration files to validate config parsing.
## Project Structure and Key Components
### Backend (Rust)
- **Server**: `/server` - Main application binary
- **Crates**: `/crates/*` - Modularized components:
- `auth` - Authentication and OPAQUE protocol
- `domain*` - Domain models and handlers
- `ldap` - LDAP protocol implementation
- `graphql-server` - GraphQL API server
- `sql-backend-handler` - Database operations
- `validation` - Input validation utilities
### Frontend (Rust + WASM)
- **App**: `/app` - Yew-based WebAssembly frontend
- **Build**: `./app/build.sh` - Compiles Rust to WASM using wasm-pack
- **Assets**: `/app/static` - Static web assets
### Configuration and Deployment
- **Config template**: `lldap_config.docker_template.toml` - Reference configuration
- **Docker**: `Dockerfile` - Container build definition
- **Scripts**:
- `prepare-release.sh` - Cross-platform release builds
- `export_schema.sh` - GraphQL schema export
- `generate_secrets.sh` - Random secret generation
- `scripts/bootstrap.sh` - User/group management script
## Common Development Workflows
### Making Backend Changes
1. Edit Rust code in `/server` or `/crates`
2. Run `cargo build --workspace` to test compilation
3. Run `cargo test --workspace` to ensure tests pass
4. Run `cargo clippy --tests --all -- -D warnings` to check for warnings
5. If GraphQL schema affected, run `./export_schema.sh`
6. Test by running server and validating functionality
### Making Frontend Changes
1. Edit code in `/app/src`
2. Run `./app/build.sh` to rebuild WASM package
3. Start server and test web interface functionality
4. Verify no JavaScript errors in browser console
### Adding New Dependencies
- Backend: Add to appropriate `Cargo.toml` in `/server` or `/crates/*`
- Frontend: Add to `/app/Cargo.toml`
- **Always rebuild after dependency changes**
## CI/CD Integration
The repository uses GitHub Actions (`.github/workflows/rust.yml`):
- **Build job**: Validates workspace compilation
- **Test job**: Runs full test suite
- **Clippy job**: Linting with warnings as errors
- **Format job**: Code formatting validation
- **Coverage job**: Code coverage analysis
**ALWAYS ensure your changes pass all CI checks by running equivalent commands locally.**
## Timing Expectations and Timeouts
| Command | Expected Time | Timeout Setting |
|---------|---------------|-----------------|
| `cargo build --workspace` | 3-4 minutes | 300+ seconds |
| `cargo build --release -p lldap` | 5-6 minutes | 420+ seconds |
| `cargo test --workspace` | 2-3 minutes | 240+ seconds |
| `./app/build.sh` | 3-4 minutes | 300+ seconds |
| `cargo clippy --tests --all -- -D warnings` | 60-90 seconds | 120+ seconds |
| `./export_schema.sh` | 70-80 seconds | 120+ seconds |
| `cargo install wasm-pack` | 90 seconds | 180+ seconds |
**NEVER CANCEL** any of these commands. Builds may take longer on slower systems.
## Troubleshooting Common Issues
### Build Issues
- **Missing wasm-pack**: Run `cargo install wasm-pack`
- **Missing binaryen**: Run `sudo apt-get install -y binaryen` or disable wasm-opt
- **Clippy warnings**: Fix all warnings as they are treated as errors in CI
- **GraphQL schema mismatch**: Run `./export_schema.sh` to update schema
### Runtime Issues
- **Key conflicts**: Remove `server_key*` files when using `key_seed` in config
- **Port conflicts**: Check if ports 3890/17170 are available
- **Database issues**: Ensure database URL in config is valid and accessible
- **Asset missing**: Ensure frontend is built with `./app/build.sh`
### Development Environment
- **Rust version**: Use stable Rust toolchain (2024 edition)
- **System dependencies**: curl, gzip, build tools
- **Database**: SQLite (default), MySQL, or PostgreSQL supported
## Configuration Reference
Essential configuration parameters:
- `ldap_base_dn`: LDAP base DN (e.g., "dc=example,dc=com")
- `ldap_user_dn`: Admin user DN
- `ldap_user_pass`: Admin password
- `jwt_secret`: Secret for JWT tokens (generate with `./generate_secrets.sh`)
- `key_seed`: Encryption key seed
- `database_url`: Database connection string
- `http_port`: Web interface port (default: 17170)
- `ldap_port`: LDAP server port (default: 3890)
**Always use the provided config template as starting point for new configurations.**
+26
View File
@@ -0,0 +1,26 @@
name: Copilot Setup Steps for LLDAP Development
steps:
- name: Update package list
run: sudo apt-get update
- name: Install system dependencies
run: sudo apt-get install -y curl gzip binaryen build-essential
- name: Install Rust toolchain
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source ~/.cargo/env
echo 'source ~/.cargo/env' >> ~/.bashrc
- name: Install wasm-pack for frontend builds
run: |
source ~/.cargo/env
cargo install wasm-pack
- name: Verify installations
run: |
source ~/.cargo/env
rustc --version
cargo --version
wasm-pack --version
+13 -2
View File
@@ -1,6 +1,6 @@
FROM localhost:5000/lldap/lldap:alpine-base
# 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.89-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"
+67 -39
View File
@@ -24,7 +24,7 @@ on:
env:
CARGO_TERM_COLOR: always
MSRV: "1.89.0"
### CI Docs
@@ -84,11 +84,17 @@ 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/cache@v4
uses: actions/checkout@v6.0.2
- name: Install Rust
id: toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: "${{ env.MSRV }}"
targets: "wasm32-unknown-unknown"
- uses: actions/cache@v5
with:
path: |
/usr/local/cargo/bin
@@ -99,8 +105,6 @@ jobs:
key: lldap-ui-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
lldap-ui-
- name: Add wasm target (rust)
run: rustup target add wasm32-unknown-unknown
- name: Install wasm-pack with cargo
run: cargo install wasm-pack || true
env:
@@ -110,7 +114,7 @@ jobs:
- name: Check build path
run: ls -al app/
- name: Upload ui artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: ui
path: app/
@@ -125,15 +129,21 @@ 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/cache@v4
uses: actions/checkout@v6.0.2
- name: Install Rust
id: toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: "${{ env.MSRV }}"
targets: "${{ matrix.target }}"
- uses: actions/cache@v5
with:
path: |
.cargo/bin
@@ -149,17 +159,17 @@ jobs:
- name: Check path
run: ls -al target/release
- name: Upload ${{ matrix.target}} lldap artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.target}}-lldap-bin
path: target/${{ matrix.target }}/release/lldap
- name: Upload ${{ matrix.target }} migration tool artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.target }}-lldap_migration_tool-bin
path: target/${{ matrix.target }}/release/lldap_migration_tool
- name: Upload ${{ matrix.target }} password tool artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.target }}-lldap_set_password-bin
path: target/${{ matrix.target }}/release/lldap_set_password
@@ -199,7 +209,7 @@ jobs:
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: x86_64-unknown-linux-musl-lldap-bin
path: bin/
@@ -216,6 +226,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 +239,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 +252,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,18 +310,18 @@ jobs:
steps:
- name: Checkout scripts
uses: actions/checkout@v4.2.2
uses: actions/checkout@v6.0.2
with:
sparse-checkout: 'scripts'
- name: Download LLDAP artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: x86_64-unknown-linux-musl-lldap-bin
path: bin/
- name: Download LLDAP set password
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: x86_64-unknown-linux-musl-lldap_set_password-bin
path: bin/
@@ -324,9 +340,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 +366,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 +387,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 +406,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 +424,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 +435,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 +446,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,21 +506,21 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4.2.2
uses: actions/checkout@v6.0.2
- name: Download all artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
path: bin
- name: Download llap ui artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: ui
path: web
- name: Setup QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Setup buildx
uses: docker/setup-buildx-action@v3
with:
@@ -667,7 +691,7 @@ jobs:
contents: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
path: bin/
- name: Check file
@@ -688,7 +712,7 @@ jobs:
chmod +x bin/*-lldap_set_password
- name: Download llap ui artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: ui
path: web
@@ -738,5 +762,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 }}
+27 -21
View File
@@ -8,6 +8,7 @@ on:
env:
CARGO_TERM_COLOR: always
MSRV: "1.89.0"
jobs:
pre_job:
@@ -33,14 +34,19 @@ jobs:
steps:
- name: Checkout sources
uses: actions/checkout@v4.2.2
uses: actions/checkout@v6.0.2
- name: Install Rust
id: toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: "${{ env.MSRV }}"
- uses: Swatinem/rust-cache@v2
- name: Build
run: cargo build --verbose --workspace
run: cargo +${{steps.toolchain.outputs.name}} build --verbose --workspace
- name: Run tests
run: cargo test --verbose --workspace
run: cargo +${{steps.toolchain.outputs.name}} test --verbose --workspace
- name: Generate GraphQL schema
run: cargo run -- export_graphql_schema -o generated_schema.graphql
run: cargo +${{steps.toolchain.outputs.name}} run -- export_graphql_schema -o generated_schema.graphql
- name: Check schema
run: diff schema.graphql generated_schema.graphql || (echo "The schema file is out of date. Please run `./export_schema.sh`" && false)
@@ -52,15 +58,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v4.2.2
- uses: Swatinem/rust-cache@v2
- name: Run cargo clippy
uses: actions-rs/cargo@v1
uses: actions/checkout@v6.0.2
- name: Install Rust
id: toolchain
uses: dtolnay/rust-toolchain@master
with:
command: clippy
args: --tests --all -- -D warnings
toolchain: "${{ env.MSRV }}"
components: clippy
- uses: Swatinem/rust-cache@v2
- run: cargo +${{steps.toolchain.outputs.name}} clippy --tests --workspace -- -D warnings
format:
name: cargo fmt
@@ -69,15 +75,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v4.2.2
- uses: Swatinem/rust-cache@v2
- name: Run cargo fmt
uses: actions-rs/cargo@v1
uses: actions/checkout@v6.0.2
- name: Install Rust
id: toolchain
uses: dtolnay/rust-toolchain@master
with:
command: fmt
args: --all -- --check
toolchain: "${{ env.MSRV }}"
components: rustfmt
- uses: Swatinem/rust-cache@v2
- run: cargo +${{steps.toolchain.outputs.name}} fmt --check --all
coverage:
name: Code coverage
@@ -88,7 +94,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v4.2.2
uses: actions/checkout@v6.0.2
- 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
+5
View File
@@ -29,3 +29,8 @@ recipe.json
lldap_config.toml
cert.pem
key.pem
# Nix
result
result-*
.direnv
+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.
+3 -1
View File
@@ -46,7 +46,9 @@ advanced guides (scripting, migrations, ...) you can contribute to.
### Code
If you don't know what to start with, check out the
[good first issues](https://github.com/lldap/lldap/labels/good%20first%20issue).
[good first issues](https://github.com/lldap/lldap/labels/good%20first%20issue).
For an alternative development environment setup, see [docs/nix-development.md](docs/nix-development.md).
Otherwise, if you want to fix a specific bug or implement a feature, make sure
to start by creating an issue for it (if it doesn't already exist). There, we
Generated
+1939 -908
View File
File diff suppressed because it is too large Load Diff
+20 -9
View File
@@ -1,21 +1,32 @@
[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"
rust-version = "1.89.0"
[profile.release]
lto = true
[profile.release.package.lldap_app]
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), [TrueNAS](docs/install.md#truenas-scale), or from [a regular distribution package manager](docs/install.md/#from-a-package-repository) (Archlinux, Debian, CentOS, Fedora, OpenSuse, Ubuntu, FreeBSD).
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/README.md)
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
+33 -10
View File
@@ -1,13 +1,14 @@
[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
rust-version.workspace = true
[dependencies]
anyhow = "1"
@@ -19,12 +20,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 +56,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 +94,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]
+46 -34
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,23 @@ impl App {
AppRoute::CreateUser => html! {
<CreateUserForm/>
},
AppRoute::Index | AppRoute::ListUsers => html! {
<div>
<UserTable />
<Link classes="btn btn-primary" to={AppRoute::CreateUser}>
<i class="bi-person-plus me-2"></i>
{"Create a user"}
</Link>
</div>
},
AppRoute::Index | AppRoute::ListUsers => {
let user_button = |key| {
html! {
<Link classes="btn btn-primary" key={key} to={AppRoute::CreateUser}>
<i class="bi-person-plus me-2"></i>
{"Create a user"}
</Link>
}
};
html! {
<div>
{ user_button("top-create-user") }
<UserTable />
{ user_button("bottom-create-user") }
</div>
}
}
AppRoute::CreateGroup => html! {
<CreateGroupForm/>
},
@@ -218,15 +222,23 @@ impl App {
AppRoute::CreateGroupAttribute => html! {
<CreateGroupAttributeForm/>
},
AppRoute::ListGroups => html! {
<div>
<GroupTable />
<Link classes="btn btn-primary" to={AppRoute::CreateGroup}>
<i class="bi-plus-circle me-2"></i>
{"Create a group"}
</Link>
</div>
},
AppRoute::ListGroups => {
let group_button = |key| {
html! {
<Link classes="btn btn-primary" key={key} to={AppRoute::CreateGroup}>
<i class="bi-plus-circle me-2"></i>
{"Create a group"}
</Link>
}
};
html! {
<div>
{ group_button("top-create-group") }
<GroupTable />
{ group_button("bottom-create-group") }
</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>
+11 -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 {
@@ -306,18 +304,22 @@ impl Component for CreateUserForm {
}
fn get_custom_attribute_input(attribute_schema: &Attribute) -> Html {
let mail_is_required = attribute_schema.name.as_str() == "mail";
if attribute_schema.is_list {
html! {
<ListAttributeInput
name={attribute_schema.name.clone()}
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
attribute_type={attribute_schema.attribute_type}
required={mail_is_required}
/>
}
} else {
html! {
<SingleAttributeInput
name={attribute_schema.name.clone()}
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
attribute_type={attribute_schema.attribute_type}
required={mail_is_required}
/>
}
}
+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>
+20 -12
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()} />
}
};
}
};
@@ -45,6 +45,8 @@ fn attribute_input(props: &AttributeInputProps) -> Html {
#[derive(Properties, PartialEq)]
struct AttributeLabelProps {
pub name: String,
#[prop_or(false)]
pub required: bool,
}
#[function_component(AttributeLabel)]
fn attribute_label(props: &AttributeLabelProps) -> Html {
@@ -66,7 +68,9 @@ fn attribute_label(props: &AttributeLabelProps) -> Html {
<label for={props.name.clone()}
class="form-label col-4 col-form-label"
>
{props.name[0..1].to_uppercase() + &props.name[1..].replace('_', " ")}{":"}
{props.name[0..1].to_uppercase() + &props.name[1..].replace('_', " ")}
{if props.required { html!{<span class="text-danger">{"*"}</span>} } else { html!{} }}
{":"}
<button
class="btn btn-sm btn-link"
type="button"
@@ -82,19 +86,21 @@ 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>,
#[prop_or(false)]
pub required: bool,
}
#[function_component(SingleAttributeInput)]
pub fn single_attribute_input(props: &SingleAttributeInputProps) -> Html {
html! {
<div class="row mb-3">
<AttributeLabel name={props.name.clone()} />
<AttributeLabel name={props.name.clone()} required={props.required} />
<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,9 +111,11 @@ 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>,
#[prop_or(false)]
pub required: bool,
}
pub enum ListAttributeInputMsg {
@@ -160,12 +168,12 @@ impl Component for ListAttributeInput {
let link = &ctx.link();
html! {
<div class="row mb-3">
<AttributeLabel name={props.name.clone()} />
<AttributeLabel name={props.name.clone()} required={props.required} />
<div class="col-8">
{self.indices.iter().map(|&i| html! {
<div class="input-group mb-2" key={i}>
<AttributeInput
attribute_type={props.attribute_type.clone()}
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)]
+14 -16
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;
@@ -147,20 +147,18 @@ impl Component for JpegFileInput {
true
}
Msg::FileLoaded(file_name, data) => {
if let Some(avatar) = &mut self.avatar {
if let Some(file) = &avatar.file {
if file.name() == file_name {
if let Result::Ok(data) = data {
if !is_valid_jpeg(data.as_slice()) {
// Clear the selection.
self.avatar = Some(JsFile::default());
// TODO: bail!("Chosen image is not a valid JPEG");
} else {
avatar.contents = Some(data);
return true;
}
}
}
if let Some(avatar) = &mut self.avatar
&& let Some(file) = &avatar.file
&& file.name() == file_name
&& let Result::Ok(data) = data
{
if !is_valid_jpeg(data.as_slice()) {
// Clear the selection.
self.avatar = Some(JsFile::default());
// TODO: bail!("Chosen image is not a valid JPEG");
} else {
avatar.contents = Some(data);
return true;
}
}
self.reader = None;
+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>
{
+2 -2
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;
@@ -27,7 +27,7 @@ pub struct LoginForm {
pub struct FormModel {
#[validate(length(min = 1, message = "Missing username"))]
username: String,
#[validate(length(min = 8, message = "Invalid password. Min length: 8"))]
#[validate(length(min = 1, message = "Missing password"))]
password: String,
}
+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)
}
}
+128
View File
@@ -0,0 +1,128 @@
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"],
}),
"modified_date" => Some(AttributeDescription {
attribute_identifier: name,
attribute_name: "modifydate",
aliases: vec![name, "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"],
}),
"modified_date" => Some(AttributeDescription {
attribute_identifier: name,
attribute_name: "modifydate",
aliases: vec![name, "modifytimestamp"],
}),
"password_modified_date" => Some(AttributeDescription {
attribute_identifier: name,
attribute_name: "passwordmodifydate",
aliases: vec![name, "pwdchangedtime"],
}),
"display_name" => Some(AttributeDescription {
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);
}
}
+2 -1
View File
@@ -2,11 +2,12 @@
#![forbid(non_ascii_idents)]
#![allow(clippy::uninlined_format_args)]
#![allow(clippy::let_unit_value)]
#![allow(clippy::unnecessary_operation)] // Doesn't work well with the html macro.
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> {
+27
View File
@@ -0,0 +1,27 @@
[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
rust-version.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
}
}
+15 -11
View File
@@ -1,12 +1,13 @@
[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
rust-version.workspace = true
[features]
default = ["opaque_server", "opaque_client"]
@@ -14,16 +15,17 @@ 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"
uuid = { version = "1.18.1", features = ["serde"] }
[dependencies.derive_more]
features = ["debug", "display"]
@@ -38,11 +40,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
}
}
@@ -4,7 +4,9 @@ use chrono::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fmt;
use uuid::Uuid;
pub mod access_control;
pub mod opaque;
/// The messages for the 3-step OPAQUE and simple login process.
@@ -207,8 +209,11 @@ pub mod types {
#[derive(Clone, Serialize, Deserialize)]
pub struct JWTClaims {
#[serde(with = "chrono::serde::ts_seconds")]
pub exp: DateTime<Utc>,
#[serde(with = "chrono::serde::ts_seconds")]
pub iat: DateTime<Utc>,
pub jti: Uuid,
pub user: String,
pub groups: HashSet<String>,
}
@@ -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::*;
+47
View File
@@ -0,0 +1,47 @@
[package]
name = "lldap_domain_handlers"
version = "0.1.0"
edition.workspace = true
authors.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
rust-version.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;
+49
View File
@@ -0,0 +1,49 @@
[package]
name = "lldap_domain_model"
version = "0.1.0"
edition.workspace = true
authors.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
rust-version.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")]
@@ -14,6 +14,7 @@ pub struct Model {
pub lowercase_display_name: String,
pub creation_date: chrono::NaiveDateTime,
pub uuid: Uuid,
pub modified_date: chrono::NaiveDateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
@@ -30,7 +31,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,
@@ -39,11 +40,12 @@ impl From<Model> for crate::domain::types::Group {
uuid: group.uuid,
users: vec![],
attributes: Vec::new(),
modified_date: group.modified_date,
}
}
}
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,
@@ -51,6 +53,7 @@ impl From<Model> for crate::domain::types::GroupDetails {
creation_date: group.creation_date,
uuid: group.uuid,
attributes: Vec::new(),
modified_date: group.modified_date,
}
}
}
@@ -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;
@@ -21,6 +21,8 @@ pub struct Model {
pub totp_secret: Option<String>,
pub mfa_type: Option<String>,
pub uuid: Uuid,
pub modified_date: chrono::NaiveDateTime,
pub password_modified_date: chrono::NaiveDateTime,
}
impl EntityName for Entity {
@@ -40,6 +42,8 @@ pub enum Column {
TotpSecret,
MfaType,
Uuid,
ModifiedDate,
PasswordModifiedDate,
}
impl ColumnTrait for Column {
@@ -47,15 +51,17 @@ 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)),
Column::ModifiedDate => ColumnType::DateTime,
Column::PasswordModifiedDate => ColumnType::DateTime,
}
.def()
}
@@ -112,7 +118,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,
@@ -121,6 +127,8 @@ impl From<Model> for crate::domain::types::User {
creation_date: user.creation_date,
uuid: user.uuid,
attributes: Vec::new(),
modified_date: user.modified_date,
password_modified_date: user.password_modified_date,
}
}
}
+65
View File
@@ -0,0 +1,65 @@
[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
rust-version.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(&[
@@ -54,6 +34,24 @@ impl From<Schema> for PublicSchema {
is_hardcoded: true,
is_readonly: true,
},
AttributeSchema {
name: "modified_date".into(),
attribute_type: AttributeType::DateTime,
is_list: false,
is_visible: true,
is_editable: false,
is_hardcoded: true,
is_readonly: true,
},
AttributeSchema {
name: "password_modified_date".into(),
attribute_type: AttributeType::DateTime,
is_list: false,
is_visible: true,
is_editable: false,
is_hardcoded: true,
is_readonly: true,
},
AttributeSchema {
name: "mail".into(),
attribute_type: AttributeType::String,
@@ -105,6 +103,15 @@ impl From<Schema> for PublicSchema {
is_hardcoded: true,
is_readonly: true,
},
AttributeSchema {
name: "modified_date".into(),
attribute_type: AttributeType::DateTime,
is_list: false,
is_visible: true,
is_editable: false,
is_hardcoded: true,
is_readonly: true,
},
AttributeSchema {
name: "uuid".into(),
attribute_type: AttributeType::String,
+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, 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| {
@@ -392,12 +415,6 @@ impl JpegPhoto {
}
}
impl Nullable for JpegPhoto {
fn null() -> Value {
JpegPhoto::null().into()
}
}
impl IntoActiveValue<Serialized> for JpegPhoto {
fn into_active_value(self) -> sea_orm::ActiveValue<Serialized> {
if self.is_empty() {
@@ -408,10 +425,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 +539,12 @@ pub struct User {
pub display_name: Option<String>,
pub creation_date: NaiveDateTime,
pub uuid: Uuid,
pub attributes: Vec<AttributeValue>,
pub attributes: Vec<Attribute>,
pub modified_date: NaiveDateTime,
pub password_modified_date: NaiveDateTime,
}
#[cfg(test)]
#[cfg(feature = "test")]
impl Default for User {
fn default() -> Self {
let epoch = chrono::Utc.timestamp_opt(0, 0).unwrap().naive_utc();
@@ -435,6 +555,8 @@ impl Default for User {
creation_date: epoch,
uuid: Uuid::from_name_and_date("", &epoch),
attributes: Vec::new(),
modified_date: epoch,
password_modified_date: epoch,
}
}
}
@@ -518,7 +640,7 @@ impl ValueType for AttributeType {
}
fn column_type() -> ColumnType {
ColumnType::String(Some(64))
ColumnType::String(StringLen::N(64))
}
}
@@ -529,7 +651,8 @@ pub struct Group {
pub creation_date: NaiveDateTime,
pub uuid: Uuid,
pub users: Vec<UserId>,
pub attributes: Vec<AttributeValue>,
pub attributes: Vec<Attribute>,
pub modified_date: NaiveDateTime,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -538,7 +661,8 @@ pub struct GroupDetails {
pub display_name: GroupName,
pub creation_date: NaiveDateTime,
pub uuid: Uuid,
pub attributes: Vec<AttributeValue>,
pub attributes: Vec<Attribute>,
pub modified_date: NaiveDateTime,
}
#[derive(Debug, Clone, PartialEq, Eq)]
+13
View File
@@ -0,0 +1,13 @@
[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
rust-version.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,
}
+76
View File
@@ -0,0 +1,76 @@
[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
rust-version.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(())
}
@@ -0,0 +1,160 @@
use anyhow::{Context as AnyhowContext, anyhow};
use juniper::FieldResult;
use lldap_access_control::{AdminBackendHandler, ReadonlyBackendHandler};
use lldap_domain::{
deserialize::deserialize_attribute_value,
public_schema::PublicSchema,
requests::CreateGroupRequest,
schema::AttributeList,
types::{Attribute as DomainAttribute, AttributeName, Email},
};
use lldap_domain_handlers::handler::{BackendHandler, ReadSchemaBackendHandler};
use std::{collections::BTreeMap, sync::Arc};
use tracing::{Instrument, Span};
use super::inputs::AttributeValue;
use crate::api::{Context, field_error_callback};
pub struct UnpackedAttributes {
pub email: Option<Email>,
pub display_name: Option<String>,
pub attributes: Vec<DomainAttribute>,
}
pub fn unpack_attributes(
attributes: Vec<AttributeValue>,
schema: &PublicSchema,
is_admin: bool,
) -> FieldResult<UnpackedAttributes> {
let email = attributes
.iter()
.find(|attr| attr.name == "mail")
.cloned()
.map(|attr| deserialize_attribute(&schema.get_schema().user_attributes, attr, is_admin))
.transpose()?
.map(|attr| attr.value.into_string().unwrap())
.map(Email::from);
let display_name = attributes
.iter()
.find(|attr| attr.name == "display_name")
.cloned()
.map(|attr| deserialize_attribute(&schema.get_schema().user_attributes, attr, is_admin))
.transpose()?
.map(|attr| attr.value.into_string().unwrap());
let attributes = attributes
.into_iter()
.filter(|attr| attr.name != "mail" && attr.name != "display_name")
.map(|attr| deserialize_attribute(&schema.get_schema().user_attributes, attr, is_admin))
.collect::<Result<Vec<_>, _>>()?;
Ok(UnpackedAttributes {
email,
display_name,
attributes,
})
}
/// Consolidates caller supplied user fields and attributes into a list of attributes.
///
/// A number of user fields are internally represented as attributes, but are still also
/// available as fields on user objects. This function consolidates these fields and the
/// given attributes into a resulting attribute list. If a value is supplied for both a
/// field and the corresponding attribute, the attribute will take precedence.
pub fn consolidate_attributes(
attributes: Vec<AttributeValue>,
first_name: Option<String>,
last_name: Option<String>,
avatar: Option<String>,
) -> Vec<AttributeValue> {
// Prepare map of the client provided attributes
let mut provided_attributes: BTreeMap<AttributeName, AttributeValue> = attributes
.into_iter()
.map(|x| {
(
x.name.clone().into(),
AttributeValue {
name: x.name.to_ascii_lowercase(),
value: x.value,
},
)
})
.collect::<BTreeMap<_, _>>();
// Prepare list of fallback attribute values
let field_attrs = [
("first_name", first_name),
("last_name", last_name),
("avatar", avatar),
];
for (name, value) in field_attrs.into_iter() {
if let Some(val) = value {
let attr_name: AttributeName = name.into();
provided_attributes
.entry(attr_name)
.or_insert_with(|| AttributeValue {
name: name.to_string(),
value: vec![val],
});
}
}
// Return the values of the resulting map
provided_attributes.into_values().collect()
}
pub async fn create_group_with_details<Handler: BackendHandler>(
context: &Context<Handler>,
request: super::inputs::CreateGroupInput,
span: Span,
) -> FieldResult<crate::query::Group<Handler>> {
let handler = context
.get_admin_handler()
.ok_or_else(field_error_callback(&span, "Unauthorized group creation"))?;
let schema = handler.get_schema().await?;
let public_schema: PublicSchema = schema.into();
let attributes = request
.attributes
.unwrap_or_default()
.into_iter()
.map(|attr| deserialize_attribute(&public_schema.get_schema().group_attributes, attr, true))
.collect::<Result<Vec<_>, _>>()?;
let request = CreateGroupRequest {
display_name: request.display_name.into(),
attributes,
};
let group_id = handler.create_group(request).await?;
let group_details = handler.get_group_details(group_id).instrument(span).await?;
crate::query::Group::<Handler>::from_group_details(group_details, Arc::new(public_schema))
}
pub fn deserialize_attribute(
attribute_schema: &AttributeList,
attribute: AttributeValue,
is_admin: bool,
) -> FieldResult<DomainAttribute> {
let attribute_name = AttributeName::from(attribute.name.as_str());
let attribute_schema = attribute_schema
.get_attribute_schema(&attribute_name)
.ok_or_else(|| anyhow!("Attribute {} is not defined in the schema", attribute.name))?;
if attribute_schema.is_readonly {
return Err(anyhow!(
"Permission denied: Attribute {} is read-only",
attribute.name
)
.into());
}
if !is_admin && !attribute_schema.is_editable {
return Err(anyhow!(
"Permission denied: Attribute {} is not editable by regular users",
attribute.name
)
.into());
}
let deserialized_values = deserialize_attribute_value(
&attribute.value,
attribute_schema.attribute_type,
attribute_schema.is_list,
)
.context(format!("While deserializing attribute {}", attribute.name))?;
Ok(DomainAttribute {
name: attribute_name,
value: deserialized_values,
})
}
@@ -0,0 +1,99 @@
use juniper::{GraphQLInputObject, GraphQLObject};
#[derive(Clone, PartialEq, Eq, Debug, GraphQLInputObject)]
// This conflicts with the attribute values returned by the user/group queries.
#[graphql(name = "AttributeValueInput")]
pub struct AttributeValue {
/// The name of the attribute. It must be present in the schema, and the type informs how
/// to interpret the values.
pub name: String,
/// The values of the attribute.
/// If the attribute is not a list, the vector must contain exactly one element.
/// Integers (signed 64 bits) are represented as strings.
/// Dates are represented as strings in RFC3339 format, e.g. "2019-10-12T07:20:50.52Z".
/// JpegPhotos are represented as base64 encoded strings. They must be valid JPEGs.
pub value: Vec<String>,
}
#[derive(PartialEq, Eq, Debug, GraphQLInputObject)]
/// The details required to create a user.
pub struct CreateUserInput {
pub id: String,
// The email can be specified as an attribute, but one of the two is required.
pub email: Option<String>,
pub display_name: Option<String>,
/// First name of user. Deprecated: use attribute instead.
/// If both field and corresponding attribute is supplied, the attribute will take precedence.
pub first_name: Option<String>,
/// Last name of user. Deprecated: use attribute instead.
/// If both field and corresponding attribute is supplied, the attribute will take precedence.
pub last_name: Option<String>,
/// Base64 encoded JpegPhoto. Deprecated: use attribute instead.
/// If both field and corresponding attribute is supplied, the attribute will take precedence.
pub avatar: Option<String>,
/// Attributes.
pub attributes: Option<Vec<AttributeValue>>,
}
#[derive(PartialEq, Eq, Debug, GraphQLInputObject)]
/// The details required to create a group.
pub struct CreateGroupInput {
pub display_name: String,
/// User-defined attributes.
pub attributes: Option<Vec<AttributeValue>>,
}
#[derive(PartialEq, Eq, Debug, GraphQLInputObject)]
/// The fields that can be updated for a user.
pub struct UpdateUserInput {
pub id: String,
pub email: Option<String>,
pub display_name: Option<String>,
/// First name of user. Deprecated: use attribute instead.
/// If both field and corresponding attribute is supplied, the attribute will take precedence.
pub first_name: Option<String>,
/// Last name of user. Deprecated: use attribute instead.
/// If both field and corresponding attribute is supplied, the attribute will take precedence.
pub last_name: Option<String>,
/// Base64 encoded JpegPhoto. Deprecated: use attribute instead.
/// If both field and corresponding attribute is supplied, the attribute will take precedence.
pub avatar: Option<String>,
/// Attribute names to remove.
/// They are processed before insertions.
pub remove_attributes: Option<Vec<String>>,
/// Inserts or updates the given attributes.
/// For lists, the entire list must be provided.
pub insert_attributes: Option<Vec<AttributeValue>>,
}
#[derive(PartialEq, Eq, Debug, GraphQLInputObject)]
/// The fields that can be updated for a group.
pub struct UpdateGroupInput {
/// The group ID.
pub id: i32,
/// The new display name.
pub display_name: Option<String>,
/// Attribute names to remove.
/// They are processed before insertions.
pub remove_attributes: Option<Vec<String>>,
/// Inserts or updates the given attributes.
/// For lists, the entire list must be provided.
pub insert_attributes: Option<Vec<AttributeValue>>,
}
#[derive(PartialEq, Eq, Debug, GraphQLObject)]
pub struct Success {
ok: bool,
}
impl Success {
pub fn new() -> Self {
Self { ok: true }
}
}
impl Default for Success {
fn default() -> Self {
Self::new()
}
}
+884
View File
@@ -0,0 +1,884 @@
pub mod helpers;
pub mod inputs;
// Re-export public types
pub use inputs::{
AttributeValue, CreateGroupInput, CreateUserInput, Success, UpdateGroupInput, UpdateUserInput,
};
use crate::api::{Context, field_error_callback};
use anyhow::anyhow;
use juniper::{FieldError, FieldResult, graphql_object};
use lldap_access_control::{
AdminBackendHandler, UserReadableBackendHandler, UserWriteableBackendHandler,
};
use lldap_domain::{
requests::{CreateAttributeRequest, CreateUserRequest, UpdateGroupRequest, UpdateUserRequest},
types::{AttributeName, AttributeType, Email, GroupId, LdapObjectClass, UserId},
};
use lldap_domain_handlers::handler::BackendHandler;
use lldap_validation::attributes::{ALLOWED_CHARACTERS_DESCRIPTION, validate_attribute_name};
use std::sync::Arc;
use tracing::{Instrument, debug, debug_span};
use helpers::{
UnpackedAttributes, consolidate_attributes, create_group_with_details, deserialize_attribute,
unpack_attributes,
};
#[derive(PartialEq, Eq, Debug)]
/// The top-level GraphQL mutation type.
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 {
_phantom: std::marker::PhantomData,
}
}
}
#[graphql_object(context = Context<Handler>)]
impl<Handler: BackendHandler> Mutation<Handler> {
async fn create_user(
context: &Context<Handler>,
user: CreateUserInput,
) -> FieldResult<super::query::User<Handler>> {
let span = debug_span!("[GraphQL mutation] create_user");
span.in_scope(|| {
debug!("{:?}", &user.id);
});
let handler = context
.get_admin_handler()
.ok_or_else(field_error_callback(&span, "Unauthorized user creation"))?;
let user_id = UserId::new(&user.id);
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(consolidated_attributes, &schema, true)?;
handler
.create_user(CreateUserRequest {
user_id: user_id.clone(),
email: user
.email
.map(Email::from)
.or(email)
.ok_or_else(|| anyhow!("Email is required when creating a new user"))?,
display_name: user.display_name.or(display_name),
attributes,
})
.instrument(span.clone())
.await?;
let user_details = handler.get_user_details(&user_id).instrument(span).await?;
super::query::User::<Handler>::from_user(user_details, Arc::new(schema))
}
async fn create_group(
context: &Context<Handler>,
name: String,
) -> FieldResult<super::query::Group<Handler>> {
let span = debug_span!("[GraphQL mutation] create_group");
span.in_scope(|| {
debug!(?name);
});
create_group_with_details(
context,
CreateGroupInput {
display_name: name,
attributes: Some(Vec::new()),
},
span,
)
.await
}
async fn create_group_with_details(
context: &Context<Handler>,
request: CreateGroupInput,
) -> FieldResult<super::query::Group<Handler>> {
let span = debug_span!("[GraphQL mutation] create_group_with_details");
span.in_scope(|| {
debug!(?request);
});
create_group_with_details(context, request, span).await
}
async fn update_user(
context: &Context<Handler>,
user: UpdateUserInput,
) -> FieldResult<Success> {
let span = debug_span!("[GraphQL mutation] update_user");
span.in_scope(|| {
debug!(?user.id);
});
let user_id = UserId::new(&user.id);
let handler = context
.get_writeable_handler(&user_id)
.ok_or_else(field_error_callback(&span, "Unauthorized user update"))?;
let is_admin = context.validation_result.is_admin();
let schema = handler.get_schema().await?;
// 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(insert_attrs, &schema, is_admin)?;
let display_name = display_name.or_else(|| {
// If the display name is not inserted, but removed, reset it.
delete_attributes
.iter()
.find(|attr| *attr == "display_name")
.map(|_| String::new())
});
handler
.update_user(UpdateUserRequest {
user_id,
email: user.email.map(Into::into).or(email),
display_name: user.display_name.or(display_name),
delete_attributes: delete_attributes
.into_iter()
.filter(|attr| attr != "mail" && attr != "display_name")
.map(Into::into)
.collect(),
insert_attributes,
})
.instrument(span)
.await?;
Ok(Success::new())
}
async fn update_group(
context: &Context<Handler>,
group: UpdateGroupInput,
) -> FieldResult<Success> {
let span = debug_span!("[GraphQL mutation] update_group");
span.in_scope(|| {
debug!(?group.id);
});
let handler = context
.get_admin_handler()
.ok_or_else(field_error_callback(&span, "Unauthorized group update"))?;
let new_display_name = group.display_name.clone().or_else(|| {
group.insert_attributes.as_ref().and_then(|a| {
a.iter()
.find(|attr| attr.name == "display_name")
.map(|attr| attr.value[0].clone())
})
});
if group.id == 1 && new_display_name.is_some() {
span.in_scope(|| debug!("Cannot change lldap_admin group name"));
return Err("Cannot change lldap_admin group name".into());
}
let schema = handler.get_schema().await?;
let insert_attributes = group
.insert_attributes
.unwrap_or_default()
.into_iter()
.filter(|attr| attr.name != "display_name")
.map(|attr| deserialize_attribute(&schema.get_schema().group_attributes, attr, true))
.collect::<Result<Vec<_>, _>>()?;
handler
.update_group(UpdateGroupRequest {
group_id: GroupId(group.id),
display_name: new_display_name.map(|s| s.as_str().into()),
delete_attributes: group
.remove_attributes
.unwrap_or_default()
.into_iter()
.filter(|attr| attr != "display_name")
.map(Into::into)
.collect(),
insert_attributes,
})
.instrument(span)
.await?;
Ok(Success::new())
}
async fn add_user_to_group(
context: &Context<Handler>,
user_id: String,
group_id: i32,
) -> FieldResult<Success> {
let span = debug_span!("[GraphQL mutation] add_user_to_group");
span.in_scope(|| {
debug!(?user_id, ?group_id);
});
let handler = context
.get_admin_handler()
.ok_or_else(field_error_callback(
&span,
"Unauthorized group membership modification",
))?;
handler
.add_user_to_group(&UserId::new(&user_id), GroupId(group_id))
.instrument(span)
.await?;
Ok(Success::new())
}
async fn remove_user_from_group(
context: &Context<Handler>,
user_id: String,
group_id: i32,
) -> FieldResult<Success> {
let span = debug_span!("[GraphQL mutation] remove_user_from_group");
span.in_scope(|| {
debug!(?user_id, ?group_id);
});
let handler = context
.get_admin_handler()
.ok_or_else(field_error_callback(
&span,
"Unauthorized group membership modification",
))?;
let user_id = UserId::new(&user_id);
if context.validation_result.user == user_id && group_id == 1 {
span.in_scope(|| debug!("Cannot remove admin rights for current user"));
return Err("Cannot remove admin rights for current user".into());
}
handler
.remove_user_from_group(&user_id, GroupId(group_id))
.instrument(span)
.await?;
Ok(Success::new())
}
async fn delete_user(context: &Context<Handler>, user_id: String) -> FieldResult<Success> {
let span = debug_span!("[GraphQL mutation] delete_user");
span.in_scope(|| {
debug!(?user_id);
});
let user_id = UserId::new(&user_id);
let handler = context
.get_admin_handler()
.ok_or_else(field_error_callback(&span, "Unauthorized user deletion"))?;
if context.validation_result.user == user_id {
span.in_scope(|| debug!("Cannot delete current user"));
return Err("Cannot delete current user".into());
}
handler.delete_user(&user_id).instrument(span).await?;
Ok(Success::new())
}
async fn delete_group(context: &Context<Handler>, group_id: i32) -> FieldResult<Success> {
let span = debug_span!("[GraphQL mutation] delete_group");
span.in_scope(|| {
debug!(?group_id);
});
let handler = context
.get_admin_handler()
.ok_or_else(field_error_callback(&span, "Unauthorized group deletion"))?;
if group_id == 1 {
span.in_scope(|| debug!("Cannot delete admin group"));
return Err("Cannot delete admin group".into());
}
handler
.delete_group(GroupId(group_id))
.instrument(span)
.await?;
Ok(Success::new())
}
async fn add_user_attribute(
context: &Context<Handler>,
name: String,
attribute_type: AttributeType,
is_list: bool,
is_visible: bool,
is_editable: bool,
) -> FieldResult<Success> {
let span = debug_span!("[GraphQL mutation] add_user_attribute");
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(
&span,
"Unauthorized attribute creation",
))?;
handler
.add_user_attribute(CreateAttributeRequest {
name: name.into(),
attribute_type,
is_list,
is_visible,
is_editable,
})
.instrument(span)
.await?;
Ok(Success::new())
}
async fn add_group_attribute(
context: &Context<Handler>,
name: String,
attribute_type: AttributeType,
is_list: bool,
is_visible: bool,
is_editable: bool,
) -> FieldResult<Success> {
let span = debug_span!("[GraphQL mutation] add_group_attribute");
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(
&span,
"Unauthorized attribute creation",
))?;
handler
.add_group_attribute(CreateAttributeRequest {
name: name.into(),
attribute_type,
is_list,
is_visible,
is_editable,
})
.instrument(span)
.await?;
Ok(Success::new())
}
async fn delete_user_attribute(
context: &Context<Handler>,
name: String,
) -> FieldResult<Success> {
let span = debug_span!("[GraphQL mutation] delete_user_attribute");
let name = AttributeName::from(name);
span.in_scope(|| {
debug!(?name);
});
let handler = context
.get_admin_handler()
.ok_or_else(field_error_callback(
&span,
"Unauthorized attribute deletion",
))?;
let schema = handler.get_schema().await?;
let attribute_schema = schema
.get_schema()
.user_attributes
.get_attribute_schema(&name)
.ok_or_else(|| anyhow!("Attribute {} is not defined in the schema", &name))?;
if attribute_schema.is_hardcoded {
return Err(anyhow!("Permission denied: Attribute {} cannot be deleted", &name).into());
}
handler
.delete_user_attribute(&name)
.instrument(span)
.await?;
Ok(Success::new())
}
async fn delete_group_attribute(
context: &Context<Handler>,
name: String,
) -> FieldResult<Success> {
let span = debug_span!("[GraphQL mutation] delete_group_attribute");
let name = AttributeName::from(name);
span.in_scope(|| {
debug!(?name);
});
let handler = context
.get_admin_handler()
.ok_or_else(field_error_callback(
&span,
"Unauthorized attribute deletion",
))?;
let schema = handler.get_schema().await?;
let attribute_schema = schema
.get_schema()
.group_attributes
.get_attribute_schema(&name)
.ok_or_else(|| anyhow!("Attribute {} is not defined in the schema", &name))?;
if attribute_schema.is_hardcoded {
return Err(anyhow!("Permission denied: Attribute {} cannot be deleted", &name).into());
}
handler
.delete_group_attribute(&name)
.instrument(span)
.await?;
Ok(Success::new())
}
async fn add_user_object_class(
context: &Context<Handler>,
name: String,
) -> FieldResult<Success> {
let span = debug_span!("[GraphQL mutation] add_user_object_class");
span.in_scope(|| {
debug!(?name);
});
let handler = context
.get_admin_handler()
.ok_or_else(field_error_callback(
&span,
"Unauthorized object class addition",
))?;
handler
.add_user_object_class(&LdapObjectClass::from(name))
.instrument(span)
.await?;
Ok(Success::new())
}
async fn add_group_object_class(
context: &Context<Handler>,
name: String,
) -> FieldResult<Success> {
let span = debug_span!("[GraphQL mutation] add_group_object_class");
span.in_scope(|| {
debug!(?name);
});
let handler = context
.get_admin_handler()
.ok_or_else(field_error_callback(
&span,
"Unauthorized object class addition",
))?;
handler
.add_group_object_class(&LdapObjectClass::from(name))
.instrument(span)
.await?;
Ok(Success::new())
}
async fn delete_user_object_class(
context: &Context<Handler>,
name: String,
) -> FieldResult<Success> {
let span = debug_span!("[GraphQL mutation] delete_user_object_class");
span.in_scope(|| {
debug!(?name);
});
let handler = context
.get_admin_handler()
.ok_or_else(field_error_callback(
&span,
"Unauthorized object class deletion",
))?;
handler
.delete_user_object_class(&LdapObjectClass::from(name))
.instrument(span)
.await?;
Ok(Success::new())
}
async fn delete_group_object_class(
context: &Context<Handler>,
name: String,
) -> FieldResult<Success> {
let span = debug_span!("[GraphQL mutation] delete_group_object_class");
span.in_scope(|| {
debug!(?name);
});
let handler = context
.get_admin_handler()
.ok_or_else(field_error_callback(
&span,
"Unauthorized object class deletion",
))?;
handler
.delete_group_object_class(&LdapObjectClass::from(name))
.instrument(span)
.await?;
Ok(Success::new())
}
}
#[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()],
},
]
);
}
}

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