Compare commits
106 Commits
user-attri
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f417427635 | ||
|
|
1f26262e13 | ||
|
|
42fccf4713 | ||
|
|
928faa4bcc | ||
|
|
3895a5050d | ||
|
|
f92035b6fd | ||
|
|
37a10c871f | ||
|
|
8397d536d9 | ||
|
|
acd39d20b1 | ||
|
|
0ddeab8caa | ||
|
|
64514ddfc6 | ||
|
|
c47be779a3 | ||
|
|
fea2ed5b79 | ||
|
|
e982908768 | ||
|
|
713dbde4cb | ||
|
|
579dd5e1b6 | ||
|
|
3828ec7624 | ||
|
|
b8c06ebd75 | ||
|
|
130d2552ac | ||
|
|
098745ebc9 | ||
|
|
95337e2cd8 | ||
|
|
143eb70bee | ||
|
|
35fe521cbe | ||
|
|
c8601b9169 | ||
|
|
8f6c324de7 | ||
|
|
f0fcc88f1d | ||
|
|
c08ddecd32 | ||
|
|
4ebfd0525b | ||
|
|
a190fe7ddf | ||
|
|
df188ee83f | ||
|
|
52c917d967 | ||
|
|
f01daae6a8 | ||
|
|
62b2afa283 | ||
|
|
305b272cdf | ||
|
|
a95ac38083 | ||
|
|
abfe2f3a17 | ||
|
|
11d766b2ba | ||
|
|
56eee6908e | ||
|
|
dcb45d4f6b | ||
|
|
a6eac55fc7 | ||
|
|
1c6646d8c5 | ||
|
|
362e968e00 | ||
|
|
17bcd7645b | ||
|
|
dcba3d17dc | ||
|
|
1f3f73585b | ||
|
|
0c6a92a8fa | ||
|
|
120ad34f92 | ||
|
|
a2ba71ac19 | ||
|
|
10a820f2a2 | ||
|
|
01f97f5ed4 | ||
|
|
f14aa2284c | ||
|
|
65e2103365 | ||
|
|
5db0072cfa | ||
|
|
1d8d3eb73f | ||
|
|
97e4d90eb7 | ||
|
|
6cf0f6df06 | ||
|
|
b1384818d2 | ||
|
|
3ec44a58be | ||
|
|
6f7bfca682 | ||
|
|
2c79a40a73 | ||
|
|
25c6d6c962 | ||
|
|
04b048dd47 | ||
|
|
dc26f97117 | ||
|
|
09c5d9f925 | ||
|
|
ee7f9c9f41 | ||
|
|
fa9c503de7 | ||
|
|
4138963bee | ||
|
|
5a2a92bbda | ||
|
|
6aa9303339 | ||
|
|
049a360506 | ||
|
|
b26de34e0d | ||
|
|
15c28110b5 | ||
|
|
83508a363c | ||
|
|
010eec22d3 | ||
|
|
b33d56a459 | ||
|
|
6eb5b959bf | ||
|
|
6f46ffd1e4 | ||
|
|
73686224dd | ||
|
|
56ed37ef8a | ||
|
|
39e1a02255 | ||
|
|
4f050cded5 | ||
|
|
254a168e78 | ||
|
|
85b83aff5f | ||
|
|
199a80ca5b | ||
|
|
f96868318a | ||
|
|
04b0fa0ae9 | ||
|
|
2e08c6a7ec | ||
|
|
892492815d | ||
|
|
2597a250f0 | ||
|
|
f67f090bde | ||
|
|
a97881477f | ||
|
|
8587fc38fd | ||
|
|
6d65a2546c | ||
|
|
7806ed34ff | ||
|
|
22623bfab1 | ||
|
|
2f20f63b41 | ||
|
|
87d825626c | ||
|
|
8cbad6d5bd | ||
|
|
8db7d8a46f | ||
|
|
533d1bcfd0 | ||
|
|
3d8aafaa9d | ||
|
|
f93681239b | ||
|
|
13720c101c | ||
|
|
a1eb708cf3 | ||
|
|
959bb907d8 | ||
|
|
22074f56d2 |
5
.github/workflows/Dockerfile.ci.alpine-base
vendored
5
.github/workflows/Dockerfile.ci.alpine-base
vendored
@@ -59,12 +59,12 @@ RUN set -x \
|
|||||||
&& for file in $(cat /lldap/app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
|
&& for file in $(cat /lldap/app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
|
||||||
&& chmod a+r -R .
|
&& chmod a+r -R .
|
||||||
|
|
||||||
FROM alpine:3.16
|
FROM alpine:3.19
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV UID=1000
|
ENV UID=1000
|
||||||
ENV GID=1000
|
ENV GID=1000
|
||||||
ENV USER=lldap
|
ENV USER=lldap
|
||||||
RUN apk add --no-cache tini ca-certificates bash tzdata && \
|
RUN apk add --no-cache tini ca-certificates bash tzdata jq curl jo && \
|
||||||
addgroup -g $GID $USER && \
|
addgroup -g $GID $USER && \
|
||||||
adduser \
|
adduser \
|
||||||
--disabled-password \
|
--disabled-password \
|
||||||
@@ -80,5 +80,6 @@ COPY --from=lldap --chown=$USER:$USER /lldap /app
|
|||||||
VOLUME ["/data"]
|
VOLUME ["/data"]
|
||||||
HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]
|
HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
COPY scripts/bootstrap.sh ./
|
||||||
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
|
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
|
||||||
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
||||||
|
|||||||
3
.github/workflows/Dockerfile.ci.debian-base
vendored
3
.github/workflows/Dockerfile.ci.debian-base
vendored
@@ -65,7 +65,7 @@ ENV UID=1000
|
|||||||
ENV GID=1000
|
ENV GID=1000
|
||||||
ENV USER=lldap
|
ENV USER=lldap
|
||||||
RUN apt update && \
|
RUN apt update && \
|
||||||
apt install -y --no-install-recommends tini openssl ca-certificates tzdata && \
|
apt install -y --no-install-recommends tini openssl ca-certificates tzdata jq curl jo && \
|
||||||
apt clean && \
|
apt clean && \
|
||||||
rm -rf /var/lib/apt/lists/* && \
|
rm -rf /var/lib/apt/lists/* && \
|
||||||
groupadd -g $GID $USER && useradd --system -m -g $USER --uid $UID $USER && \
|
groupadd -g $GID $USER && useradd --system -m -g $USER --uid $UID $USER && \
|
||||||
@@ -74,6 +74,7 @@ COPY --from=lldap --chown=$USER:$USER /lldap /app
|
|||||||
COPY --from=lldap --chown=$USER:$USER /docker-entrypoint.sh /docker-entrypoint.sh
|
COPY --from=lldap --chown=$USER:$USER /docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
VOLUME ["/data"]
|
VOLUME ["/data"]
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
COPY scripts/bootstrap.sh ./
|
||||||
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
|
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
|
||||||
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
||||||
HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]
|
HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]
|
||||||
|
|||||||
5
.github/workflows/Dockerfile.dev
vendored
5
.github/workflows/Dockerfile.dev
vendored
@@ -1,5 +1,5 @@
|
|||||||
# Keep tracking base image
|
# Keep tracking base image
|
||||||
FROM rust:1.74-slim-bookworm
|
FROM rust:1.81-slim-bookworm
|
||||||
|
|
||||||
# Set needed env path
|
# 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"
|
ENV PATH="/opt/armv7l-linux-musleabihf-cross/:/opt/armv7l-linux-musleabihf-cross/bin/:/opt/aarch64-linux-musl-cross/:/opt/aarch64-linux-musl-cross/bin/:/opt/x86_64-linux-musl-cross/:/opt/x86_64-linux-musl-cross/bin/:$PATH"
|
||||||
@@ -34,7 +34,8 @@ RUN wget -c https://musl.cc/x86_64-linux-musl-cross.tgz && \
|
|||||||
### Add musl target
|
### Add musl target
|
||||||
RUN rustup target add x86_64-unknown-linux-musl && \
|
RUN rustup target add x86_64-unknown-linux-musl && \
|
||||||
rustup target add aarch64-unknown-linux-musl && \
|
rustup target add aarch64-unknown-linux-musl && \
|
||||||
rustup target add armv7-unknown-linux-musleabihf
|
rustup target add armv7-unknown-linux-musleabihf && \
|
||||||
|
rustup target add x86_64-unknown-freebsd
|
||||||
|
|
||||||
|
|
||||||
CMD ["bash"]
|
CMD ["bash"]
|
||||||
|
|||||||
70
.github/workflows/docker-build-static.yml
vendored
70
.github/workflows/docker-build-static.yml
vendored
@@ -39,7 +39,7 @@ env:
|
|||||||
# GitHub actions randomly timeout when downloading musl-gcc, using custom dev image #
|
# GitHub actions randomly timeout when downloading musl-gcc, using custom dev image #
|
||||||
# Look into .github/workflows/Dockerfile.dev for development image details #
|
# Look into .github/workflows/Dockerfile.dev for development image details #
|
||||||
# Using lldap dev image based on https://hub.docker.com/_/rust and musl-gcc bundled #
|
# Using lldap dev image based on https://hub.docker.com/_/rust and musl-gcc bundled #
|
||||||
# lldap/rust-dev:latest #
|
# lldap/rust-dev #
|
||||||
#######################################################################################
|
#######################################################################################
|
||||||
# Cargo build
|
# Cargo build
|
||||||
### armv7, aarch64 and amd64 is musl based
|
### armv7, aarch64 and amd64 is musl based
|
||||||
@@ -84,10 +84,10 @@ jobs:
|
|||||||
needs: pre_job
|
needs: pre_job
|
||||||
if: ${{ needs.pre_job.outputs.should_skip != 'true' || github.event_name == 'release' }}
|
if: ${{ needs.pre_job.outputs.should_skip != 'true' || github.event_name == 'release' }}
|
||||||
container:
|
container:
|
||||||
image: lldap/rust-dev:latest
|
image: lldap/rust-dev:v81
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4.1.1
|
uses: actions/checkout@v4.2.2
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
@@ -125,14 +125,14 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
target: [armv7-unknown-linux-musleabihf, aarch64-unknown-linux-musl, x86_64-unknown-linux-musl]
|
target: [armv7-unknown-linux-musleabihf, aarch64-unknown-linux-musl, x86_64-unknown-linux-musl]
|
||||||
container:
|
container:
|
||||||
image: lldap/rust-dev:latest
|
image: lldap/rust-dev:v81
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
RUSTFLAGS: -Ctarget-feature=+crt-static
|
RUSTFLAGS: -Ctarget-feature=+crt-static
|
||||||
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
|
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4.1.1
|
uses: actions/checkout@v4.2.2
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
@@ -216,6 +216,8 @@ jobs:
|
|||||||
LLDAP_database_url: postgres://lldapuser:lldappass@localhost/lldap
|
LLDAP_database_url: postgres://lldapuser:lldappass@localhost/lldap
|
||||||
LLDAP_ldap_port: 3890
|
LLDAP_ldap_port: 3890
|
||||||
LLDAP_http_port: 17170
|
LLDAP_http_port: 17170
|
||||||
|
LLDAP_JWT_SECRET: verysecret
|
||||||
|
LLDAP_LDAP_USER_PASS: password
|
||||||
|
|
||||||
|
|
||||||
- name: Run lldap with mariadb DB (MySQL Compatible) and healthcheck
|
- name: Run lldap with mariadb DB (MySQL Compatible) and healthcheck
|
||||||
@@ -227,6 +229,8 @@ jobs:
|
|||||||
LLDAP_database_url: mysql://lldapuser:lldappass@localhost/lldap
|
LLDAP_database_url: mysql://lldapuser:lldappass@localhost/lldap
|
||||||
LLDAP_ldap_port: 3891
|
LLDAP_ldap_port: 3891
|
||||||
LLDAP_http_port: 17171
|
LLDAP_http_port: 17171
|
||||||
|
LLDAP_JWT_SECRET: verysecret
|
||||||
|
LLDAP_LDAP_USER_PASS: password
|
||||||
|
|
||||||
|
|
||||||
- name: Run lldap with sqlite DB and healthcheck
|
- name: Run lldap with sqlite DB and healthcheck
|
||||||
@@ -238,6 +242,8 @@ jobs:
|
|||||||
LLDAP_database_url: sqlite://users.db?mode=rwc
|
LLDAP_database_url: sqlite://users.db?mode=rwc
|
||||||
LLDAP_ldap_port: 3892
|
LLDAP_ldap_port: 3892
|
||||||
LLDAP_http_port: 17172
|
LLDAP_http_port: 17172
|
||||||
|
LLDAP_JWT_SECRET: verysecret
|
||||||
|
LLDAP_LDAP_USER_PASS: password
|
||||||
|
|
||||||
- name: Check DB container logs
|
- name: Check DB container logs
|
||||||
run: |
|
run: |
|
||||||
@@ -294,7 +300,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout scripts
|
- name: Checkout scripts
|
||||||
uses: actions/checkout@v4.1.1
|
uses: actions/checkout@v4.2.2
|
||||||
with:
|
with:
|
||||||
sparse-checkout: 'scripts'
|
sparse-checkout: 'scripts'
|
||||||
|
|
||||||
@@ -324,9 +330,9 @@ jobs:
|
|||||||
sleep 10s
|
sleep 10s
|
||||||
bin/lldap healthcheck
|
bin/lldap healthcheck
|
||||||
env:
|
env:
|
||||||
LLDAP_database_url: sqlite://users.db?mode=rwc
|
LLDAP_DATABASE_URL: sqlite://users.db?mode=rwc
|
||||||
LLDAP_ldap_port: 3890
|
LLDAP_LDAP_PORT: 3890
|
||||||
LLDAP_http_port: 17170
|
LLDAP_HTTP_PORT: 17170
|
||||||
LLDAP_LDAP_USER_PASS: ldappass
|
LLDAP_LDAP_USER_PASS: ldappass
|
||||||
LLDAP_JWT_SECRET: somejwtsecret
|
LLDAP_JWT_SECRET: somejwtsecret
|
||||||
|
|
||||||
@@ -350,8 +356,11 @@ jobs:
|
|||||||
sed -i -r -e "s/X'([[:xdigit:]]+'[^'])/'\\\x\\1/g" -e ":a; s/(INSERT INTO (user_attribute_schema|jwt_storage)\(.*\) VALUES\(.*),1([^']*\);)$/\1,true\3/; s/(INSERT INTO (user_attribute_schema|jwt_storage)\(.*\) VALUES\(.*),0([^']*\);)$/\1,false\3/; ta" -e '1s/^/BEGIN;\n/' -e '$aCOMMIT;' ./dump.sql
|
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
|
- name: Create schema on postgres
|
||||||
|
env:
|
||||||
|
LLDAP_DATABASE_URL: postgres://lldapuser:lldappass@localhost:5432/lldap
|
||||||
|
LLDAP_JWT_SECRET: somejwtsecret
|
||||||
run: |
|
run: |
|
||||||
bin/lldap create_schema -d postgres://lldapuser:lldappass@localhost:5432/lldap
|
bin/lldap create_schema
|
||||||
|
|
||||||
- name: Copy converted db to postgress and import
|
- name: Copy converted db to postgress and import
|
||||||
run: |
|
run: |
|
||||||
@@ -368,7 +377,10 @@ jobs:
|
|||||||
sed -i '1 i\SET FOREIGN_KEY_CHECKS = 0;' ./dump.sql
|
sed -i '1 i\SET FOREIGN_KEY_CHECKS = 0;' ./dump.sql
|
||||||
|
|
||||||
- name: Create schema on mariadb
|
- 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
|
- name: Copy converted db to mariadb and import
|
||||||
run: |
|
run: |
|
||||||
@@ -384,7 +396,10 @@ jobs:
|
|||||||
sed -i '1 i\SET FOREIGN_KEY_CHECKS = 0;' ./dump.sql
|
sed -i '1 i\SET FOREIGN_KEY_CHECKS = 0;' ./dump.sql
|
||||||
|
|
||||||
- name: Create schema on mysql
|
- 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
|
- name: Copy converted db to mysql and import
|
||||||
run: |
|
run: |
|
||||||
@@ -399,10 +414,9 @@ jobs:
|
|||||||
sleep 10s
|
sleep 10s
|
||||||
bin/lldap healthcheck
|
bin/lldap healthcheck
|
||||||
env:
|
env:
|
||||||
LLDAP_database_url: postgres://lldapuser:lldappass@localhost:5432/lldap
|
LLDAP_DATABASE_URL: postgres://lldapuser:lldappass@localhost:5432/lldap
|
||||||
LLDAP_ldap_port: 3891
|
LLDAP_LDAP_PORT: 3891
|
||||||
LLDAP_http_port: 17171
|
LLDAP_HTTP_PORT: 17171
|
||||||
LLDAP_LDAP_USER_PASS: ldappass
|
|
||||||
LLDAP_JWT_SECRET: somejwtsecret
|
LLDAP_JWT_SECRET: somejwtsecret
|
||||||
|
|
||||||
- name: Run lldap with mariaDB and healthcheck again
|
- name: Run lldap with mariaDB and healthcheck again
|
||||||
@@ -411,9 +425,9 @@ jobs:
|
|||||||
sleep 10s
|
sleep 10s
|
||||||
bin/lldap healthcheck
|
bin/lldap healthcheck
|
||||||
env:
|
env:
|
||||||
LLDAP_database_url: mysql://lldapuser:lldappass@localhost:3306/lldap
|
LLDAP_DATABASE_URL: mysql://lldapuser:lldappass@localhost:3306/lldap
|
||||||
LLDAP_ldap_port: 3892
|
LLDAP_LDAP_PORT: 3892
|
||||||
LLDAP_http_port: 17172
|
LLDAP_HTTP_PORT: 17172
|
||||||
LLDAP_JWT_SECRET: somejwtsecret
|
LLDAP_JWT_SECRET: somejwtsecret
|
||||||
|
|
||||||
- name: Run lldap with mysql and healthcheck again
|
- name: Run lldap with mysql and healthcheck again
|
||||||
@@ -422,9 +436,9 @@ jobs:
|
|||||||
sleep 10s
|
sleep 10s
|
||||||
bin/lldap healthcheck
|
bin/lldap healthcheck
|
||||||
env:
|
env:
|
||||||
LLDAP_database_url: mysql://lldapuser:lldappass@localhost:3307/lldap
|
LLDAP_DATABASE_URL: mysql://lldapuser:lldappass@localhost:3307/lldap
|
||||||
LLDAP_ldap_port: 3893
|
LLDAP_LDAP_PORT: 3893
|
||||||
LLDAP_http_port: 17173
|
LLDAP_HTTP_PORT: 17173
|
||||||
LLDAP_JWT_SECRET: somejwtsecret
|
LLDAP_JWT_SECRET: somejwtsecret
|
||||||
|
|
||||||
- name: Test Dummy User Postgres
|
- name: Test Dummy User Postgres
|
||||||
@@ -482,7 +496,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4.1.1
|
uses: actions/checkout@v4.2.2
|
||||||
|
|
||||||
- name: Download all artifacts
|
- name: Download all artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
@@ -512,7 +526,7 @@ jobs:
|
|||||||
tags: ${{ matrix.container }}-base
|
tags: ${{ matrix.container }}-base
|
||||||
|
|
||||||
- name: Build ${{ matrix.container }} Base Docker Image
|
- name: Build ${{ matrix.container }} Base Docker Image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
# On PR will fail, force fully uncomment push: true, or docker image will fail for next steps
|
# On PR will fail, force fully uncomment push: true, or docker image will fail for next steps
|
||||||
@@ -613,7 +627,7 @@ jobs:
|
|||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build ${{ matrix.container }}-rootless Docker Image
|
- name: Build ${{ matrix.container }}-rootless Docker Image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
@@ -627,7 +641,7 @@ jobs:
|
|||||||
|
|
||||||
### This docker build always the last, due :latest tag pushed multiple times, for whatever variants may added in future add docker build above this
|
### This docker build always the last, due :latest tag pushed multiple times, for whatever variants may added in future add docker build above this
|
||||||
- name: Build ${{ matrix.container }} Docker Image
|
- name: Build ${{ matrix.container }} Docker Image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
@@ -738,5 +752,9 @@ jobs:
|
|||||||
artifacts: aarch64-lldap.tar.gz,
|
artifacts: aarch64-lldap.tar.gz,
|
||||||
amd64-lldap.tar.gz,
|
amd64-lldap.tar.gz,
|
||||||
armhf-lldap.tar.gz
|
armhf-lldap.tar.gz
|
||||||
|
draft: true
|
||||||
|
omitBodyDuringUpdate: true
|
||||||
|
omitDraftDuringUpdate: true
|
||||||
|
omitNameDuringUpdate: true
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
|||||||
20
.github/workflows/release-bot.yml
vendored
20
.github/workflows/release-bot.yml
vendored
@@ -1,20 +0,0 @@
|
|||||||
name: Release Bot
|
|
||||||
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types: [published]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
comment:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
issues: write
|
|
||||||
pull-requests: write
|
|
||||||
steps:
|
|
||||||
- uses: nflaig/release-comment-on-pr@master
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.RELEASE_BOT_TOKEN }}
|
|
||||||
message: |
|
|
||||||
Thank you everyone for the contribution!
|
|
||||||
This feature is now available in the latest release, [${releaseTag}](${releaseUrl}).
|
|
||||||
You can support LLDAP by starring our repo, contributing some configuration examples and becoming a sponsor.
|
|
||||||
18
.github/workflows/rust.yml
vendored
18
.github/workflows/rust.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout sources
|
- name: Checkout sources
|
||||||
uses: actions/checkout@v4.1.1
|
uses: actions/checkout@v4.2.2
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
- name: Build
|
- name: Build
|
||||||
run: cargo build --verbose --workspace
|
run: cargo build --verbose --workspace
|
||||||
@@ -52,7 +52,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout sources
|
- name: Checkout sources
|
||||||
uses: actions/checkout@v4.1.1
|
uses: actions/checkout@v4.2.2
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout sources
|
- name: Checkout sources
|
||||||
uses: actions/checkout@v4.1.1
|
uses: actions/checkout@v4.2.2
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout sources
|
- name: Checkout sources
|
||||||
uses: actions/checkout@v4.1.1
|
uses: actions/checkout@v4.2.2
|
||||||
|
|
||||||
- name: Install Rust
|
- name: Install Rust
|
||||||
run: rustup toolchain install nightly --component llvm-tools-preview && rustup component add llvm-tools-preview --toolchain stable-x86_64-unknown-linux-gnu
|
run: rustup toolchain install nightly --component llvm-tools-preview && rustup component add llvm-tools-preview --toolchain stable-x86_64-unknown-linux-gnu
|
||||||
@@ -101,16 +101,10 @@ jobs:
|
|||||||
run: cargo llvm-cov --workspace --no-report
|
run: cargo llvm-cov --workspace --no-report
|
||||||
- name: Aggregate reports
|
- name: Aggregate reports
|
||||||
run: cargo llvm-cov --no-run --lcov --output-path lcov.info
|
run: cargo llvm-cov --no-run --lcov --output-path lcov.info
|
||||||
- name: Upload coverage to Codecov
|
|
||||||
uses: codecov/codecov-action@v3
|
|
||||||
if: github.ref != 'refs/heads/main' || github.event_name != 'push'
|
|
||||||
with:
|
|
||||||
files: lcov.info
|
|
||||||
fail_ci_if_error: true
|
|
||||||
- name: Upload coverage to Codecov (main)
|
- name: Upload coverage to Codecov (main)
|
||||||
uses: codecov/codecov-action@v3
|
uses: codecov/codecov-action@v4
|
||||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
|
||||||
with:
|
with:
|
||||||
files: lcov.info
|
files: lcov.info
|
||||||
fail_ci_if_error: true
|
fail_ci_if_error: true
|
||||||
|
codecov_yml_path: .github/codecov.yml
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|||||||
103
CHANGELOG.md
103
CHANGELOG.md
@@ -5,6 +5,107 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.6.1] 2024-11-22
|
||||||
|
|
||||||
|
Small release, mainly to fix a migration issue with Sqlite and Postgresql.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a link to a community terraform provider (#1035)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The opaque dependency now points to the official crate rather than a fork (#1040)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Migration of the DB schema from 7 to 8 is now automatic for sqlite, and fixed for postgres (#1045)
|
||||||
|
- The startup warning about `key_seed` applying instead of `key_file` now has instructions on how to silence it (#1032)
|
||||||
|
|
||||||
|
### New services
|
||||||
|
|
||||||
|
- OneDev
|
||||||
|
|
||||||
|
## [0.6.0] 2024-11-09
|
||||||
|
|
||||||
|
### Breaking
|
||||||
|
|
||||||
|
- The endpoint `/auth/reset/step1` is now `POST` instead of `GET` (#704)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Custom attributes are now supported (#67) ! You can add new fields (string, integers, JPEG or dates) to users and query them. That unlocks many integrations with other services, and allows for a deeper/more customized integration. Special thanks to @pixelrazor and @bojidar-bg for their help with the UI.
|
||||||
|
- Custom object classes (for all users/groups) can now be added (#833)
|
||||||
|
- Barebones support for Paged Results Control (no paging, no respect for windows, but a correct response with all the results) (#698)
|
||||||
|
- A daily docker image is tagged and released. (#613)
|
||||||
|
- A bootstrap script allows reading the list of users/groups from a file and making sure the server contains exactly the same thing. (#654)
|
||||||
|
- Make it possible to serve lldap behind a sub-path in (#752)
|
||||||
|
- LLDAP can now be found on a custom package repository for opensuse, fedora, ubuntu, debian and centos ([Repository link](https://software.opensuse.org//download.html?project=home%3AMasgalor%3ALLDAP&package=lldap)). Thanks @Masgalor for setting it up and maintaining it.
|
||||||
|
- There's now an option to force reset the admin password (#748) optionally on every restart (#959)
|
||||||
|
- There's a rootless docker container (#755)
|
||||||
|
- entryDN is now supported (#780)
|
||||||
|
- Unknown LDAP controls are now detected and ignored (#787, #799)
|
||||||
|
- A community-developed CLI for scripting (#793)
|
||||||
|
- Added a way to print raw logs to debug long-running sessions (#992)
|
||||||
|
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The official docker repository is now `lldap/lldap`
|
||||||
|
- Removed password length limitation in lldap_set_password tool
|
||||||
|
- Group names and emails are now case insensitive, but keep their casing (#666)
|
||||||
|
- Better error messages (and exit code (#745)) when changing the private key (#778, #1008), using the wrong SMTP port (#970), using the wrong env variables (#972)
|
||||||
|
- Allow `member=` filters with plain user names (not full DNs) (#949)
|
||||||
|
- Correctly detect and refuse anonymous binds (#974)
|
||||||
|
- Clearer logging (#971, #981, #982)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Logging out applies globally, not just in the local browser. (#721)
|
||||||
|
- It's no longer possible to create the same user twice (#745)
|
||||||
|
- Fix wide substring filters (#738)
|
||||||
|
- Don't log the database password if provided in the connection URL (#735)
|
||||||
|
- Fix a panic when postgres uses a different collation (#821)
|
||||||
|
- The UI now defaults to the user ID for users with no display names (#843)
|
||||||
|
- Fix searching for users with more than one `memberOf` filter (#872)
|
||||||
|
- Fix compilation on Windows (#932) and Illumos (#964)
|
||||||
|
- The UI now correctly detects whether password resets are enabled. (#753)
|
||||||
|
- Fix a missing lowercasing of username when changing passwords through LDAP (#1012)
|
||||||
|
- Fix SQLite writers erroring when racing (#1021)
|
||||||
|
- LDAP sessions no longer buffer their logs until unbind, causing memory leaks (#1025)
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Only expand attributes once per query, not per result (#687)
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- When asked to send a password reset to an unknown email, sleep for 3 seconds and don't print the email in the error (#887)
|
||||||
|
|
||||||
|
### New services
|
||||||
|
|
||||||
|
Linux user accounts can now be managed by LLDAP, using PAM and nslcd.
|
||||||
|
|
||||||
|
- Apereo CAS server
|
||||||
|
- Carpal
|
||||||
|
- Gitlab
|
||||||
|
- Grocy
|
||||||
|
- Harbor
|
||||||
|
- Home Assistant
|
||||||
|
- Jenkins
|
||||||
|
- Kasm
|
||||||
|
- Maddy
|
||||||
|
- Mastodon
|
||||||
|
- Metabase
|
||||||
|
- MegaRAC-BMC
|
||||||
|
- Netbox
|
||||||
|
- OCIS
|
||||||
|
- Prosody
|
||||||
|
- Radicale
|
||||||
|
- SonarQube
|
||||||
|
- Traccar
|
||||||
|
- Zitadel
|
||||||
|
|
||||||
## [0.5.0] 2023-09-14
|
## [0.5.0] 2023-09-14
|
||||||
|
|
||||||
### Breaking
|
### Breaking
|
||||||
@@ -71,7 +172,7 @@ systems, including PAM authentication.
|
|||||||
## [0.4.3] 2023-04-11
|
## [0.4.3] 2023-04-11
|
||||||
|
|
||||||
The repository has changed from `nitnelave/lldap` to `lldap/lldap`, both on GitHub
|
The repository has changed from `nitnelave/lldap` to `lldap/lldap`, both on GitHub
|
||||||
and on DockerHub (although we will keep publishing the images to
|
and on DockerHub (although we will keep publishing the images to
|
||||||
`nitnelave/lldap` for the foreseeable future). All data on GitHub has been
|
`nitnelave/lldap` for the foreseeable future). All data on GitHub has been
|
||||||
migrated, and the new docker images are available both on DockerHub and on the
|
migrated, and the new docker images are available both on DockerHub and on the
|
||||||
GHCR under `lldap/lldap`.
|
GHCR under `lldap/lldap`.
|
||||||
|
|||||||
2346
Cargo.lock
generated
2346
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -17,9 +17,5 @@ lto = true
|
|||||||
[profile.release.package.lldap_app]
|
[profile.release.package.lldap_app]
|
||||||
opt-level = 's'
|
opt-level = 's'
|
||||||
|
|
||||||
[patch.crates-io.opaque-ke]
|
|
||||||
git = 'https://github.com/nitnelave/opaque-ke/'
|
|
||||||
branch = 'zeroize_1.5'
|
|
||||||
|
|
||||||
[patch.crates-io.lber]
|
[patch.crates-io.lber]
|
||||||
git = 'https://github.com/inejge/ldap3/'
|
git = 'https://github.com/inejge/ldap3/'
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ RUN cargo build --release -p lldap -p lldap_migration_tool -p lldap_set_password
|
|||||||
&& ./app/build.sh
|
&& ./app/build.sh
|
||||||
|
|
||||||
# Final image
|
# Final image
|
||||||
FROM alpine:3.16
|
FROM alpine:3.19
|
||||||
|
|
||||||
ENV GOSU_VERSION 1.14
|
ENV GOSU_VERSION 1.14
|
||||||
# Fetch gosu from git
|
# Fetch gosu from git
|
||||||
@@ -80,6 +80,7 @@ COPY --from=builder /app/app/static app/static
|
|||||||
COPY --from=builder /app/app/pkg app/pkg
|
COPY --from=builder /app/app/pkg app/pkg
|
||||||
COPY --from=builder /app/target/release/lldap /app/target/release/lldap_migration_tool /app/target/release/lldap_set_password ./
|
COPY --from=builder /app/target/release/lldap /app/target/release/lldap_migration_tool /app/target/release/lldap_set_password ./
|
||||||
COPY docker-entrypoint.sh lldap_config.docker_template.toml ./
|
COPY docker-entrypoint.sh lldap_config.docker_template.toml ./
|
||||||
|
COPY scripts/bootstrap.sh ./
|
||||||
|
|
||||||
RUN set -x \
|
RUN set -x \
|
||||||
&& apk add --no-cache bash tzdata \
|
&& apk add --no-cache bash tzdata \
|
||||||
|
|||||||
286
README.md
286
README.md
@@ -38,6 +38,7 @@
|
|||||||
- [With Docker](#with-docker)
|
- [With Docker](#with-docker)
|
||||||
- [With Kubernetes](#with-kubernetes)
|
- [With Kubernetes](#with-kubernetes)
|
||||||
- [From a package repository](#from-a-package-repository)
|
- [From a package repository](#from-a-package-repository)
|
||||||
|
- [With FreeBSD](#with-freebsd)
|
||||||
- [From source](#from-source)
|
- [From source](#from-source)
|
||||||
- [Backend](#backend)
|
- [Backend](#backend)
|
||||||
- [Frontend](#frontend)
|
- [Frontend](#frontend)
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
- [Client configuration](#client-configuration)
|
- [Client configuration](#client-configuration)
|
||||||
- [Compatible services](#compatible-services)
|
- [Compatible services](#compatible-services)
|
||||||
- [General configuration guide](#general-configuration-guide)
|
- [General configuration guide](#general-configuration-guide)
|
||||||
|
- [Integration with OS's](#integration-with-oss)
|
||||||
- [Sample client configurations](#sample-client-configurations)
|
- [Sample client configurations](#sample-client-configurations)
|
||||||
- [Incompatible services](#incompatible-services)
|
- [Incompatible services](#incompatible-services)
|
||||||
- [Migrating from SQLite](#migrating-from-sqlite)
|
- [Migrating from SQLite](#migrating-from-sqlite)
|
||||||
@@ -154,6 +156,7 @@ services:
|
|||||||
- LLDAP_JWT_SECRET=REPLACE_WITH_RANDOM
|
- LLDAP_JWT_SECRET=REPLACE_WITH_RANDOM
|
||||||
- LLDAP_KEY_SEED=REPLACE_WITH_RANDOM
|
- LLDAP_KEY_SEED=REPLACE_WITH_RANDOM
|
||||||
- LLDAP_LDAP_BASE_DN=dc=example,dc=com
|
- LLDAP_LDAP_BASE_DN=dc=example,dc=com
|
||||||
|
- LLDAP_LDAP_USER_PASS=adminPas$word
|
||||||
# If using LDAPS, set enabled true and configure cert and key path
|
# If using LDAPS, set enabled true and configure cert and key path
|
||||||
# - LLDAP_LDAPS_OPTIONS__ENABLED=true
|
# - LLDAP_LDAPS_OPTIONS__ENABLED=true
|
||||||
# - LLDAP_LDAPS_OPTIONS__CERT_FILE=/path/to/certfile.crt
|
# - LLDAP_LDAPS_OPTIONS__CERT_FILE=/path/to/certfile.crt
|
||||||
@@ -161,6 +164,15 @@ services:
|
|||||||
# You can also set a different database:
|
# You can also set a different database:
|
||||||
# - LLDAP_DATABASE_URL=mysql://mysql-user:password@mysql-server/my-database
|
# - LLDAP_DATABASE_URL=mysql://mysql-user:password@mysql-server/my-database
|
||||||
# - LLDAP_DATABASE_URL=postgres://postgres-user:password@postgres-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
|
Then the service will listen on two ports, one for LDAP and one for the web
|
||||||
@@ -183,30 +195,229 @@ Depending on the distribution you use, it might be possible to install lldap
|
|||||||
from a package repository, officially supported by the distribution or
|
from a package repository, officially supported by the distribution or
|
||||||
community contributed.
|
community contributed.
|
||||||
|
|
||||||
#### Debian, CentOS Fedora, OpenSUSE, Ubuntu
|
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.
|
||||||
|
|
||||||
The package for these distributions can be found at [LLDAP OBS](https://software.opensuse.org//download.html?project=home%3AMasgalor%3ALLDAP&package=lldap).
|
<details>
|
||||||
- When using the distributed package, the default login is `admin/password`. You can change that from the web UI after starting the service.
|
<summary><b>Arch Linux</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>
|
||||||
|
Support: <a href="https://github.com/lldap/lldap/discussions/1044">Discussions</a><br>
|
||||||
|
Package repository: <a href="https://aur.archlinux.org/packages">Arch User Repository</a><br><br>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td>Package name</td>
|
||||||
|
<td>Maintainer</td>
|
||||||
|
<td>Description</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="https://aur.archlinux.org/packages/lldap">lldap</a></td>
|
||||||
|
<td><a href="https://github.com/Zepmann">@Zepmann</a></td>
|
||||||
|
<td>Builds the latest stable version.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="https://aur.archlinux.org/packages/lldap-bin">lldap-bin</a></td>
|
||||||
|
<td><a href="https://github.com/Zepmann">@Zepmann</a></td>
|
||||||
|
<td>Uses the latest pre-compiled binaries from the <a href="https://github.com/lldap/lldap/releases">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><a href="https://aur.archlinux.org/packages/lldap-git">lldap-git</a></td>
|
||||||
|
<td></td>
|
||||||
|
<td>Builds the latest main branch code.</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
LLDAP 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>
|
||||||
|
|
||||||
#### Arch Linux
|
### With FreeBSD
|
||||||
|
|
||||||
Arch Linux offers unofficial support through the [Arch User Repository
|
You can also install it as a rc.d service in FreeBSD, see
|
||||||
(AUR)](https://wiki.archlinux.org/title/Arch_User_Repository).
|
[FreeBSD-install.md](example_configs/freebsd/freebsd-install.md).
|
||||||
Available package descriptions in AUR are:
|
|
||||||
|
|
||||||
- [lldap](https://aur.archlinux.org/packages/lldap) - Builds the latest stable version.
|
The rc.d script file
|
||||||
- [lldap-bin](https://aur.archlinux.org/packages/lldap-bin) - Uses the latest
|
[rc.d_lldap](example_configs/freebsd/rc.d_lldap).
|
||||||
pre-compiled binaries from the [releases in this repository](https://github.com/lldap/lldap/releases).
|
|
||||||
This package is recommended if you want to run lldap on a system with
|
|
||||||
limited resources.
|
|
||||||
- [lldap-git](https://aur.archlinux.org/packages/lldap-git) - Builds the
|
|
||||||
latest main branch code.
|
|
||||||
|
|
||||||
The package descriptions can be used
|
|
||||||
[to create and install packages](https://wiki.archlinux.org/title/Arch_User_Repository#Getting_started).
|
|
||||||
Each package places lldap's configuration file at `/etc/lldap.toml` and offers
|
|
||||||
[systemd service](https://wiki.archlinux.org/title/systemd#Using_units)
|
|
||||||
`lldap.service` to (auto-)start and stop lldap.
|
|
||||||
|
|
||||||
### From source
|
### From source
|
||||||
|
|
||||||
@@ -277,10 +488,16 @@ create users, set passwords, add them to groups and so on. Users can also
|
|||||||
connect to the web UI and change their information, or request a password reset
|
connect to the web UI and change their information, or request a password reset
|
||||||
link (if you configured the SMTP client).
|
link (if you configured the SMTP client).
|
||||||
|
|
||||||
Creating and managing custom attributes is currently in Beta. It's not
|
You can create and manage custom attributes through the Web UI, or through the
|
||||||
supported in the Web UI. The recommended way is to use
|
community-contributed CLI frontend (
|
||||||
[Zepmann/lldap-cli](https://github.com/Zepmann/lldap-cli), a
|
[Zepmann/lldap-cli](https://github.com/Zepmann/lldap-cli)). This is necessary
|
||||||
community-contributed CLI frontend.
|
for some service integrations.
|
||||||
|
|
||||||
|
The [bootstrap.sh](scripts/bootstrap.sh) script can enforce a list of
|
||||||
|
users/groups/attributes from a given file, reflecting it on the server.
|
||||||
|
|
||||||
|
To manage the user, group and membership lifecycle in an infrastructure-as-code
|
||||||
|
scenario you can use the unofficial [LLDAP terraform provider in the terraform registry](https://registry.terraform.io/providers/tasansga/lldap/latest).
|
||||||
|
|
||||||
LLDAP is also very scriptable, through its GraphQL API. See the
|
LLDAP is also very scriptable, through its GraphQL API. See the
|
||||||
[Scripting](docs/scripting.md) docs for more info.
|
[Scripting](docs/scripting.md) docs for more info.
|
||||||
@@ -342,6 +559,13 @@ 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
|
the `lldap_strict_readonly` or `lldap_password_manager` group, to avoid granting full
|
||||||
administration access to many services.
|
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
|
### Sample client configurations
|
||||||
|
|
||||||
Some specific clients have been tested to work and come with sample
|
Some specific clients have been tested to work and come with sample
|
||||||
@@ -355,6 +579,7 @@ folder for help with:
|
|||||||
- [Authentik](example_configs/authentik.md)
|
- [Authentik](example_configs/authentik.md)
|
||||||
- [Bookstack](example_configs/bookstack.env.example)
|
- [Bookstack](example_configs/bookstack.env.example)
|
||||||
- [Calibre-Web](example_configs/calibre_web.md)
|
- [Calibre-Web](example_configs/calibre_web.md)
|
||||||
|
- [Carpal](example_configs/carpal.md)
|
||||||
- [Dell iDRAC](example_configs/dell_idrac.md)
|
- [Dell iDRAC](example_configs/dell_idrac.md)
|
||||||
- [Dex](example_configs/dex_config.yml)
|
- [Dex](example_configs/dex_config.yml)
|
||||||
- [Dokuwiki](example_configs/dokuwiki.md)
|
- [Dokuwiki](example_configs/dokuwiki.md)
|
||||||
@@ -366,6 +591,8 @@ folder for help with:
|
|||||||
- [GitLab](example_configs/gitlab.md)
|
- [GitLab](example_configs/gitlab.md)
|
||||||
- [Grafana](example_configs/grafana_ldap_config.toml)
|
- [Grafana](example_configs/grafana_ldap_config.toml)
|
||||||
- [Grocy](example_configs/grocy.md)
|
- [Grocy](example_configs/grocy.md)
|
||||||
|
- [Harbor](example_configs/harbor.md)
|
||||||
|
- [HashiCorp Vault](example_configs/hashicorp-vault.md)
|
||||||
- [Hedgedoc](example_configs/hedgedoc.md)
|
- [Hedgedoc](example_configs/hedgedoc.md)
|
||||||
- [Home Assistant](example_configs/home-assistant.md)
|
- [Home Assistant](example_configs/home-assistant.md)
|
||||||
- [Jellyfin](example_configs/jellyfin.md)
|
- [Jellyfin](example_configs/jellyfin.md)
|
||||||
@@ -373,22 +600,30 @@ folder for help with:
|
|||||||
- [Jitsi Meet](example_configs/jitsi_meet.conf)
|
- [Jitsi Meet](example_configs/jitsi_meet.conf)
|
||||||
- [Kasm](example_configs/kasm.md)
|
- [Kasm](example_configs/kasm.md)
|
||||||
- [KeyCloak](example_configs/keycloak.md)
|
- [KeyCloak](example_configs/keycloak.md)
|
||||||
|
- [Kimai](example_configs/kimai.yaml)
|
||||||
- [LibreNMS](example_configs/librenms.md)
|
- [LibreNMS](example_configs/librenms.md)
|
||||||
- [Maddy](example_configs/maddy.md)
|
- [Maddy](example_configs/maddy.md)
|
||||||
- [Mastodon](example_configs/mastodon.env.example)
|
- [Mastodon](example_configs/mastodon.env.example)
|
||||||
- [Matrix](example_configs/matrix_synapse.yml)
|
- [Matrix](example_configs/matrix_synapse.yml)
|
||||||
- [Mealie](example_configs/mealie.md)
|
- [Mealie](example_configs/mealie.md)
|
||||||
|
- [Metabase](example_configs/metabase.md)
|
||||||
|
- [MegaRAC-BMC](example_configs/MegaRAC-SP-X-BMC.md)
|
||||||
- [MinIO](example_configs/minio.md)
|
- [MinIO](example_configs/minio.md)
|
||||||
|
- [Netbox](example_configs/netbox.md)
|
||||||
- [Nextcloud](example_configs/nextcloud.md)
|
- [Nextcloud](example_configs/nextcloud.md)
|
||||||
- [Nexus](example_configs/nexus.md)
|
- [Nexus](example_configs/nexus.md)
|
||||||
|
- [OCIS (OwnCloud Infinite Scale)](example_configs/ocis.md)
|
||||||
|
- [OneDev](example_configs/onedev.md)
|
||||||
- [Organizr](example_configs/Organizr.md)
|
- [Organizr](example_configs/Organizr.md)
|
||||||
- [Portainer](example_configs/portainer.md)
|
- [Portainer](example_configs/portainer.md)
|
||||||
- [PowerDNS Admin](example_configs/powerdns_admin.md)
|
- [PowerDNS Admin](example_configs/powerdns_admin.md)
|
||||||
|
- [Prosody](example_configs/prosody.md)
|
||||||
- [Proxmox VE](example_configs/proxmox.md)
|
- [Proxmox VE](example_configs/proxmox.md)
|
||||||
- [Radicale](example_configs/radicale.md)
|
- [Radicale](example_configs/radicale.md)
|
||||||
- [Rancher](example_configs/rancher.md)
|
- [Rancher](example_configs/rancher.md)
|
||||||
- [Seafile](example_configs/seafile.md)
|
- [Seafile](example_configs/seafile.md)
|
||||||
- [Shaarli](example_configs/shaarli.md)
|
- [Shaarli](example_configs/shaarli.md)
|
||||||
|
- [SonarQube](example_configs/sonarqube.md)
|
||||||
- [Squid](example_configs/squid.md)
|
- [Squid](example_configs/squid.md)
|
||||||
- [Syncthing](example_configs/syncthing.md)
|
- [Syncthing](example_configs/syncthing.md)
|
||||||
- [TheLounge](example_configs/thelounge.md)
|
- [TheLounge](example_configs/thelounge.md)
|
||||||
@@ -479,6 +714,9 @@ modern identity protocols, check out Kanidm.
|
|||||||
If you just set up the server, can get to the login page but the password you
|
If you just set up the server, can get to the login page but the password you
|
||||||
set isn't working, try the following:
|
set isn't working, try the following:
|
||||||
|
|
||||||
|
- If you have changed the admin password in the config after the first run, it
|
||||||
|
won't be used (unless you force its use with `force_ldap_user_pass_reset`).
|
||||||
|
The config password is only for the initial admin creation.
|
||||||
- (For docker): Make sure that the `/data` folder is persistent, either to a
|
- (For docker): Make sure that the `/data` folder is persistent, either to a
|
||||||
docker volume or mounted from the host filesystem.
|
docker volume or mounted from the host filesystem.
|
||||||
- Check if there is a `lldap_config.toml` file (either in `/data` for docker
|
- Check if there is a `lldap_config.toml` file (either in `/data` for docker
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ homepage = "https://github.com/lldap/lldap"
|
|||||||
license = "GPL-3.0-only"
|
license = "GPL-3.0-only"
|
||||||
name = "lldap_app"
|
name = "lldap_app"
|
||||||
repository = "https://github.com/lldap/lldap"
|
repository = "https://github.com/lldap/lldap"
|
||||||
version = "0.5.1-alpha"
|
version = "0.6.2-alpha"
|
||||||
include = ["src/**/*", "queries/**/*", "Cargo.toml", "../schema.graphql"]
|
include = ["src/**/*", "queries/**/*", "Cargo.toml", "../schema.graphql"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -22,8 +22,8 @@ rand = "0.8"
|
|||||||
serde = "1"
|
serde = "1"
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
url-escape = "0.1.1"
|
url-escape = "0.1.1"
|
||||||
validator = "=0.14"
|
validator = "0.14"
|
||||||
validator_derive = "*"
|
validator_derive = "0.14"
|
||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
wasm-bindgen-futures = "*"
|
wasm-bindgen-futures = "*"
|
||||||
yew = "0.19.3"
|
yew = "0.19.3"
|
||||||
@@ -75,3 +75,10 @@ rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9"
|
|||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
crate-type = ["cdylib"]
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
|
[package.metadata.wasm-pack.profile.dev]
|
||||||
|
wasm-opt = ['--enable-bulk-memory']
|
||||||
|
[package.metadata.wasm-pack.profile.profiling]
|
||||||
|
wasm-opt = ['--enable-bulk-memory']
|
||||||
|
[package.metadata.wasm-pack.profile.release]
|
||||||
|
wasm-opt = ['--enable-bulk-memory']
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
mutation CreateGroup($name: String!) {
|
mutation CreateGroup($group: CreateGroupInput!) {
|
||||||
createGroup(name: $name) {
|
createGroupWithDetails(request: $group) {
|
||||||
id
|
id
|
||||||
displayName
|
displayName
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ query GetGroupAttributesSchema {
|
|||||||
isList
|
isList
|
||||||
isVisible
|
isVisible
|
||||||
isHardcoded
|
isHardcoded
|
||||||
|
isReadonly
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,5 +8,22 @@ query GetGroupDetails($id: Int!) {
|
|||||||
id
|
id
|
||||||
displayName
|
displayName
|
||||||
}
|
}
|
||||||
|
attributes {
|
||||||
|
name
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
schema {
|
||||||
|
groupSchema {
|
||||||
|
attributes {
|
||||||
|
name
|
||||||
|
attributeType
|
||||||
|
isList
|
||||||
|
isVisible
|
||||||
|
isEditable
|
||||||
|
isHardcoded
|
||||||
|
isReadonly
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ query GetUserAttributesSchema {
|
|||||||
isVisible
|
isVisible
|
||||||
isEditable
|
isEditable
|
||||||
isHardcoded
|
isHardcoded
|
||||||
|
isReadonly
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ query GetUserDetails($id: String!) {
|
|||||||
user(userId: $id) {
|
user(userId: $id) {
|
||||||
id
|
id
|
||||||
email
|
email
|
||||||
displayName
|
|
||||||
firstName
|
|
||||||
lastName
|
|
||||||
avatar
|
avatar
|
||||||
|
displayName
|
||||||
creationDate
|
creationDate
|
||||||
uuid
|
uuid
|
||||||
groups {
|
groups {
|
||||||
@@ -15,13 +13,18 @@ query GetUserDetails($id: String!) {
|
|||||||
attributes {
|
attributes {
|
||||||
name
|
name
|
||||||
value
|
value
|
||||||
schema {
|
}
|
||||||
|
}
|
||||||
|
schema {
|
||||||
|
userSchema {
|
||||||
|
attributes {
|
||||||
name
|
name
|
||||||
attributeType
|
attributeType
|
||||||
isList
|
isList
|
||||||
isVisible
|
isVisible
|
||||||
isEditable
|
isEditable
|
||||||
isHardcoded
|
isHardcoded
|
||||||
|
isReadonly
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
app/queries/update_group.graphql
Normal file
6
app/queries/update_group.graphql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
mutation UpdateGroup($group: UpdateGroupInput!) {
|
||||||
|
updateGroup(group: $group) {
|
||||||
|
ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -155,8 +155,13 @@ impl Component for AddGroupMemberComponent {
|
|||||||
let to_add_user_list = self.get_selectable_user_list(ctx, user_list);
|
let to_add_user_list = self.get_selectable_user_list(ctx, user_list);
|
||||||
#[allow(unused_braces)]
|
#[allow(unused_braces)]
|
||||||
let make_select_option = |user: User| {
|
let make_select_option = |user: User| {
|
||||||
|
let name = if user.display_name.is_empty() {
|
||||||
|
user.id.clone()
|
||||||
|
} else {
|
||||||
|
user.display_name.clone()
|
||||||
|
};
|
||||||
html_nested! {
|
html_nested! {
|
||||||
<SelectOption value={user.id.clone()} text={user.display_name.clone()} key={user.id} />
|
<SelectOption value={user.id.clone()} text={name} key={user.id} />
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
html! {
|
html! {
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ impl App {
|
|||||||
<ListGroupSchema />
|
<ListGroupSchema />
|
||||||
},
|
},
|
||||||
AppRoute::GroupDetails { group_id } => html! {
|
AppRoute::GroupDetails { group_id } => html! {
|
||||||
<GroupDetails group_id={*group_id} />
|
<GroupDetails group_id={*group_id} is_admin={is_admin} />
|
||||||
},
|
},
|
||||||
AppRoute::UserDetails { user_id } => html! {
|
AppRoute::UserDetails { user_id } => html! {
|
||||||
<UserDetails username={user_id.clone()} is_admin={is_admin} />
|
<UserDetails username={user_id.clone()} is_admin={is_admin} />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use yew::{function_component, html, virtual_dom::AttrValue, Properties};
|
|||||||
#[graphql(
|
#[graphql(
|
||||||
schema_path = "../schema.graphql",
|
schema_path = "../schema.graphql",
|
||||||
query_path = "queries/get_user_details.graphql",
|
query_path = "queries/get_user_details.graphql",
|
||||||
|
variables_derives = "Clone,PartialEq,Eq",
|
||||||
response_derives = "Debug, Hash, PartialEq, Eq, Clone",
|
response_derives = "Debug, Hash, PartialEq, Eq, Clone",
|
||||||
custom_scalars_module = "crate::infra::graphql"
|
custom_scalars_module = "crate::infra::graphql"
|
||||||
)]
|
)]
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::{
|
components::{
|
||||||
form::{field::Field, submit::Submit},
|
form::{
|
||||||
|
attribute_input::{ListAttributeInput, SingleAttributeInput},
|
||||||
|
field::Field,
|
||||||
|
submit::Submit,
|
||||||
|
},
|
||||||
router::AppRoute,
|
router::AppRoute,
|
||||||
},
|
},
|
||||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
convert_attribute_type,
|
||||||
|
infra::{
|
||||||
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
form_utils::{
|
||||||
|
read_all_form_attributes, AttributeValue, EmailIsRequired, GraphQlAttributeSchema,
|
||||||
|
IsAdmin,
|
||||||
|
},
|
||||||
|
schema::AttributeType,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Result};
|
use anyhow::{ensure, Result};
|
||||||
use gloo_console::log;
|
use gloo_console::log;
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use validator_derive::Validate;
|
use validator_derive::Validate;
|
||||||
@@ -13,6 +25,33 @@ use yew::prelude::*;
|
|||||||
use yew_form_derive::Model;
|
use yew_form_derive::Model;
|
||||||
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||||
|
|
||||||
|
#[derive(GraphQLQuery)]
|
||||||
|
#[graphql(
|
||||||
|
schema_path = "../schema.graphql",
|
||||||
|
query_path = "queries/get_group_attributes_schema.graphql",
|
||||||
|
response_derives = "Debug,Clone,PartialEq,Eq",
|
||||||
|
custom_scalars_module = "crate::infra::graphql"
|
||||||
|
)]
|
||||||
|
pub struct GetGroupAttributesSchema;
|
||||||
|
|
||||||
|
use get_group_attributes_schema::ResponseData;
|
||||||
|
|
||||||
|
pub type Attribute =
|
||||||
|
get_group_attributes_schema::GetGroupAttributesSchemaSchemaGroupSchemaAttributes;
|
||||||
|
|
||||||
|
convert_attribute_type!(get_group_attributes_schema::AttributeType);
|
||||||
|
|
||||||
|
impl From<&Attribute> for GraphQlAttributeSchema {
|
||||||
|
fn from(attr: &Attribute) -> Self {
|
||||||
|
Self {
|
||||||
|
name: attr.name.clone(),
|
||||||
|
is_list: attr.is_list,
|
||||||
|
is_readonly: attr.is_readonly,
|
||||||
|
is_editable: false, // Need to be admin to edit it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(GraphQLQuery)]
|
#[derive(GraphQLQuery)]
|
||||||
#[graphql(
|
#[graphql(
|
||||||
schema_path = "../schema.graphql",
|
schema_path = "../schema.graphql",
|
||||||
@@ -25,6 +64,8 @@ pub struct CreateGroup;
|
|||||||
pub struct CreateGroupForm {
|
pub struct CreateGroupForm {
|
||||||
common: CommonComponentParts<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
form: yew_form::Form<CreateGroupModel>,
|
form: yew_form::Form<CreateGroupModel>,
|
||||||
|
attributes_schema: Option<Vec<Attribute>>,
|
||||||
|
form_ref: NodeRef,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||||
@@ -35,6 +76,7 @@ pub struct CreateGroupModel {
|
|||||||
|
|
||||||
pub enum Msg {
|
pub enum Msg {
|
||||||
Update,
|
Update,
|
||||||
|
ListAttributesResponse(Result<ResponseData>),
|
||||||
SubmitForm,
|
SubmitForm,
|
||||||
CreateGroupResponse(Result<create_group::ResponseData>),
|
CreateGroupResponse(Result<create_group::ResponseData>),
|
||||||
}
|
}
|
||||||
@@ -48,12 +90,33 @@ impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
|||||||
match msg {
|
match msg {
|
||||||
Msg::Update => Ok(true),
|
Msg::Update => Ok(true),
|
||||||
Msg::SubmitForm => {
|
Msg::SubmitForm => {
|
||||||
if !self.form.validate() {
|
ensure!(self.form.validate(), "Check the form for errors");
|
||||||
bail!("Check the form for errors");
|
|
||||||
}
|
let all_values = read_all_form_attributes(
|
||||||
|
self.attributes_schema.iter().flatten(),
|
||||||
|
&self.form_ref,
|
||||||
|
IsAdmin(true),
|
||||||
|
EmailIsRequired(false),
|
||||||
|
)?;
|
||||||
|
let attributes = Some(
|
||||||
|
all_values
|
||||||
|
.into_iter()
|
||||||
|
.filter(|a| !a.values.is_empty())
|
||||||
|
.map(
|
||||||
|
|AttributeValue { name, values }| create_group::AttributeValueInput {
|
||||||
|
name,
|
||||||
|
value: values,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
|
||||||
let model = self.form.model();
|
let model = self.form.model();
|
||||||
let req = create_group::Variables {
|
let req = create_group::Variables {
|
||||||
name: model.groupname,
|
group: create_group::CreateGroupInput {
|
||||||
|
displayName: model.groupname,
|
||||||
|
attributes,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
self.common.call_graphql::<CreateGroup, _>(
|
self.common.call_graphql::<CreateGroup, _>(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -66,11 +129,16 @@ impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
|||||||
Msg::CreateGroupResponse(response) => {
|
Msg::CreateGroupResponse(response) => {
|
||||||
log!(&format!(
|
log!(&format!(
|
||||||
"Created group '{}'",
|
"Created group '{}'",
|
||||||
&response?.create_group.display_name
|
&response?.create_group_with_details.display_name
|
||||||
));
|
));
|
||||||
ctx.link().history().unwrap().push(AppRoute::ListGroups);
|
ctx.link().history().unwrap().push(AppRoute::ListGroups);
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
Msg::ListAttributesResponse(schema) => {
|
||||||
|
self.attributes_schema =
|
||||||
|
Some(schema?.schema.group_schema.attributes.into_iter().collect());
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,11 +151,22 @@ impl Component for CreateGroupForm {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = ();
|
type Properties = ();
|
||||||
|
|
||||||
fn create(_: &Context<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
Self {
|
let mut component = Self {
|
||||||
common: CommonComponentParts::<Self>::create(),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
form: yew_form::Form::<CreateGroupModel>::new(CreateGroupModel::default()),
|
form: yew_form::Form::<CreateGroupModel>::new(CreateGroupModel::default()),
|
||||||
}
|
attributes_schema: None,
|
||||||
|
form_ref: NodeRef::default(),
|
||||||
|
};
|
||||||
|
component
|
||||||
|
.common
|
||||||
|
.call_graphql::<GetGroupAttributesSchema, _>(
|
||||||
|
ctx,
|
||||||
|
get_group_attributes_schema::Variables {},
|
||||||
|
Msg::ListAttributesResponse,
|
||||||
|
"Error trying to fetch group schema",
|
||||||
|
);
|
||||||
|
component
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
@@ -98,7 +177,8 @@ impl Component for CreateGroupForm {
|
|||||||
let link = ctx.link();
|
let link = ctx.link();
|
||||||
html! {
|
html! {
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<form class="form py-3" style="max-width: 636px">
|
<form class="form py-3" style="max-width: 636px"
|
||||||
|
ref={self.form_ref.clone()}>
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<h5 class="fw-bold">{"Create a group"}</h5>
|
<h5 class="fw-bold">{"Create a group"}</h5>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,6 +188,14 @@ impl Component for CreateGroupForm {
|
|||||||
label="Group name"
|
label="Group name"
|
||||||
field_name="groupname"
|
field_name="groupname"
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
|
{
|
||||||
|
self.attributes_schema
|
||||||
|
.iter()
|
||||||
|
.flatten()
|
||||||
|
.filter(|a| !a.is_readonly && a.name != "display_name")
|
||||||
|
.map(get_custom_attribute_input)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
}
|
||||||
<Submit
|
<Submit
|
||||||
disabled={self.common.is_task_running()}
|
disabled={self.common.is_task_running()}
|
||||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})} />
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})} />
|
||||||
@@ -124,3 +212,21 @@ impl Component for CreateGroupForm {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_custom_attribute_input(attribute_schema: &Attribute) -> Html {
|
||||||
|
if attribute_schema.is_list {
|
||||||
|
html! {
|
||||||
|
<ListAttributeInput
|
||||||
|
name={attribute_schema.name.clone()}
|
||||||
|
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {
|
||||||
|
<SingleAttributeInput
|
||||||
|
name={attribute_schema.name.clone()}
|
||||||
|
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::{
|
components::{
|
||||||
form::{field::Field, submit::Submit},
|
form::{
|
||||||
|
attribute_input::{ListAttributeInput, SingleAttributeInput},
|
||||||
|
field::Field,
|
||||||
|
submit::Submit,
|
||||||
|
},
|
||||||
router::AppRoute,
|
router::AppRoute,
|
||||||
},
|
},
|
||||||
|
convert_attribute_type,
|
||||||
infra::{
|
infra::{
|
||||||
api::HostService,
|
api::HostService,
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
form_utils::{
|
||||||
|
read_all_form_attributes, AttributeValue, EmailIsRequired, GraphQlAttributeSchema,
|
||||||
|
IsAdmin,
|
||||||
|
},
|
||||||
|
schema::AttributeType,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Result};
|
use anyhow::{ensure, Result};
|
||||||
use gloo_console::log;
|
use gloo_console::log;
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use lldap_auth::{opaque, registration};
|
use lldap_auth::{opaque, registration};
|
||||||
@@ -17,6 +27,32 @@ use yew::prelude::*;
|
|||||||
use yew_form_derive::Model;
|
use yew_form_derive::Model;
|
||||||
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||||
|
|
||||||
|
#[derive(GraphQLQuery)]
|
||||||
|
#[graphql(
|
||||||
|
schema_path = "../schema.graphql",
|
||||||
|
query_path = "queries/get_user_attributes_schema.graphql",
|
||||||
|
response_derives = "Debug,Clone,PartialEq,Eq",
|
||||||
|
custom_scalars_module = "crate::infra::graphql"
|
||||||
|
)]
|
||||||
|
pub struct GetUserAttributesSchema;
|
||||||
|
|
||||||
|
use get_user_attributes_schema::ResponseData;
|
||||||
|
|
||||||
|
pub type Attribute = get_user_attributes_schema::GetUserAttributesSchemaSchemaUserSchemaAttributes;
|
||||||
|
|
||||||
|
convert_attribute_type!(get_user_attributes_schema::AttributeType);
|
||||||
|
|
||||||
|
impl From<&Attribute> for GraphQlAttributeSchema {
|
||||||
|
fn from(attr: &Attribute) -> Self {
|
||||||
|
Self {
|
||||||
|
name: attr.name.clone(),
|
||||||
|
is_list: attr.is_list,
|
||||||
|
is_readonly: attr.is_readonly,
|
||||||
|
is_editable: attr.is_editable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(GraphQLQuery)]
|
#[derive(GraphQLQuery)]
|
||||||
#[graphql(
|
#[graphql(
|
||||||
schema_path = "../schema.graphql",
|
schema_path = "../schema.graphql",
|
||||||
@@ -29,17 +65,14 @@ pub struct CreateUser;
|
|||||||
pub struct CreateUserForm {
|
pub struct CreateUserForm {
|
||||||
common: CommonComponentParts<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
form: yew_form::Form<CreateUserModel>,
|
form: yew_form::Form<CreateUserModel>,
|
||||||
|
attributes_schema: Option<Vec<Attribute>>,
|
||||||
|
form_ref: NodeRef,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||||
pub struct CreateUserModel {
|
pub struct CreateUserModel {
|
||||||
#[validate(length(min = 1, message = "Username is required"))]
|
#[validate(length(min = 1, message = "Username is required"))]
|
||||||
username: String,
|
username: String,
|
||||||
#[validate(email(message = "A valid email is required"))]
|
|
||||||
email: String,
|
|
||||||
display_name: String,
|
|
||||||
first_name: String,
|
|
||||||
last_name: String,
|
|
||||||
#[validate(custom(
|
#[validate(custom(
|
||||||
function = "empty_or_long",
|
function = "empty_or_long",
|
||||||
message = "Password should be longer than 8 characters (or left empty)"
|
message = "Password should be longer than 8 characters (or left empty)"
|
||||||
@@ -59,6 +92,7 @@ fn empty_or_long(value: &str) -> Result<(), validator::ValidationError> {
|
|||||||
|
|
||||||
pub enum Msg {
|
pub enum Msg {
|
||||||
Update,
|
Update,
|
||||||
|
ListAttributesResponse(Result<ResponseData>),
|
||||||
SubmitForm,
|
SubmitForm,
|
||||||
CreateUserResponse(Result<create_user::ResponseData>),
|
CreateUserResponse(Result<create_user::ResponseData>),
|
||||||
SuccessfulCreation,
|
SuccessfulCreation,
|
||||||
@@ -79,21 +113,43 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
|||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::Update => Ok(true),
|
Msg::Update => Ok(true),
|
||||||
|
Msg::ListAttributesResponse(schema) => {
|
||||||
|
self.attributes_schema =
|
||||||
|
Some(schema?.schema.user_schema.attributes.into_iter().collect());
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
Msg::SubmitForm => {
|
Msg::SubmitForm => {
|
||||||
if !self.form.validate() {
|
ensure!(self.form.validate(), "Check the form for errors");
|
||||||
bail!("Check the form for errors");
|
|
||||||
}
|
let all_values = read_all_form_attributes(
|
||||||
|
self.attributes_schema.iter().flatten(),
|
||||||
|
&self.form_ref,
|
||||||
|
IsAdmin(true),
|
||||||
|
EmailIsRequired(true),
|
||||||
|
)?;
|
||||||
|
let attributes = Some(
|
||||||
|
all_values
|
||||||
|
.into_iter()
|
||||||
|
.filter(|a| !a.values.is_empty())
|
||||||
|
.map(
|
||||||
|
|AttributeValue { name, values }| create_user::AttributeValueInput {
|
||||||
|
name,
|
||||||
|
value: values,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
|
||||||
let model = self.form.model();
|
let model = self.form.model();
|
||||||
let to_option = |s: String| if s.is_empty() { None } else { Some(s) };
|
|
||||||
let req = create_user::Variables {
|
let req = create_user::Variables {
|
||||||
user: create_user::CreateUserInput {
|
user: create_user::CreateUserInput {
|
||||||
id: model.username,
|
id: model.username,
|
||||||
email: model.email,
|
email: None,
|
||||||
displayName: to_option(model.display_name),
|
displayName: None,
|
||||||
firstName: to_option(model.first_name),
|
firstName: None,
|
||||||
lastName: to_option(model.last_name),
|
lastName: None,
|
||||||
avatar: None,
|
avatar: None,
|
||||||
attributes: None,
|
attributes,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
self.common.call_graphql::<CreateUser, _>(
|
self.common.call_graphql::<CreateUser, _>(
|
||||||
@@ -177,11 +233,20 @@ impl Component for CreateUserForm {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = ();
|
type Properties = ();
|
||||||
|
|
||||||
fn create(_: &Context<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
Self {
|
let mut component = Self {
|
||||||
common: CommonComponentParts::<Self>::create(),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
form: yew_form::Form::<CreateUserModel>::new(CreateUserModel::default()),
|
form: yew_form::Form::<CreateUserModel>::new(CreateUserModel::default()),
|
||||||
}
|
attributes_schema: None,
|
||||||
|
form_ref: NodeRef::default(),
|
||||||
|
};
|
||||||
|
component.common.call_graphql::<GetUserAttributesSchema, _>(
|
||||||
|
ctx,
|
||||||
|
get_user_attributes_schema::Variables {},
|
||||||
|
Msg::ListAttributesResponse,
|
||||||
|
"Error trying to fetch user schema",
|
||||||
|
);
|
||||||
|
component
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
@@ -192,38 +257,22 @@ impl Component for CreateUserForm {
|
|||||||
let link = &ctx.link();
|
let link = &ctx.link();
|
||||||
html! {
|
html! {
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<form class="form py-3" style="max-width: 636px">
|
<form class="form py-3"
|
||||||
|
ref={self.form_ref.clone()}>
|
||||||
<Field<CreateUserModel>
|
<Field<CreateUserModel>
|
||||||
form={&self.form}
|
form={&self.form}
|
||||||
required=true
|
required=true
|
||||||
label="User name"
|
label="User name"
|
||||||
field_name="username"
|
field_name="username"
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
<Field<CreateUserModel>
|
{
|
||||||
form={&self.form}
|
self.attributes_schema
|
||||||
required=true
|
.iter()
|
||||||
label="Email"
|
.flatten()
|
||||||
field_name="email"
|
.filter(|a| !a.is_readonly)
|
||||||
input_type="email"
|
.map(get_custom_attribute_input)
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
.collect::<Vec<_>>()
|
||||||
<Field<CreateUserModel>
|
}
|
||||||
form={&self.form}
|
|
||||||
label="Display name"
|
|
||||||
field_name="display_name"
|
|
||||||
autocomplete="name"
|
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
|
||||||
<Field<CreateUserModel>
|
|
||||||
form={&self.form}
|
|
||||||
label="First name"
|
|
||||||
field_name="first_name"
|
|
||||||
autocomplete="given-name"
|
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
|
||||||
<Field<CreateUserModel>
|
|
||||||
form={&self.form}
|
|
||||||
label="Last name"
|
|
||||||
field_name="last_name"
|
|
||||||
autocomplete="family-name"
|
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
|
||||||
<Field<CreateUserModel>
|
<Field<CreateUserModel>
|
||||||
form={&self.form}
|
form={&self.form}
|
||||||
label="Password"
|
label="Password"
|
||||||
@@ -255,3 +304,21 @@ impl Component for CreateUserForm {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_custom_attribute_input(attribute_schema: &Attribute) -> Html {
|
||||||
|
if attribute_schema.is_list {
|
||||||
|
html! {
|
||||||
|
<ListAttributeInput
|
||||||
|
name={attribute_schema.name.clone()}
|
||||||
|
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {
|
||||||
|
<SingleAttributeInput
|
||||||
|
name={attribute_schema.name.clone()}
|
||||||
|
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
use crate::infra::schema::AttributeType;
|
use crate::{
|
||||||
use yew::{
|
components::form::{date_input::DateTimeInput, file_input::JpegFileInput},
|
||||||
function_component, html, virtual_dom::AttrValue, Callback, InputEvent, NodeRef, Properties,
|
infra::{schema::AttributeType, tooltip::Tooltip},
|
||||||
|
};
|
||||||
|
use web_sys::Element;
|
||||||
|
use yew::{
|
||||||
|
function_component, html, use_effect_with_deps, use_node_ref, virtual_dom::AttrValue,
|
||||||
|
Component, Context, Html, Properties,
|
||||||
};
|
};
|
||||||
|
|
||||||
/*
|
|
||||||
<input
|
|
||||||
ref={&ctx.props().input_ref}
|
|
||||||
type="text"
|
|
||||||
class="input-component"
|
|
||||||
placeholder={placeholder}
|
|
||||||
onmouseover={ctx.link().callback(|_| Msg::Hover)}
|
|
||||||
/>
|
|
||||||
*/
|
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
struct AttributeInputProps {
|
struct AttributeInputProps {
|
||||||
@@ -26,23 +21,64 @@ fn attribute_input(props: &AttributeInputProps) -> Html {
|
|||||||
let input_type = match props.attribute_type {
|
let input_type = match props.attribute_type {
|
||||||
AttributeType::String => "text",
|
AttributeType::String => "text",
|
||||||
AttributeType::Integer => "number",
|
AttributeType::Integer => "number",
|
||||||
AttributeType::DateTime => "datetime-local",
|
AttributeType::DateTime => {
|
||||||
AttributeType::Jpeg => "file",
|
return html! {
|
||||||
};
|
<DateTimeInput name={props.name.clone()} value={props.value.clone()} />
|
||||||
let accept = match props.attribute_type {
|
}
|
||||||
AttributeType::Jpeg => Some("image/jpeg"),
|
}
|
||||||
_ => None,
|
AttributeType::Jpeg => {
|
||||||
|
return html! {
|
||||||
|
<JpegFileInput name={props.name.clone()} value={props.value.clone()} />
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<input
|
<input
|
||||||
type={input_type}
|
type={input_type}
|
||||||
accept={accept}
|
|
||||||
name={props.name.clone()}
|
name={props.name.clone()}
|
||||||
class="form-control"
|
class="form-control"
|
||||||
value={props.value.clone()} />
|
value={props.value.clone()} />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
struct AttributeLabelProps {
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
#[function_component(AttributeLabel)]
|
||||||
|
fn attribute_label(props: &AttributeLabelProps) -> Html {
|
||||||
|
let tooltip_ref = use_node_ref();
|
||||||
|
|
||||||
|
use_effect_with_deps(
|
||||||
|
move |tooltip_ref| {
|
||||||
|
Tooltip::new(
|
||||||
|
tooltip_ref
|
||||||
|
.cast::<Element>()
|
||||||
|
.expect("Tooltip element should exist"),
|
||||||
|
);
|
||||||
|
|| {}
|
||||||
|
},
|
||||||
|
tooltip_ref.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<label for={props.name.clone()}
|
||||||
|
class="form-label col-4 col-form-label"
|
||||||
|
>
|
||||||
|
{props.name[0..1].to_uppercase() + &props.name[1..].replace('_', " ")}{":"}
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-link"
|
||||||
|
type="button"
|
||||||
|
data-bs-placement="right"
|
||||||
|
title={props.name.clone()}
|
||||||
|
ref={tooltip_ref}>
|
||||||
|
<i class="bi bi-info-circle" aria-label="Info" />
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct SingleAttributeInputProps {
|
pub struct SingleAttributeInputProps {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -55,10 +91,7 @@ pub struct SingleAttributeInputProps {
|
|||||||
pub fn single_attribute_input(props: &SingleAttributeInputProps) -> Html {
|
pub fn single_attribute_input(props: &SingleAttributeInputProps) -> Html {
|
||||||
html! {
|
html! {
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<label for={props.name.clone()}
|
<AttributeLabel name={props.name.clone()} />
|
||||||
class="form-label col-4 col-form-label">
|
|
||||||
{&props.name}{":"}
|
|
||||||
</label>
|
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<AttributeInput
|
<AttributeInput
|
||||||
attribute_type={props.attribute_type.clone()}
|
attribute_type={props.attribute_type.clone()}
|
||||||
@@ -68,3 +101,90 @@ pub fn single_attribute_input(props: &SingleAttributeInputProps) -> Html {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct ListAttributeInputProps {
|
||||||
|
pub name: String,
|
||||||
|
pub attribute_type: AttributeType,
|
||||||
|
#[prop_or(vec!())]
|
||||||
|
pub values: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum ListAttributeInputMsg {
|
||||||
|
Remove(usize),
|
||||||
|
Append,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ListAttributeInput {
|
||||||
|
indices: Vec<usize>,
|
||||||
|
next_index: usize,
|
||||||
|
values: Vec<String>,
|
||||||
|
}
|
||||||
|
impl Component for ListAttributeInput {
|
||||||
|
type Message = ListAttributeInputMsg;
|
||||||
|
type Properties = ListAttributeInputProps;
|
||||||
|
|
||||||
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
|
let values = ctx.props().values.clone();
|
||||||
|
Self {
|
||||||
|
indices: (0..values.len()).collect(),
|
||||||
|
next_index: values.len(),
|
||||||
|
values,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
|
match msg {
|
||||||
|
ListAttributeInputMsg::Remove(removed) => {
|
||||||
|
self.indices.retain_mut(|x| *x != removed);
|
||||||
|
}
|
||||||
|
ListAttributeInputMsg::Append => {
|
||||||
|
self.indices.push(self.next_index);
|
||||||
|
self.next_index += 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn changed(&mut self, ctx: &Context<Self>) -> bool {
|
||||||
|
if ctx.props().values != self.values {
|
||||||
|
self.values.clone_from(&ctx.props().values);
|
||||||
|
self.indices = (0..self.values.len()).collect();
|
||||||
|
self.next_index = self.values.len();
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let props = &ctx.props();
|
||||||
|
let link = &ctx.link();
|
||||||
|
html! {
|
||||||
|
<div class="row mb-3">
|
||||||
|
<AttributeLabel name={props.name.clone()} />
|
||||||
|
<div class="col-8">
|
||||||
|
{self.indices.iter().map(|&i| html! {
|
||||||
|
<div class="input-group mb-2" key={i}>
|
||||||
|
<AttributeInput
|
||||||
|
attribute_type={props.attribute_type.clone()}
|
||||||
|
name={props.name.clone()}
|
||||||
|
value={props.values.get(i).cloned().unwrap_or_default()} />
|
||||||
|
<button
|
||||||
|
class="btn btn-danger"
|
||||||
|
type="button"
|
||||||
|
onclick={link.callback(move |_| ListAttributeInputMsg::Remove(i))}>
|
||||||
|
<i class="bi-x-circle-fill" aria-label="Remove value" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}).collect::<Html>()}
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary"
|
||||||
|
type="button"
|
||||||
|
onclick={link.callback(|_| ListAttributeInputMsg::Append)}>
|
||||||
|
<i class="bi-plus-circle me-2"></i>
|
||||||
|
{"Add value"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
49
app/src/components/form/date_input.rs
Normal file
49
app/src/components/form/date_input.rs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use web_sys::HtmlInputElement;
|
||||||
|
use yew::{function_component, html, use_state, virtual_dom::AttrValue, Event, Properties};
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct DateTimeInputProps {
|
||||||
|
pub name: AttrValue,
|
||||||
|
pub value: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(DateTimeInput)]
|
||||||
|
pub fn date_time_input(props: &DateTimeInputProps) -> Html {
|
||||||
|
let value = use_state(|| {
|
||||||
|
props
|
||||||
|
.value
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|x| DateTime::<Utc>::from_str(x).ok())
|
||||||
|
});
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="input-group">
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name={props.name.clone()}
|
||||||
|
value={value.as_ref().map(|v: &DateTime<Utc>| v.to_rfc3339())} />
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
step="1"
|
||||||
|
class="form-control"
|
||||||
|
value={value.as_ref().map(|v: &DateTime<Utc>| v.naive_utc().to_string())}
|
||||||
|
onchange={move |e: Event| {
|
||||||
|
let string_val =
|
||||||
|
e.target()
|
||||||
|
.expect("Event should have target")
|
||||||
|
.unchecked_into::<HtmlInputElement>()
|
||||||
|
.value();
|
||||||
|
value.set(
|
||||||
|
NaiveDateTime::from_str(&string_val)
|
||||||
|
.ok()
|
||||||
|
.map(|x| DateTime::from_naive_utc_and_offset(x, Utc))
|
||||||
|
)
|
||||||
|
}} />
|
||||||
|
<span class="input-group-text">{"UTC"}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
238
app/src/components/form/file_input.rs
Normal file
238
app/src/components/form/file_input.rs
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
use std::{fmt::Display, str::FromStr};
|
||||||
|
|
||||||
|
use anyhow::{bail, Error, Ok, Result};
|
||||||
|
use gloo_file::{
|
||||||
|
callbacks::{read_as_bytes, FileReader},
|
||||||
|
File,
|
||||||
|
};
|
||||||
|
use web_sys::{FileList, HtmlInputElement, InputEvent};
|
||||||
|
use yew::Properties;
|
||||||
|
use yew::{prelude::*, virtual_dom::AttrValue};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct JsFile {
|
||||||
|
file: Option<File>,
|
||||||
|
contents: Option<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for JsFile {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"{}",
|
||||||
|
self.file.as_ref().map(File::name).unwrap_or_default()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for JsFile {
|
||||||
|
type Err = Error;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self> {
|
||||||
|
if s.is_empty() {
|
||||||
|
Ok(JsFile::default())
|
||||||
|
} else {
|
||||||
|
bail!("Building file from non-empty string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_base64(file: &JsFile) -> Result<String> {
|
||||||
|
match file {
|
||||||
|
JsFile {
|
||||||
|
file: None,
|
||||||
|
contents: None,
|
||||||
|
} => Ok(String::new()),
|
||||||
|
JsFile {
|
||||||
|
file: Some(_),
|
||||||
|
contents: None,
|
||||||
|
} => bail!("Image file hasn't finished loading, try again"),
|
||||||
|
JsFile {
|
||||||
|
file: Some(_),
|
||||||
|
contents: Some(data),
|
||||||
|
} => {
|
||||||
|
if !is_valid_jpeg(data.as_slice()) {
|
||||||
|
bail!("Chosen image is not a valid JPEG");
|
||||||
|
}
|
||||||
|
Ok(base64::encode(data))
|
||||||
|
}
|
||||||
|
JsFile {
|
||||||
|
file: None,
|
||||||
|
contents: Some(data),
|
||||||
|
} => Ok(base64::encode(data)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A [yew::Component] to display the user details, with a form allowing to edit them.
|
||||||
|
pub struct JpegFileInput {
|
||||||
|
// None means that the avatar hasn't changed.
|
||||||
|
avatar: Option<JsFile>,
|
||||||
|
reader: Option<FileReader>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Msg {
|
||||||
|
Update,
|
||||||
|
/// A new file was selected.
|
||||||
|
FileSelected(File),
|
||||||
|
/// The "Clear" button for the avatar was clicked.
|
||||||
|
ClearClicked,
|
||||||
|
/// A picked file finished loading.
|
||||||
|
FileLoaded(String, Result<Vec<u8>>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Props {
|
||||||
|
pub name: AttrValue,
|
||||||
|
pub value: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Component for JpegFileInput {
|
||||||
|
type Message = Msg;
|
||||||
|
type Properties = Props;
|
||||||
|
|
||||||
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
|
Self {
|
||||||
|
avatar: Some(JsFile {
|
||||||
|
file: None,
|
||||||
|
contents: ctx
|
||||||
|
.props()
|
||||||
|
.value
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|x| base64::decode(x).ok()),
|
||||||
|
}),
|
||||||
|
reader: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn changed(&mut self, ctx: &Context<Self>) -> bool {
|
||||||
|
self.avatar = Some(JsFile {
|
||||||
|
file: None,
|
||||||
|
contents: ctx
|
||||||
|
.props()
|
||||||
|
.value
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|x| base64::decode(x).ok()),
|
||||||
|
});
|
||||||
|
self.reader = None;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
|
match msg {
|
||||||
|
Msg::Update => true,
|
||||||
|
Msg::FileSelected(new_avatar) => {
|
||||||
|
if self
|
||||||
|
.avatar
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|f| f.file.as_ref().map(|f| f.name()))
|
||||||
|
!= Some(new_avatar.name())
|
||||||
|
{
|
||||||
|
let file_name = new_avatar.name();
|
||||||
|
let link = ctx.link().clone();
|
||||||
|
self.reader = Some(read_as_bytes(&new_avatar, move |res| {
|
||||||
|
link.send_message(Msg::FileLoaded(
|
||||||
|
file_name,
|
||||||
|
res.map_err(|e| anyhow::anyhow!("{:#}", e)),
|
||||||
|
))
|
||||||
|
}));
|
||||||
|
self.avatar = Some(JsFile {
|
||||||
|
file: Some(new_avatar),
|
||||||
|
contents: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Msg::ClearClicked => {
|
||||||
|
self.avatar = Some(JsFile::default());
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Msg::FileLoaded(file_name, data) => {
|
||||||
|
if let Some(avatar) = &mut self.avatar {
|
||||||
|
if let Some(file) = &avatar.file {
|
||||||
|
if file.name() == file_name {
|
||||||
|
if let Result::Ok(data) = data {
|
||||||
|
if !is_valid_jpeg(data.as_slice()) {
|
||||||
|
// Clear the selection.
|
||||||
|
self.avatar = Some(JsFile::default());
|
||||||
|
// TODO: bail!("Chosen image is not a valid JPEG");
|
||||||
|
} else {
|
||||||
|
avatar.contents = Some(data);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.reader = None;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let link = &ctx.link();
|
||||||
|
|
||||||
|
let avatar_string = match &self.avatar {
|
||||||
|
Some(avatar) => {
|
||||||
|
let avatar_base64 = to_base64(avatar);
|
||||||
|
avatar_base64.as_deref().unwrap_or("").to_owned()
|
||||||
|
}
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
|
html! {
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-5">
|
||||||
|
<input type="hidden" name={ctx.props().name.clone()} value={avatar_string.clone()} />
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
id="avatarInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg"
|
||||||
|
oninput={link.callback(|e: InputEvent| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
Self::upload_files(input.files())
|
||||||
|
})} />
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary col-auto"
|
||||||
|
id="avatarClear"
|
||||||
|
type="button"
|
||||||
|
onclick={link.callback(|_| {Msg::ClearClicked})}>
|
||||||
|
{"Clear"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
{
|
||||||
|
if !avatar_string.is_empty() {
|
||||||
|
html!{
|
||||||
|
<img
|
||||||
|
id="avatarDisplay"
|
||||||
|
src={format!("data:image/jpeg;base64, {}", avatar_string)}
|
||||||
|
style="max-height:128px;max-width:128px;height:auto;width:auto;"
|
||||||
|
alt="Avatar" />
|
||||||
|
}
|
||||||
|
} else { html! {} }
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JpegFileInput {
|
||||||
|
fn upload_files(files: Option<FileList>) -> Msg {
|
||||||
|
match files {
|
||||||
|
Some(files) if files.length() > 0 => {
|
||||||
|
Msg::FileSelected(File::from(files.item(0).unwrap()))
|
||||||
|
}
|
||||||
|
Some(_) | None => Msg::Update,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_valid_jpeg(bytes: &[u8]) -> bool {
|
||||||
|
image::io::Reader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg)
|
||||||
|
.decode()
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
pub mod attribute_input;
|
pub mod attribute_input;
|
||||||
pub mod checkbox;
|
pub mod checkbox;
|
||||||
|
pub mod date_input;
|
||||||
pub mod field;
|
pub mod field;
|
||||||
|
pub mod file_input;
|
||||||
pub mod select;
|
pub mod select;
|
||||||
pub mod static_value;
|
pub mod static_value;
|
||||||
pub mod submit;
|
pub mod submit;
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::{
|
components::{
|
||||||
add_group_member::{self, AddGroupMemberComponent},
|
add_group_member::{self, AddGroupMemberComponent},
|
||||||
|
group_details_form::GroupDetailsForm,
|
||||||
remove_user_from_group::RemoveUserFromGroupComponent,
|
remove_user_from_group::RemoveUserFromGroupComponent,
|
||||||
router::{AppRoute, Link},
|
router::{AppRoute, Link},
|
||||||
},
|
},
|
||||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
convert_attribute_type,
|
||||||
|
infra::{
|
||||||
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
form_utils::GraphQlAttributeSchema,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Error, Result};
|
use anyhow::{bail, Error, Result};
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
@@ -22,12 +27,28 @@ pub struct GetGroupDetails;
|
|||||||
pub type Group = get_group_details::GetGroupDetailsGroup;
|
pub type Group = get_group_details::GetGroupDetailsGroup;
|
||||||
pub type User = get_group_details::GetGroupDetailsGroupUsers;
|
pub type User = get_group_details::GetGroupDetailsGroupUsers;
|
||||||
pub type AddGroupMemberUser = add_group_member::User;
|
pub type AddGroupMemberUser = add_group_member::User;
|
||||||
|
pub type Attribute = get_group_details::GetGroupDetailsGroupAttributes;
|
||||||
|
pub type AttributeSchema = get_group_details::GetGroupDetailsSchemaGroupSchemaAttributes;
|
||||||
|
pub type AttributeType = get_group_details::AttributeType;
|
||||||
|
|
||||||
|
convert_attribute_type!(AttributeType);
|
||||||
|
|
||||||
|
impl From<&AttributeSchema> for GraphQlAttributeSchema {
|
||||||
|
fn from(attr: &AttributeSchema) -> Self {
|
||||||
|
Self {
|
||||||
|
name: attr.name.clone(),
|
||||||
|
is_list: attr.is_list,
|
||||||
|
is_readonly: attr.is_readonly,
|
||||||
|
is_editable: attr.is_editable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct GroupDetails {
|
pub struct GroupDetails {
|
||||||
common: CommonComponentParts<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
/// The group info. If none, the error is in `error`. If `error` is None, then we haven't
|
/// The group info. If none, the error is in `error`. If `error` is None, then we haven't
|
||||||
/// received the server response yet.
|
/// received the server response yet.
|
||||||
group: Option<Group>,
|
group_and_schema: Option<(Group, Vec<AttributeSchema>)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// State machine describing the possible transitions of the component state.
|
/// State machine describing the possible transitions of the component state.
|
||||||
@@ -38,11 +59,13 @@ pub enum Msg {
|
|||||||
OnError(Error),
|
OnError(Error),
|
||||||
OnUserAddedToGroup(AddGroupMemberUser),
|
OnUserAddedToGroup(AddGroupMemberUser),
|
||||||
OnUserRemovedFromGroup((String, i64)),
|
OnUserRemovedFromGroup((String, i64)),
|
||||||
|
DisplayNameUpdated,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
||||||
pub struct Props {
|
pub struct Props {
|
||||||
pub group_id: i64,
|
pub group_id: i64,
|
||||||
|
pub is_admin: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GroupDetails {
|
impl GroupDetails {
|
||||||
@@ -69,41 +92,16 @@ impl GroupDetails {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_details(&self, g: &Group) -> Html {
|
fn view_details(&self, ctx: &Context<Self>, g: &Group, schema: Vec<AttributeSchema>) -> Html {
|
||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
<h3>{g.display_name.to_string()}</h3>
|
<h3>{g.display_name.to_string()}</h3>
|
||||||
<div class="py-3">
|
<GroupDetailsForm
|
||||||
<form class="form">
|
group={g.clone()}
|
||||||
<div class="form-group row mb-3">
|
group_attributes_schema={schema}
|
||||||
<label for="displayName"
|
is_admin={ctx.props().is_admin}
|
||||||
class="form-label col-4 col-form-label">
|
on_display_name_updated={ctx.link().callback(|_| Msg::DisplayNameUpdated)}
|
||||||
{"Group: "}
|
/>
|
||||||
</label>
|
|
||||||
<div class="col-8">
|
|
||||||
<span id="groupId" class="form-constrol-static">{g.display_name.to_string()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row mb-3">
|
|
||||||
<label for="creationDate"
|
|
||||||
class="form-label col-4 col-form-label">
|
|
||||||
{"Creation date: "}
|
|
||||||
</label>
|
|
||||||
<div class="col-8">
|
|
||||||
<span id="creationDate" class="form-constrol-static">{g.creation_date.naive_local().date()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row mb-3">
|
|
||||||
<label for="uuid"
|
|
||||||
class="form-label col-4 col-form-label">
|
|
||||||
{"UUID: "}
|
|
||||||
</label>
|
|
||||||
<div class="col-8">
|
|
||||||
<span id="uuid" class="form-constrol-static">{g.uuid.to_string()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,29 +180,38 @@ impl GroupDetails {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<GroupDetails> for GroupDetails {
|
impl CommonComponent<GroupDetails> for GroupDetails {
|
||||||
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::GroupDetailsResponse(response) => match response {
|
Msg::GroupDetailsResponse(response) => match response {
|
||||||
Ok(group) => self.group = Some(group.group),
|
Ok(group) => {
|
||||||
|
self.group_and_schema =
|
||||||
|
Some((group.group, group.schema.group_schema.attributes))
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
self.group = None;
|
self.group_and_schema = None;
|
||||||
bail!("Error getting user details: {}", e);
|
bail!("Error getting user details: {}", e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Msg::OnError(e) => return Err(e),
|
Msg::OnError(e) => return Err(e),
|
||||||
Msg::OnUserAddedToGroup(user) => {
|
Msg::OnUserAddedToGroup(user) => {
|
||||||
self.group.as_mut().unwrap().users.push(User {
|
self.group_and_schema.as_mut().unwrap().0.users.push(User {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
display_name: user.display_name,
|
display_name: user.display_name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Msg::OnUserRemovedFromGroup((user_id, _)) => {
|
Msg::OnUserRemovedFromGroup((user_id, _)) => {
|
||||||
self.group
|
self.group_and_schema
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
.0
|
||||||
.users
|
.users
|
||||||
.retain(|u| u.id != user_id);
|
.retain(|u| u.id != user_id);
|
||||||
}
|
}
|
||||||
|
Msg::DisplayNameUpdated => self.get_group_details(ctx),
|
||||||
}
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
@@ -221,7 +228,7 @@ impl Component for GroupDetails {
|
|||||||
fn create(ctx: &Context<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
let mut table = Self {
|
let mut table = Self {
|
||||||
common: CommonComponentParts::<Self>::create(),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
group: None,
|
group_and_schema: None,
|
||||||
};
|
};
|
||||||
table.get_group_details(ctx);
|
table.get_group_details(ctx);
|
||||||
table
|
table
|
||||||
@@ -232,15 +239,15 @@ impl Component for GroupDetails {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
match (&self.group, &self.common.error) {
|
match (&self.group_and_schema, &self.common.error) {
|
||||||
(None, None) => html! {{"Loading..."}},
|
(None, None) => html! {{"Loading..."}},
|
||||||
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||||
(Some(u), error) => {
|
(Some((group, schema)), error) => {
|
||||||
html! {
|
html! {
|
||||||
<div>
|
<div>
|
||||||
{self.view_details(u)}
|
{self.view_details(ctx, group, schema.clone())}
|
||||||
{self.view_user_list(ctx, u)}
|
{self.view_user_list(ctx, group)}
|
||||||
{self.view_add_user_button(ctx, u)}
|
{self.view_add_user_button(ctx, group)}
|
||||||
{self.view_messages(error)}
|
{self.view_messages(error)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
272
app/src/components/group_details_form.rs
Normal file
272
app/src/components/group_details_form.rs
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
use crate::{
|
||||||
|
components::{
|
||||||
|
form::{
|
||||||
|
attribute_input::{ListAttributeInput, SingleAttributeInput},
|
||||||
|
static_value::StaticValue,
|
||||||
|
submit::Submit,
|
||||||
|
},
|
||||||
|
group_details::{Attribute, AttributeSchema, Group},
|
||||||
|
},
|
||||||
|
infra::{
|
||||||
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
form_utils::{read_all_form_attributes, AttributeValue, EmailIsRequired, IsAdmin},
|
||||||
|
schema::AttributeType,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use anyhow::{Ok, Result};
|
||||||
|
use graphql_client::GraphQLQuery;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
/// The GraphQL query sent to the server to update the group details.
|
||||||
|
#[derive(GraphQLQuery)]
|
||||||
|
#[graphql(
|
||||||
|
schema_path = "../schema.graphql",
|
||||||
|
query_path = "queries/update_group.graphql",
|
||||||
|
response_derives = "Debug",
|
||||||
|
variables_derives = "Clone,PartialEq,Eq",
|
||||||
|
custom_scalars_module = "crate::infra::graphql"
|
||||||
|
)]
|
||||||
|
pub struct UpdateGroup;
|
||||||
|
|
||||||
|
/// A [yew::Component] to display the group details, with a form allowing to edit them.
|
||||||
|
pub struct GroupDetailsForm {
|
||||||
|
common: CommonComponentParts<Self>,
|
||||||
|
/// True if we just successfully updated the group, to display a success message.
|
||||||
|
just_updated: bool,
|
||||||
|
updated_group_name: bool,
|
||||||
|
group: Group,
|
||||||
|
form_ref: NodeRef,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Msg {
|
||||||
|
/// A form field changed.
|
||||||
|
Update,
|
||||||
|
/// The "Submit" button was clicked.
|
||||||
|
SubmitClicked,
|
||||||
|
/// We got the response from the server about our update message.
|
||||||
|
GroupUpdated(Result<update_group::ResponseData>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(yew::Properties, Clone, PartialEq)]
|
||||||
|
pub struct Props {
|
||||||
|
/// The current group details.
|
||||||
|
pub group: Group,
|
||||||
|
pub group_attributes_schema: Vec<AttributeSchema>,
|
||||||
|
pub is_admin: bool,
|
||||||
|
pub on_display_name_updated: Callback<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommonComponent<GroupDetailsForm> for GroupDetailsForm {
|
||||||
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
|
match msg {
|
||||||
|
Msg::Update => Ok(true),
|
||||||
|
Msg::SubmitClicked => self.submit_group_update_form(ctx),
|
||||||
|
Msg::GroupUpdated(Err(e)) => Err(e),
|
||||||
|
Msg::GroupUpdated(Result::Ok(_)) => {
|
||||||
|
self.just_updated = true;
|
||||||
|
if self.updated_group_name {
|
||||||
|
self.updated_group_name = false;
|
||||||
|
ctx.props().on_display_name_updated.emit(());
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||||
|
&mut self.common
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Component for GroupDetailsForm {
|
||||||
|
type Message = Msg;
|
||||||
|
type Properties = Props;
|
||||||
|
|
||||||
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
|
Self {
|
||||||
|
common: CommonComponentParts::<Self>::create(),
|
||||||
|
just_updated: false,
|
||||||
|
updated_group_name: false,
|
||||||
|
group: ctx.props().group.clone(),
|
||||||
|
form_ref: NodeRef::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
|
self.just_updated = false;
|
||||||
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let link = &ctx.link();
|
||||||
|
|
||||||
|
let can_edit =
|
||||||
|
|a: &AttributeSchema| (ctx.props().is_admin || a.is_editable) && !a.is_readonly;
|
||||||
|
let display_field = |a: &AttributeSchema| {
|
||||||
|
if can_edit(a) {
|
||||||
|
get_custom_attribute_input(a, &self.group.attributes)
|
||||||
|
} else {
|
||||||
|
get_custom_attribute_static(a, &self.group.attributes)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
html! {
|
||||||
|
<div class="py-3">
|
||||||
|
<form
|
||||||
|
class="form"
|
||||||
|
ref={self.form_ref.clone()}>
|
||||||
|
<StaticValue label="Group ID" id="groupId">
|
||||||
|
<i>{&self.group.id}</i>
|
||||||
|
</StaticValue>
|
||||||
|
{
|
||||||
|
ctx
|
||||||
|
.props()
|
||||||
|
.group_attributes_schema
|
||||||
|
.iter()
|
||||||
|
.filter(|a| a.is_hardcoded && a.name != "group_id")
|
||||||
|
.map(display_field)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
}
|
||||||
|
{
|
||||||
|
ctx
|
||||||
|
.props()
|
||||||
|
.group_attributes_schema
|
||||||
|
.iter()
|
||||||
|
.filter(|a| !a.is_hardcoded)
|
||||||
|
.map(display_field)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
}
|
||||||
|
<Submit
|
||||||
|
text="Save changes"
|
||||||
|
disabled={self.common.is_task_running()}
|
||||||
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})} />
|
||||||
|
</form>
|
||||||
|
{
|
||||||
|
if let Some(e) = &self.common.error {
|
||||||
|
html! {
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
{e.to_string() }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else { html! {} }
|
||||||
|
}
|
||||||
|
<div hidden={!self.just_updated}>
|
||||||
|
<div class="alert alert-success mt-4">{"Group successfully updated!"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_custom_attribute_input(
|
||||||
|
attribute_schema: &AttributeSchema,
|
||||||
|
group_attributes: &[Attribute],
|
||||||
|
) -> Html {
|
||||||
|
let values = group_attributes
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.name == attribute_schema.name)
|
||||||
|
.map(|attribute| attribute.value.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if attribute_schema.is_list {
|
||||||
|
html! {
|
||||||
|
<ListAttributeInput
|
||||||
|
name={attribute_schema.name.clone()}
|
||||||
|
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||||
|
values={values}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {
|
||||||
|
<SingleAttributeInput
|
||||||
|
name={attribute_schema.name.clone()}
|
||||||
|
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||||
|
value={values.first().cloned().unwrap_or_default()}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_custom_attribute_static(
|
||||||
|
attribute_schema: &AttributeSchema,
|
||||||
|
group_attributes: &[Attribute],
|
||||||
|
) -> Html {
|
||||||
|
let values = group_attributes
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.name == attribute_schema.name)
|
||||||
|
.map(|attribute| attribute.value.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
html! {
|
||||||
|
<StaticValue label={attribute_schema.name.clone()} id={attribute_schema.name.clone()}>
|
||||||
|
{values.into_iter().map(|x| html!{<div>{x}</div>}).collect::<Vec<_>>()}
|
||||||
|
</StaticValue>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GroupDetailsForm {
|
||||||
|
fn submit_group_update_form(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
||||||
|
let mut all_values = read_all_form_attributes(
|
||||||
|
ctx.props().group_attributes_schema.iter(),
|
||||||
|
&self.form_ref,
|
||||||
|
IsAdmin(ctx.props().is_admin),
|
||||||
|
EmailIsRequired(false),
|
||||||
|
)?;
|
||||||
|
let base_attributes = &self.group.attributes;
|
||||||
|
all_values.retain(|a| {
|
||||||
|
let base_val = base_attributes
|
||||||
|
.iter()
|
||||||
|
.find(|base_val| base_val.name == a.name);
|
||||||
|
base_val
|
||||||
|
.map(|v| v.value != a.values)
|
||||||
|
.unwrap_or(!a.values.is_empty())
|
||||||
|
});
|
||||||
|
if all_values.iter().any(|a| a.name == "display_name") {
|
||||||
|
self.updated_group_name = true;
|
||||||
|
}
|
||||||
|
let remove_attributes: Option<Vec<String>> = if all_values.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(all_values.iter().map(|a| a.name.clone()).collect())
|
||||||
|
};
|
||||||
|
let insert_attributes: Option<Vec<update_group::AttributeValueInput>> =
|
||||||
|
if remove_attributes.is_none() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(
|
||||||
|
all_values
|
||||||
|
.into_iter()
|
||||||
|
.filter(|a| !a.values.is_empty())
|
||||||
|
.map(
|
||||||
|
|AttributeValue { name, values }| update_group::AttributeValueInput {
|
||||||
|
name,
|
||||||
|
value: values,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mut group_input = update_group::UpdateGroupInput {
|
||||||
|
id: self.group.id,
|
||||||
|
displayName: None,
|
||||||
|
removeAttributes: None,
|
||||||
|
insertAttributes: None,
|
||||||
|
};
|
||||||
|
let default_group_input = group_input.clone();
|
||||||
|
group_input.removeAttributes = remove_attributes;
|
||||||
|
group_input.insertAttributes = insert_attributes;
|
||||||
|
// Nothing changed.
|
||||||
|
if group_input == default_group_input {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let req = update_group::Variables { group: group_input };
|
||||||
|
self.common.call_graphql::<UpdateGroup, _>(
|
||||||
|
ctx,
|
||||||
|
req,
|
||||||
|
Msg::GroupUpdated,
|
||||||
|
"Error trying to update group",
|
||||||
|
);
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ pub mod delete_user;
|
|||||||
pub mod delete_user_attribute;
|
pub mod delete_user_attribute;
|
||||||
pub mod form;
|
pub mod form;
|
||||||
pub mod group_details;
|
pub mod group_details;
|
||||||
|
pub mod group_details_form;
|
||||||
pub mod group_schema_table;
|
pub mod group_schema_table;
|
||||||
pub mod group_table;
|
pub mod group_table;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
|
|||||||
@@ -104,7 +104,11 @@ impl Component for ResetPasswordStep1Form {
|
|||||||
</div>
|
</div>
|
||||||
{ if self.just_succeeded {
|
{ if self.just_succeeded {
|
||||||
html! {
|
html! {
|
||||||
{"A reset token has been sent to your email."}
|
{"If a user with this username or email exists, a password reset email will \
|
||||||
|
be sent to the associated email address. Please check your email and \
|
||||||
|
follow the instructions. If you don't receive an email, please check \
|
||||||
|
your spam folder. If you still don't receive an email, please contact \
|
||||||
|
your administrator."}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
html! {
|
html! {
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ use crate::{
|
|||||||
remove_user_from_group::RemoveUserFromGroupComponent,
|
remove_user_from_group::RemoveUserFromGroupComponent,
|
||||||
router::{AppRoute, Link},
|
router::{AppRoute, Link},
|
||||||
user_details_form::UserDetailsForm,
|
user_details_form::UserDetailsForm,
|
||||||
}, infra::{schema::AttributeType, common_component::{CommonComponent, CommonComponentParts}},
|
},
|
||||||
convert_attribute_type
|
convert_attribute_type,
|
||||||
|
infra::{
|
||||||
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
form_utils::GraphQlAttributeSchema,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Error, Result};
|
use anyhow::{bail, Error, Result};
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
@@ -23,15 +27,33 @@ pub struct GetUserDetails;
|
|||||||
pub type User = get_user_details::GetUserDetailsUser;
|
pub type User = get_user_details::GetUserDetailsUser;
|
||||||
pub type Group = get_user_details::GetUserDetailsUserGroups;
|
pub type Group = get_user_details::GetUserDetailsUserGroups;
|
||||||
pub type Attribute = get_user_details::GetUserDetailsUserAttributes;
|
pub type Attribute = get_user_details::GetUserDetailsUserAttributes;
|
||||||
pub type AttributeSchema = get_user_details::GetUserDetailsUserAttributesSchema;
|
pub type AttributeSchema = get_user_details::GetUserDetailsSchemaUserSchemaAttributes;
|
||||||
|
pub type AttributeType = get_user_details::AttributeType;
|
||||||
|
|
||||||
convert_attribute_type!(get_user_details::AttributeType);
|
convert_attribute_type!(AttributeType);
|
||||||
|
|
||||||
|
impl From<&AttributeSchema> for GraphQlAttributeSchema {
|
||||||
|
fn from(attr: &AttributeSchema) -> Self {
|
||||||
|
Self {
|
||||||
|
name: attr.name.clone(),
|
||||||
|
is_list: attr.is_list,
|
||||||
|
is_readonly: attr.is_readonly,
|
||||||
|
is_editable: attr.is_editable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct UserDetails {
|
pub struct UserDetails {
|
||||||
common: CommonComponentParts<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
/// The user info. If none, the error is in `error`. If `error` is None, then we haven't
|
/// The user info. If none, the error is in `error`. If `error` is None, then we haven't
|
||||||
/// received the server response yet.
|
/// received the server response yet.
|
||||||
user: Option<User>,
|
user_and_schema: Option<(User, Vec<AttributeSchema>)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserDetails {
|
||||||
|
fn mut_groups(&mut self) -> &mut Vec<Group> {
|
||||||
|
&mut self.user_and_schema.as_mut().unwrap().0.groups
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// State machine describing the possible transitions of the component state.
|
/// State machine describing the possible transitions of the component state.
|
||||||
@@ -54,22 +76,20 @@ impl CommonComponent<UserDetails> for UserDetails {
|
|||||||
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::UserDetailsResponse(response) => match response {
|
Msg::UserDetailsResponse(response) => match response {
|
||||||
Ok(user) => self.user = Some(user.user),
|
Ok(user) => {
|
||||||
|
self.user_and_schema = Some((user.user, user.schema.user_schema.attributes))
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
self.user = None;
|
self.user_and_schema = None;
|
||||||
bail!("Error getting user details: {}", e);
|
bail!("Error getting user details: {}", e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Msg::OnError(e) => return Err(e),
|
Msg::OnError(e) => return Err(e),
|
||||||
Msg::OnUserAddedToGroup(group) => {
|
Msg::OnUserAddedToGroup(group) => {
|
||||||
self.user.as_mut().unwrap().groups.push(group);
|
self.mut_groups().push(group);
|
||||||
}
|
}
|
||||||
Msg::OnUserRemovedFromGroup((_, group_id)) => {
|
Msg::OnUserRemovedFromGroup((_, group_id)) => {
|
||||||
self.user
|
self.mut_groups().retain(|g| g.id != group_id);
|
||||||
.as_mut()
|
|
||||||
.unwrap()
|
|
||||||
.groups
|
|
||||||
.retain(|g| g.id != group_id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
@@ -182,7 +202,7 @@ impl Component for UserDetails {
|
|||||||
fn create(ctx: &Context<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
let mut table = Self {
|
let mut table = Self {
|
||||||
common: CommonComponentParts::<Self>::create(),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
user: None,
|
user_and_schema: None,
|
||||||
};
|
};
|
||||||
table.get_user_details(ctx);
|
table.get_user_details(ctx);
|
||||||
table
|
table
|
||||||
@@ -193,10 +213,8 @@ impl Component for UserDetails {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
match (&self.user, &self.common.error) {
|
match (&self.user_and_schema, &self.common.error) {
|
||||||
(None, None) => html! {{"Loading..."}},
|
(Some((u, schema)), error) => {
|
||||||
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
|
||||||
(Some(u), error) => {
|
|
||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
<h3>{u.id.to_string()}</h3>
|
<h3>{u.id.to_string()}</h3>
|
||||||
@@ -211,13 +229,20 @@ impl Component for UserDetails {
|
|||||||
<div>
|
<div>
|
||||||
<h5 class="row m-3 fw-bold">{"User details"}</h5>
|
<h5 class="row m-3 fw-bold">{"User details"}</h5>
|
||||||
</div>
|
</div>
|
||||||
<UserDetailsForm user={u.clone()} />
|
<UserDetailsForm
|
||||||
|
user={u.clone()}
|
||||||
|
user_attributes_schema={schema.clone()}
|
||||||
|
is_admin={ctx.props().is_admin}
|
||||||
|
is_edited_user_admin={u.groups.iter().any(|g| g.display_name == "lldap_admin")}
|
||||||
|
/>
|
||||||
{self.view_group_memberships(ctx, u)}
|
{self.view_group_memberships(ctx, u)}
|
||||||
{self.view_add_group_button(ctx, u)}
|
{self.view_add_group_button(ctx, u)}
|
||||||
{self.view_messages(error)}
|
{self.view_messages(error)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
(None, None) => html! {{"Loading..."}},
|
||||||
|
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +1,21 @@
|
|||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
components::{
|
components::{
|
||||||
form::{attribute_input::SingleAttributeInput, field::Field, static_value::StaticValue, submit::Submit},
|
form::{
|
||||||
user_details::{AttributeSchema, User},
|
attribute_input::{ListAttributeInput, SingleAttributeInput},
|
||||||
}, convert_attribute_type, infra::{common_component::{CommonComponent, CommonComponentParts}, schema::AttributeType}
|
static_value::StaticValue,
|
||||||
};
|
submit::Submit,
|
||||||
use anyhow::{anyhow, bail, Error, Ok, Result};
|
},
|
||||||
use gloo_console::log;
|
user_details::{Attribute, AttributeSchema, User},
|
||||||
use gloo_file::{
|
},
|
||||||
callbacks::{read_as_bytes, FileReader},
|
infra::{
|
||||||
File,
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
form_utils::{read_all_form_attributes, AttributeValue, EmailIsRequired, IsAdmin},
|
||||||
|
schema::AttributeType,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
use anyhow::{Ok, Result};
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use validator::HasLen;
|
|
||||||
use validator_derive::Validate;
|
|
||||||
use web_sys::{FileList, FormData, HtmlFormElement, HtmlInputElement, InputEvent};
|
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew_form_derive::Model;
|
|
||||||
|
|
||||||
use super::user_details::Attribute;
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct JsFile {
|
|
||||||
file: Option<File>,
|
|
||||||
contents: Option<Vec<u8>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ToString for JsFile {
|
|
||||||
fn to_string(&self) -> String {
|
|
||||||
self.file.as_ref().map(File::name).unwrap_or_default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromStr for JsFile {
|
|
||||||
type Err = Error;
|
|
||||||
|
|
||||||
fn from_str(s: &str) -> Result<Self> {
|
|
||||||
if s.is_empty() {
|
|
||||||
Ok(JsFile::default())
|
|
||||||
} else {
|
|
||||||
bail!("Building file from non-empty string")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The fields of the form, with the editable details and the constraints.
|
|
||||||
#[derive(Model, Validate, PartialEq, Eq, Clone)]
|
|
||||||
pub struct UserModel {
|
|
||||||
#[validate(email)]
|
|
||||||
email: String,
|
|
||||||
display_name: String,
|
|
||||||
first_name: String,
|
|
||||||
last_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The GraphQL query sent to the server to update the user details.
|
/// The GraphQL query sent to the server to update the user details.
|
||||||
#[derive(GraphQLQuery)]
|
#[derive(GraphQLQuery)]
|
||||||
@@ -69,10 +31,6 @@ pub struct UpdateUser;
|
|||||||
/// A [yew::Component] to display the user details, with a form allowing to edit them.
|
/// A [yew::Component] to display the user details, with a form allowing to edit them.
|
||||||
pub struct UserDetailsForm {
|
pub struct UserDetailsForm {
|
||||||
common: CommonComponentParts<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
form: yew_form::Form<UserModel>,
|
|
||||||
// None means that the avatar hasn't changed.
|
|
||||||
avatar: Option<JsFile>,
|
|
||||||
reader: Option<FileReader>,
|
|
||||||
/// True if we just successfully updated the user, to display a success message.
|
/// True if we just successfully updated the user, to display a success message.
|
||||||
just_updated: bool,
|
just_updated: bool,
|
||||||
user: User,
|
user: User,
|
||||||
@@ -82,14 +40,8 @@ pub struct UserDetailsForm {
|
|||||||
pub enum Msg {
|
pub enum Msg {
|
||||||
/// A form field changed.
|
/// A form field changed.
|
||||||
Update,
|
Update,
|
||||||
/// A new file was selected.
|
|
||||||
FileSelected(File),
|
|
||||||
/// The "Submit" button was clicked.
|
/// The "Submit" button was clicked.
|
||||||
SubmitClicked,
|
SubmitClicked,
|
||||||
/// The "Clear" button for the avatar was clicked.
|
|
||||||
ClearAvatarClicked,
|
|
||||||
/// A picked file finished loading.
|
|
||||||
FileLoaded(String, Result<Vec<u8>>),
|
|
||||||
/// We got the response from the server about our update message.
|
/// We got the response from the server about our update message.
|
||||||
UserUpdated(Result<update_user::ResponseData>),
|
UserUpdated(Result<update_user::ResponseData>),
|
||||||
}
|
}
|
||||||
@@ -98,6 +50,9 @@ pub enum Msg {
|
|||||||
pub struct Props {
|
pub struct Props {
|
||||||
/// The current user details.
|
/// The current user details.
|
||||||
pub user: User,
|
pub user: User,
|
||||||
|
pub user_attributes_schema: Vec<AttributeSchema>,
|
||||||
|
pub is_admin: bool,
|
||||||
|
pub is_edited_user_admin: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<UserDetailsForm> for UserDetailsForm {
|
impl CommonComponent<UserDetailsForm> for UserDetailsForm {
|
||||||
@@ -108,60 +63,12 @@ impl CommonComponent<UserDetailsForm> for UserDetailsForm {
|
|||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::Update => Ok(true),
|
Msg::Update => Ok(true),
|
||||||
Msg::FileSelected(new_avatar) => {
|
|
||||||
if self
|
|
||||||
.avatar
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|f| f.file.as_ref().map(|f| f.name()))
|
|
||||||
!= Some(new_avatar.name())
|
|
||||||
{
|
|
||||||
let file_name = new_avatar.name();
|
|
||||||
let link = ctx.link().clone();
|
|
||||||
self.reader = Some(read_as_bytes(&new_avatar, move |res| {
|
|
||||||
link.send_message(Msg::FileLoaded(
|
|
||||||
file_name,
|
|
||||||
res.map_err(|e| anyhow::anyhow!("{:#}", e)),
|
|
||||||
))
|
|
||||||
}));
|
|
||||||
self.avatar = Some(JsFile {
|
|
||||||
file: Some(new_avatar),
|
|
||||||
contents: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
Msg::SubmitClicked => self.submit_user_update_form(ctx),
|
Msg::SubmitClicked => self.submit_user_update_form(ctx),
|
||||||
Msg::ClearAvatarClicked => {
|
Msg::UserUpdated(Err(e)) => Err(e),
|
||||||
self.avatar = Some(JsFile::default());
|
Msg::UserUpdated(Result::Ok(_)) => {
|
||||||
|
self.just_updated = true;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
Msg::UserUpdated(response) => self.user_update_finished(response),
|
|
||||||
Msg::FileLoaded(file_name, data) => {
|
|
||||||
if let Some(avatar) = &mut self.avatar {
|
|
||||||
if let Some(file) = &avatar.file {
|
|
||||||
if file.name() == file_name {
|
|
||||||
let data = data?;
|
|
||||||
if !is_valid_jpeg(data.as_slice()) {
|
|
||||||
// Clear the selection.
|
|
||||||
self.avatar = None;
|
|
||||||
bail!("Chosen image is not a valid JPEG");
|
|
||||||
} else {
|
|
||||||
avatar.contents = Some(data);
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.reader = None;
|
|
||||||
Ok(false)
|
|
||||||
} // Msg::OnSubmit(e) => {
|
|
||||||
// e.prevent_default();
|
|
||||||
// let form: HtmlFormElement = e.target_unchecked_into();
|
|
||||||
// let data = FormData::new_with_form(&form).unwrap();
|
|
||||||
// log!(format!("form data{:#?}", data));
|
|
||||||
// log!(format!("form data data{:#?}", *data));
|
|
||||||
// Ok(true)
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,18 +82,9 @@ impl Component for UserDetailsForm {
|
|||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
|
|
||||||
fn create(ctx: &Context<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
let model = UserModel {
|
|
||||||
email: ctx.props().user.email.clone(),
|
|
||||||
display_name: ctx.props().user.display_name.clone(),
|
|
||||||
first_name: ctx.props().user.first_name.clone(),
|
|
||||||
last_name: ctx.props().user.last_name.clone(),
|
|
||||||
};
|
|
||||||
Self {
|
Self {
|
||||||
common: CommonComponentParts::<Self>::create(),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
form: yew_form::Form::new(model),
|
|
||||||
avatar: None,
|
|
||||||
just_updated: false,
|
just_updated: false,
|
||||||
reader: None,
|
|
||||||
user: ctx.props().user.clone(),
|
user: ctx.props().user.clone(),
|
||||||
form_ref: NodeRef::default(),
|
form_ref: NodeRef::default(),
|
||||||
}
|
}
|
||||||
@@ -200,98 +98,45 @@ impl Component for UserDetailsForm {
|
|||||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
let link = &ctx.link();
|
let link = &ctx.link();
|
||||||
|
|
||||||
let avatar_string = match &self.avatar {
|
let can_edit =
|
||||||
Some(avatar) => {
|
|a: &AttributeSchema| (ctx.props().is_admin || a.is_editable) && !a.is_readonly;
|
||||||
let avatar_base64 = to_base64(avatar);
|
let display_field = |a: &AttributeSchema| {
|
||||||
avatar_base64.as_deref().unwrap_or("").to_owned()
|
if can_edit(a) {
|
||||||
|
get_custom_attribute_input(a, &self.user.attributes)
|
||||||
|
} else {
|
||||||
|
get_custom_attribute_static(a, &self.user.attributes)
|
||||||
}
|
}
|
||||||
None => self.user.avatar.as_deref().unwrap_or("").to_owned(),
|
|
||||||
};
|
};
|
||||||
html! {
|
html! {
|
||||||
<div class="py-3">
|
<div class="py-3">
|
||||||
<form class="form">
|
<form
|
||||||
|
class="form"
|
||||||
|
ref={self.form_ref.clone()}>
|
||||||
<StaticValue label="User ID" id="userId">
|
<StaticValue label="User ID" id="userId">
|
||||||
<i>{&self.user.id}</i>
|
<i>{&self.user.id}</i>
|
||||||
</StaticValue>
|
</StaticValue>
|
||||||
<StaticValue label="Creation date" id="creationDate">
|
{
|
||||||
{&self.user.creation_date.naive_local().date()}
|
ctx
|
||||||
</StaticValue>
|
.props()
|
||||||
<StaticValue label="UUID" id="uuid">
|
.user_attributes_schema
|
||||||
{&self.user.uuid}
|
.iter()
|
||||||
</StaticValue>
|
.filter(|a| a.is_hardcoded && a.name != "user_id")
|
||||||
<Field<UserModel>
|
.map(display_field)
|
||||||
form={&self.form}
|
.collect::<Vec<_>>()
|
||||||
required=true
|
}
|
||||||
label="Email"
|
{
|
||||||
field_name="email"
|
ctx
|
||||||
input_type="email"
|
.props()
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
.user_attributes_schema
|
||||||
<Field<UserModel>
|
.iter()
|
||||||
form={&self.form}
|
.filter(|a| !a.is_hardcoded)
|
||||||
label="Display name"
|
.map(display_field)
|
||||||
field_name="display_name"
|
.collect::<Vec<_>>()
|
||||||
autocomplete="name"
|
}
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
|
||||||
<Field<UserModel>
|
|
||||||
form={&self.form}
|
|
||||||
label="First name"
|
|
||||||
field_name="first_name"
|
|
||||||
autocomplete="given-name"
|
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
|
||||||
<Field<UserModel>
|
|
||||||
form={&self.form}
|
|
||||||
label="Last name"
|
|
||||||
field_name="last_name"
|
|
||||||
autocomplete="family-name"
|
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
|
||||||
<div class="form-group row align-items-center mb-3">
|
|
||||||
<label for="avatar"
|
|
||||||
class="form-label col-4 col-form-label">
|
|
||||||
{"Avatar: "}
|
|
||||||
</label>
|
|
||||||
<div class="col-8">
|
|
||||||
<div class="row align-items-center">
|
|
||||||
<div class="col-5">
|
|
||||||
<input
|
|
||||||
class="form-control"
|
|
||||||
id="avatarInput"
|
|
||||||
type="file"
|
|
||||||
accept="image/jpeg"
|
|
||||||
oninput={link.callback(|e: InputEvent| {
|
|
||||||
let input: HtmlInputElement = e.target_unchecked_into();
|
|
||||||
Self::upload_files(input.files())
|
|
||||||
})} />
|
|
||||||
</div>
|
|
||||||
<div class="col-3">
|
|
||||||
<button
|
|
||||||
class="btn btn-secondary col-auto"
|
|
||||||
id="avatarClear"
|
|
||||||
disabled={self.common.is_task_running()}
|
|
||||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::ClearAvatarClicked})}>
|
|
||||||
{"Clear"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
{
|
|
||||||
if !avatar_string.is_empty() {
|
|
||||||
html!{
|
|
||||||
<img
|
|
||||||
id="avatarDisplay"
|
|
||||||
src={format!("data:image/jpeg;base64, {}", avatar_string)}
|
|
||||||
style="max-height:128px;max-width:128px;height:auto;width:auto;"
|
|
||||||
alt="Avatar" />
|
|
||||||
}
|
|
||||||
} else { html! {} }
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{self.user.attributes.iter().map(get_custom_attribute_input).collect::<Vec<_>>()}
|
|
||||||
<Submit
|
<Submit
|
||||||
text="Save changes"
|
text="Save changes"
|
||||||
disabled={self.common.is_task_running()}
|
disabled={self.common.is_task_running()}
|
||||||
onclick={link.callback(|e: MouseEvent| {Msg::SubmitClicked})} />
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})} />
|
||||||
</form>
|
</form>
|
||||||
{
|
{
|
||||||
if let Some(e) = &self.common.error {
|
if let Some(e) = &self.common.error {
|
||||||
@@ -310,91 +155,97 @@ impl Component for UserDetailsForm {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type AttributeValue = (String, Vec<String>);
|
fn get_custom_attribute_input(
|
||||||
|
attribute_schema: &AttributeSchema,
|
||||||
fn get_values_from_form_data(
|
user_attributes: &[Attribute],
|
||||||
schema: Vec<AttributeSchema>,
|
) -> Html {
|
||||||
form: &FormData,
|
let values = user_attributes
|
||||||
) -> Result<Vec<AttributeValue>> {
|
.iter()
|
||||||
schema
|
.find(|a| a.name == attribute_schema.name)
|
||||||
.into_iter()
|
.map(|attribute| attribute.value.clone())
|
||||||
.map(|attr| -> Result<AttributeValue> {
|
.unwrap_or_default();
|
||||||
let val = form
|
if attribute_schema.is_list {
|
||||||
.get_all(attr.name.as_str())
|
html! {
|
||||||
.iter()
|
<ListAttributeInput
|
||||||
.map(|js_val| js_val.as_string().unwrap())
|
name={attribute_schema.name.clone()}
|
||||||
.filter(|val| !val.is_empty())
|
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||||
.collect::<Vec<String>>();
|
values={values}
|
||||||
if val.length() > 1 && !attr.is_list {
|
/>
|
||||||
return Err(anyhow!(
|
}
|
||||||
"Multiple values supplied for non-list attribute {}",
|
} else {
|
||||||
attr.name
|
html! {
|
||||||
));
|
<SingleAttributeInput
|
||||||
}
|
name={attribute_schema.name.clone()}
|
||||||
Ok((attr.name.clone(), val))
|
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||||
})
|
value={values.first().cloned().unwrap_or_default()}
|
||||||
.collect()
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_custom_attribute_input(attribute: &Attribute) -> Html {
|
fn get_custom_attribute_static(
|
||||||
if attribute.schema.is_list {
|
attribute_schema: &AttributeSchema,
|
||||||
html!{<p>{"list attr"}</p>}
|
user_attributes: &[Attribute],
|
||||||
} else {
|
) -> Html {
|
||||||
let value = if attribute.value.is_empty() {
|
let values = user_attributes
|
||||||
None
|
.iter()
|
||||||
} else {
|
.find(|a| a.name == attribute_schema.name)
|
||||||
Some(attribute.value[0].clone())
|
.map(|attribute| attribute.value.clone())
|
||||||
};
|
.unwrap_or_default();
|
||||||
html!{<SingleAttributeInput name={attribute.name.clone()} attribute_type={Into::<AttributeType>::into(attribute.schema.attribute_type.clone())} value={value}/>}
|
html! {
|
||||||
|
<StaticValue label={attribute_schema.name.clone()} id={attribute_schema.name.clone()}>
|
||||||
|
{values.into_iter().map(|x| html!{<div>{x}</div>}).collect::<Vec<_>>()}
|
||||||
|
</StaticValue>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UserDetailsForm {
|
impl UserDetailsForm {
|
||||||
fn submit_user_update_form(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
fn submit_user_update_form(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
||||||
if !self.form.validate() {
|
// TODO: Handle unloaded files.
|
||||||
bail!("Invalid inputs");
|
// if let Some(JsFile {
|
||||||
}
|
// file: Some(_),
|
||||||
if let Some(JsFile {
|
// contents: None,
|
||||||
file: Some(_),
|
// }) = &self.avatar
|
||||||
contents: None,
|
// {
|
||||||
}) = &self.avatar
|
// bail!("Image file hasn't finished loading, try again");
|
||||||
{
|
// }
|
||||||
bail!("Image file hasn't finished loading, try again");
|
let mut all_values = read_all_form_attributes(
|
||||||
}
|
ctx.props().user_attributes_schema.iter(),
|
||||||
let form = self.form_ref.cast::<HtmlFormElement>().unwrap();
|
&self.form_ref,
|
||||||
let form_data = FormData::new_with_form(&form)
|
IsAdmin(ctx.props().is_admin),
|
||||||
.map_err(|e| anyhow!("Failed to get FormData: {:#?}", e.as_string()))?;
|
EmailIsRequired(!ctx.props().is_edited_user_admin),
|
||||||
let mut all_values = get_values_from_form_data(
|
|
||||||
self.user
|
|
||||||
.attributes
|
|
||||||
.iter()
|
|
||||||
.map(|attr| attr.schema.clone())
|
|
||||||
.filter(|attr| !attr.is_hardcoded)
|
|
||||||
.filter(|attr| attr.is_editable)
|
|
||||||
.collect(),
|
|
||||||
&form_data,
|
|
||||||
)?;
|
)?;
|
||||||
let base_user = &self.user;
|
let base_attributes = &self.user.attributes;
|
||||||
let base_attrs = &self.user.attributes;
|
all_values.retain(|a| {
|
||||||
all_values.retain(|(name, val)| {
|
let base_val = base_attributes
|
||||||
let name = name.clone();
|
.iter()
|
||||||
let base_val = base_attrs
|
.find(|base_val| base_val.name == a.name);
|
||||||
.into_iter()
|
base_val
|
||||||
.find(|base_val| base_val.name == name)
|
.map(|v| v.value != a.values)
|
||||||
.unwrap();
|
.unwrap_or(!a.values.is_empty())
|
||||||
let new_values = val.clone();
|
|
||||||
base_val.value != new_values
|
|
||||||
});
|
});
|
||||||
let remove_names: Option<Vec<String>> = if all_values.is_empty() {
|
let remove_attributes: Option<Vec<String>> = if all_values.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(all_values.iter().map(|(name, _)| name.clone()).collect())
|
Some(all_values.iter().map(|a| a.name.clone()).collect())
|
||||||
};
|
|
||||||
let insert_attrs: Option<Vec<update_user::AttributeValueInput>> = if remove_names.is_none() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(all_values.into_iter().map(|(name, value)| update_user::AttributeValueInput{name, value}).collect())
|
|
||||||
};
|
};
|
||||||
|
let insert_attributes: Option<Vec<update_user::AttributeValueInput>> =
|
||||||
|
if remove_attributes.is_none() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(
|
||||||
|
all_values
|
||||||
|
.into_iter()
|
||||||
|
.filter(|a| !a.values.is_empty())
|
||||||
|
.map(
|
||||||
|
|AttributeValue { name, values }| update_user::AttributeValueInput {
|
||||||
|
name,
|
||||||
|
value: values,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
};
|
||||||
let mut user_input = update_user::UpdateUserInput {
|
let mut user_input = update_user::UpdateUserInput {
|
||||||
id: self.user.id.clone(),
|
id: self.user.id.clone(),
|
||||||
email: None,
|
email: None,
|
||||||
@@ -402,27 +253,12 @@ impl UserDetailsForm {
|
|||||||
firstName: None,
|
firstName: None,
|
||||||
lastName: None,
|
lastName: None,
|
||||||
avatar: None,
|
avatar: None,
|
||||||
removeAttributes: remove_names,
|
removeAttributes: None,
|
||||||
insertAttributes: insert_attrs,
|
insertAttributes: None,
|
||||||
};
|
};
|
||||||
let default_user_input = user_input.clone();
|
let default_user_input = user_input.clone();
|
||||||
let model = self.form.model();
|
user_input.removeAttributes = remove_attributes;
|
||||||
let email = model.email;
|
user_input.insertAttributes = insert_attributes;
|
||||||
if base_user.email != email {
|
|
||||||
user_input.email = Some(email);
|
|
||||||
}
|
|
||||||
if base_user.display_name != model.display_name {
|
|
||||||
user_input.displayName = Some(model.display_name);
|
|
||||||
}
|
|
||||||
if base_user.first_name != model.first_name {
|
|
||||||
user_input.firstName = Some(model.first_name);
|
|
||||||
}
|
|
||||||
if base_user.last_name != model.last_name {
|
|
||||||
user_input.lastName = Some(model.last_name);
|
|
||||||
}
|
|
||||||
if let Some(avatar) = &self.avatar {
|
|
||||||
user_input.avatar = Some(to_base64(avatar)?);
|
|
||||||
}
|
|
||||||
// Nothing changed.
|
// Nothing changed.
|
||||||
if user_input == default_user_input {
|
if user_input == default_user_input {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
@@ -436,58 +272,4 @@ impl UserDetailsForm {
|
|||||||
);
|
);
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn user_update_finished(&mut self, r: Result<update_user::ResponseData>) -> Result<bool> {
|
|
||||||
r?;
|
|
||||||
let model = self.form.model();
|
|
||||||
self.user.email = model.email;
|
|
||||||
self.user.display_name = model.display_name;
|
|
||||||
self.user.first_name = model.first_name;
|
|
||||||
self.user.last_name = model.last_name;
|
|
||||||
if let Some(avatar) = &self.avatar {
|
|
||||||
self.user.avatar = Some(to_base64(avatar)?);
|
|
||||||
}
|
|
||||||
self.just_updated = true;
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn upload_files(files: Option<FileList>) -> Msg {
|
|
||||||
if let Some(files) = files {
|
|
||||||
if files.length() > 0 {
|
|
||||||
Msg::FileSelected(File::from(files.item(0).unwrap()))
|
|
||||||
} else {
|
|
||||||
Msg::Update
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Msg::Update
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_valid_jpeg(bytes: &[u8]) -> bool {
|
|
||||||
image::io::Reader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg)
|
|
||||||
.decode()
|
|
||||||
.is_ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn to_base64(file: &JsFile) -> Result<String> {
|
|
||||||
match file {
|
|
||||||
JsFile {
|
|
||||||
file: None,
|
|
||||||
contents: _,
|
|
||||||
} => Ok(String::new()),
|
|
||||||
JsFile {
|
|
||||||
file: Some(_),
|
|
||||||
contents: None,
|
|
||||||
} => bail!("Image file hasn't finished loading, try again"),
|
|
||||||
JsFile {
|
|
||||||
file: Some(_),
|
|
||||||
contents: Some(data),
|
|
||||||
} => {
|
|
||||||
if !is_valid_jpeg(data.as_slice()) {
|
|
||||||
bail!("Chosen image is not a valid JPEG");
|
|
||||||
}
|
|
||||||
Ok(base64::encode(data))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use super::cookies::set_cookie;
|
use super::cookies::set_cookie;
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use gloo_net::http::{Method, Request};
|
use gloo_net::http::{Method, RequestBuilder};
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use lldap_auth::{login, registration, JWTClaims};
|
use lldap_auth::{login, registration, JWTClaims};
|
||||||
|
|
||||||
@@ -32,14 +32,16 @@ async fn call_server<Body: Serialize>(
|
|||||||
body: RequestType<Body>,
|
body: RequestType<Body>,
|
||||||
error_message: &'static str,
|
error_message: &'static str,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let mut request = Request::new(url)
|
let request_builder = RequestBuilder::new(url)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.credentials(RequestCredentials::SameOrigin);
|
.credentials(RequestCredentials::SameOrigin);
|
||||||
if let RequestType::Post(b) = body {
|
let request = if let RequestType::Post(b) = body {
|
||||||
request = request
|
request_builder
|
||||||
.body(serde_json::to_string(&b)?)
|
.method(Method::POST)
|
||||||
.method(Method::POST);
|
.body(serde_json::to_string(&b)?)?
|
||||||
}
|
} else {
|
||||||
|
request_builder.build()?
|
||||||
|
};
|
||||||
let response = request.send().await?;
|
let response = request.send().await?;
|
||||||
if response.ok() {
|
if response.ok() {
|
||||||
Ok(response.text().await?)
|
Ok(response.text().await?)
|
||||||
@@ -202,7 +204,7 @@ impl HostService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn probe_password_reset() -> Result<bool> {
|
pub async fn probe_password_reset() -> Result<bool> {
|
||||||
Ok(gloo_net::http::Request::get(
|
Ok(gloo_net::http::Request::post(
|
||||||
&(base_url() + "/auth/reset/step1/lldap_unlikely_very_long_user_name"),
|
&(base_url() + "/auth/reset/step1/lldap_unlikely_very_long_user_name"),
|
||||||
)
|
)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
|
|||||||
70
app/src/infra/form_utils.rs
Normal file
70
app/src/infra/form_utils.rs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
use anyhow::{anyhow, ensure, Result};
|
||||||
|
use validator::validate_email;
|
||||||
|
use web_sys::{FormData, HtmlFormElement};
|
||||||
|
use yew::NodeRef;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct AttributeValue {
|
||||||
|
pub name: String,
|
||||||
|
pub values: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GraphQlAttributeSchema {
|
||||||
|
pub name: String,
|
||||||
|
pub is_list: bool,
|
||||||
|
pub is_readonly: bool,
|
||||||
|
pub is_editable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_attributes(
|
||||||
|
all_values: &[AttributeValue],
|
||||||
|
email_is_required: EmailIsRequired,
|
||||||
|
) -> Result<()> {
|
||||||
|
let maybe_email_values = all_values.iter().find(|a| a.name == "mail");
|
||||||
|
if email_is_required.0 || maybe_email_values.is_some() {
|
||||||
|
let email_values = &maybe_email_values
|
||||||
|
.ok_or_else(|| anyhow!("Email is required"))?
|
||||||
|
.values;
|
||||||
|
ensure!(email_values.len() == 1, "Email is required");
|
||||||
|
ensure!(validate_email(&email_values[0]), "Email is not valid");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct IsAdmin(pub bool);
|
||||||
|
pub struct EmailIsRequired(pub bool);
|
||||||
|
|
||||||
|
pub fn read_all_form_attributes(
|
||||||
|
schema: impl IntoIterator<Item = impl Into<GraphQlAttributeSchema>>,
|
||||||
|
form_ref: &NodeRef,
|
||||||
|
is_admin: IsAdmin,
|
||||||
|
email_is_required: EmailIsRequired,
|
||||||
|
) -> Result<Vec<AttributeValue>> {
|
||||||
|
let form = form_ref.cast::<HtmlFormElement>().unwrap();
|
||||||
|
let form_data = FormData::new_with_form(&form)
|
||||||
|
.map_err(|e| anyhow!("Failed to get FormData: {:#?}", e.as_string()))?;
|
||||||
|
let all_values = schema
|
||||||
|
.into_iter()
|
||||||
|
.map(Into::<GraphQlAttributeSchema>::into)
|
||||||
|
.filter(|attr| !attr.is_readonly && (is_admin.0 || attr.is_editable))
|
||||||
|
.map(|attr| -> Result<AttributeValue> {
|
||||||
|
let val = form_data
|
||||||
|
.get_all(attr.name.as_str())
|
||||||
|
.iter()
|
||||||
|
.map(|js_val| js_val.as_string().unwrap_or_default())
|
||||||
|
.filter(|val| !val.is_empty())
|
||||||
|
.collect::<Vec<String>>();
|
||||||
|
ensure!(
|
||||||
|
val.len() <= 1 || attr.is_list,
|
||||||
|
"Multiple values supplied for non-list attribute {}",
|
||||||
|
attr.name
|
||||||
|
);
|
||||||
|
Ok(AttributeValue {
|
||||||
|
name: attr.name.clone(),
|
||||||
|
values: val,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>>>()?;
|
||||||
|
validate_attributes(&all_values, email_is_required)?;
|
||||||
|
Ok(all_values)
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ use crate::infra::api::HostService;
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use wasm_bindgen_futures::spawn_local;
|
use wasm_bindgen_futures::spawn_local;
|
||||||
use yew::{use_effect, use_state_eq, UseStateHandle};
|
use yew::{use_effect_with_deps, use_state_eq, UseStateHandle};
|
||||||
|
|
||||||
// Enum to represent a result that is fetched asynchronously.
|
// Enum to represent a result that is fetched asynchronously.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -31,22 +31,29 @@ pub fn use_graphql_call<QueryType>(
|
|||||||
) -> UseStateHandle<LoadableResult<QueryType::ResponseData>>
|
) -> UseStateHandle<LoadableResult<QueryType::ResponseData>>
|
||||||
where
|
where
|
||||||
QueryType: GraphQLQuery + 'static,
|
QueryType: GraphQLQuery + 'static,
|
||||||
|
<QueryType as graphql_client::GraphQLQuery>::Variables: std::cmp::PartialEq + Clone,
|
||||||
<QueryType as graphql_client::GraphQLQuery>::ResponseData: std::cmp::PartialEq,
|
<QueryType as graphql_client::GraphQLQuery>::ResponseData: std::cmp::PartialEq,
|
||||||
{
|
{
|
||||||
let loadable_result: UseStateHandle<LoadableResult<QueryType::ResponseData>> =
|
let loadable_result: UseStateHandle<LoadableResult<QueryType::ResponseData>> =
|
||||||
use_state_eq(|| LoadableResult::Loading);
|
use_state_eq(|| LoadableResult::Loading);
|
||||||
{
|
{
|
||||||
let loadable_result = loadable_result.clone();
|
let loadable_result = loadable_result.clone();
|
||||||
use_effect(move || {
|
use_effect_with_deps(
|
||||||
let task = HostService::graphql_query::<QueryType>(variables, "Failed graphql query");
|
move |variables| {
|
||||||
|
let task = HostService::graphql_query::<QueryType>(
|
||||||
|
variables.clone(),
|
||||||
|
"Failed graphql query",
|
||||||
|
);
|
||||||
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
let response = task.await;
|
let response = task.await;
|
||||||
loadable_result.set(LoadableResult::Loaded(response));
|
loadable_result.set(LoadableResult::Loaded(response));
|
||||||
});
|
});
|
||||||
|
|
||||||
|| ()
|
|| ()
|
||||||
})
|
},
|
||||||
|
variables,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
loadable_result.clone()
|
loadable_result.clone()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod common_component;
|
pub mod common_component;
|
||||||
pub mod cookies;
|
pub mod cookies;
|
||||||
|
pub mod form_utils;
|
||||||
pub mod functional;
|
pub mod functional;
|
||||||
pub mod graphql;
|
pub mod graphql;
|
||||||
pub mod modal;
|
pub mod modal;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
|
pub mod tooltip;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
#![allow(clippy::empty_docs)]
|
||||||
|
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
|
|||||||
@@ -34,25 +34,25 @@ impl FromStr for AttributeType {
|
|||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! convert_attribute_type {
|
macro_rules! convert_attribute_type {
|
||||||
($source_type:ty) => {
|
($source_type:ty) => {
|
||||||
impl From<$source_type> for AttributeType {
|
impl From<$source_type> for $crate::infra::schema::AttributeType {
|
||||||
fn from(value: $source_type) -> Self {
|
fn from(value: $source_type) -> Self {
|
||||||
match value {
|
match value {
|
||||||
<$source_type>::STRING => AttributeType::String,
|
<$source_type>::STRING => $crate::infra::schema::AttributeType::String,
|
||||||
<$source_type>::INTEGER => AttributeType::Integer,
|
<$source_type>::INTEGER => $crate::infra::schema::AttributeType::Integer,
|
||||||
<$source_type>::DATE_TIME => AttributeType::DateTime,
|
<$source_type>::DATE_TIME => $crate::infra::schema::AttributeType::DateTime,
|
||||||
<$source_type>::JPEG_PHOTO => AttributeType::Jpeg,
|
<$source_type>::JPEG_PHOTO => $crate::infra::schema::AttributeType::Jpeg,
|
||||||
_ => panic!("Unknown attribute type"),
|
_ => panic!("Unknown attribute type"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<AttributeType> for $source_type {
|
impl From<$crate::infra::schema::AttributeType> for $source_type {
|
||||||
fn from(value: AttributeType) -> Self {
|
fn from(value: $crate::infra::schema::AttributeType) -> Self {
|
||||||
match value {
|
match value {
|
||||||
AttributeType::String => <$source_type>::STRING,
|
$crate::infra::schema::AttributeType::String => <$source_type>::STRING,
|
||||||
AttributeType::Integer => <$source_type>::INTEGER,
|
$crate::infra::schema::AttributeType::Integer => <$source_type>::INTEGER,
|
||||||
AttributeType::DateTime => <$source_type>::DATE_TIME,
|
$crate::infra::schema::AttributeType::DateTime => <$source_type>::DATE_TIME,
|
||||||
AttributeType::Jpeg => <$source_type>::JPEG_PHOTO,
|
$crate::infra::schema::AttributeType::Jpeg => <$source_type>::JPEG_PHOTO,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
app/src/infra/tooltip.rs
Normal file
12
app/src/infra/tooltip.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#![allow(clippy::empty_docs)]
|
||||||
|
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
extern "C" {
|
||||||
|
#[wasm_bindgen(js_namespace = bootstrap)]
|
||||||
|
pub type Tooltip;
|
||||||
|
|
||||||
|
#[wasm_bindgen(constructor, js_namespace = bootstrap)]
|
||||||
|
pub fn new(e: web_sys::Element) -> Tooltip;
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ homepage = "https://github.com/lldap/lldap"
|
|||||||
license = "GPL-3.0-only"
|
license = "GPL-3.0-only"
|
||||||
name = "lldap_auth"
|
name = "lldap_auth"
|
||||||
repository = "https://github.com/lldap/lldap"
|
repository = "https://github.com/lldap/lldap"
|
||||||
version = "0.4.0"
|
version = "0.6.0"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["opaque_server", "opaque_client"]
|
default = ["opaque_server", "opaque_client"]
|
||||||
@@ -25,15 +25,20 @@ serde = "*"
|
|||||||
sha2 = "0.9"
|
sha2 = "0.9"
|
||||||
thiserror = "*"
|
thiserror = "*"
|
||||||
|
|
||||||
|
[dependencies.derive_more]
|
||||||
|
features = ["debug", "display"]
|
||||||
|
default-features = false
|
||||||
|
version = "1"
|
||||||
|
|
||||||
[dependencies.opaque-ke]
|
[dependencies.opaque-ke]
|
||||||
version = "0.6"
|
version = "0.7"
|
||||||
|
|
||||||
[dependencies.chrono]
|
[dependencies.chrono]
|
||||||
version = "*"
|
version = "*"
|
||||||
features = [ "serde" ]
|
features = ["serde"]
|
||||||
|
|
||||||
[dependencies.sea-orm]
|
[dependencies.sea-orm]
|
||||||
version= "0.12"
|
version = "0.12"
|
||||||
default-features = false
|
default-features = false
|
||||||
features = ["macros"]
|
features = ["macros"]
|
||||||
optional = true
|
optional = true
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ pub mod types {
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[cfg(feature = "sea_orm")]
|
#[cfg(feature = "sea_orm")]
|
||||||
use sea_orm::{DbErr, DeriveValueType, QueryResult, TryFromU64, Value};
|
use sea_orm::{DbErr, DeriveValueType, TryFromU64, Value};
|
||||||
|
|
||||||
#[derive(
|
#[derive(
|
||||||
PartialEq, Eq, PartialOrd, Ord, Clone, Debug, Default, Hash, Serialize, Deserialize,
|
PartialEq, Eq, PartialOrd, Ord, Clone, Debug, Default, Hash, Serialize, Deserialize,
|
||||||
@@ -151,10 +151,22 @@ pub mod types {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(
|
#[derive(
|
||||||
PartialEq, Eq, PartialOrd, Ord, Clone, Debug, Default, Hash, Serialize, Deserialize,
|
PartialEq,
|
||||||
|
Eq,
|
||||||
|
PartialOrd,
|
||||||
|
Ord,
|
||||||
|
Clone,
|
||||||
|
Default,
|
||||||
|
Hash,
|
||||||
|
Serialize,
|
||||||
|
Deserialize,
|
||||||
|
derive_more::Debug,
|
||||||
|
derive_more::Display,
|
||||||
)]
|
)]
|
||||||
#[cfg_attr(feature = "sea_orm", derive(DeriveValueType))]
|
#[cfg_attr(feature = "sea_orm", derive(DeriveValueType))]
|
||||||
#[serde(from = "CaseInsensitiveString")]
|
#[serde(from = "CaseInsensitiveString")]
|
||||||
|
#[debug(r#""{}""#, _0.as_str())]
|
||||||
|
#[display("{}", _0.as_str())]
|
||||||
pub struct UserId(CaseInsensitiveString);
|
pub struct UserId(CaseInsensitiveString);
|
||||||
|
|
||||||
impl UserId {
|
impl UserId {
|
||||||
@@ -176,11 +188,6 @@ pub mod types {
|
|||||||
Self(s.into())
|
Self(s.into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
impl std::fmt::Display for UserId {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
|
||||||
write!(f, "{}", self.0.as_str())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "sea_orm")]
|
#[cfg(feature = "sea_orm")]
|
||||||
impl From<&UserId> for Value {
|
impl From<&UserId> for Value {
|
||||||
|
|||||||
@@ -14,15 +14,14 @@ Backend:
|
|||||||
is defined in `schema.graphql`.
|
is defined in `schema.graphql`.
|
||||||
* The static frontend files are served by this port too.
|
* The static frontend files are served by this port too.
|
||||||
|
|
||||||
Note that secure protocols (LDAPS, HTTPS) are currently not supported. This can
|
Note that HTTPS is currently not supported. This can be worked around by using
|
||||||
be worked around by using a reverse proxy in front of the server (for the HTTP
|
a reverse proxy in front of the server (for the HTTP API) that wraps/unwraps
|
||||||
API) that wraps/unwraps the HTTPS messages, or only open the service to
|
the HTTPS messages. LDAPS is supported.
|
||||||
localhost or other trusted docker containers (for the LDAP API).
|
|
||||||
|
|
||||||
Frontend:
|
Frontend:
|
||||||
* User management UI.
|
* User management UI.
|
||||||
* Written in Rust compiled to WASM as an SPA with the Yew library.
|
* Written in Rust compiled to WASM as an SPA with the Yew library.
|
||||||
* Based on components, with a React-like organization.
|
* Based on components, with a React-like framework.
|
||||||
|
|
||||||
Data storage:
|
Data storage:
|
||||||
* The data (users, groups, memberships, active JWTs, ...) is stored in SQL.
|
* The data (users, groups, memberships, active JWTs, ...) is stored in SQL.
|
||||||
@@ -50,19 +49,19 @@ Data storage:
|
|||||||
Authentication is done via the OPAQUE protocol, meaning that the passwords are
|
Authentication is done via the OPAQUE protocol, meaning that the passwords are
|
||||||
never sent to the server, but instead the client proves that they know the
|
never sent to the server, but instead the client proves that they know the
|
||||||
correct password (zero-knowledge proof). This is likely overkill, especially
|
correct password (zero-knowledge proof). This is likely overkill, especially
|
||||||
considered that the LDAP interface requires sending the password to the server,
|
considered that the LDAP interface requires sending the password in cleartext
|
||||||
but it's one less potential flaw (especially since the LDAP interface can be
|
to the server, but it's one less potential flaw (especially since the LDAP
|
||||||
restricted to an internal docker-only network while the web app is exposed to
|
interface can be restricted to an internal docker-only network while the web
|
||||||
the Internet).
|
app is exposed to the Internet).
|
||||||
|
|
||||||
OPAQUE's "passwords" (user-specific blobs of data that can only be used in a
|
OPAQUE's "passwords" (user-specific blobs of data that can only be used in a
|
||||||
zero-knowledge proof that the password is correct) are hashed using Argon2, the
|
zero-knowledge proof that the password is correct) are hashed using Argon2, the
|
||||||
state of the art in terms of password storage. They are hashed using a secret
|
state of the art in terms of password storage. They are hashed using a secret
|
||||||
provided in the configuration (which can be given as environment variable or
|
provided in the configuration (which can be given as environment variable,
|
||||||
command line argument as well): this should be kept secret and shouldn't change
|
command line argument or a file as well): this should be kept secret and
|
||||||
(it would invalidate all passwords). Note that even if it was compromised, the
|
shouldn't change (it would invalidate all passwords). Note that even if it was
|
||||||
attacker wouldn't be able to decrypt the passwords without running an expensive
|
compromised, the attacker wouldn't be able to decrypt the passwords without
|
||||||
brute-force search independently for each password.
|
running an expensive brute-force search independently for each password.
|
||||||
|
|
||||||
### JWTs and refresh tokens
|
### JWTs and refresh tokens
|
||||||
|
|
||||||
|
|||||||
48
example_configs/MegaRAC-SP-X-BMC.md
Normal file
48
example_configs/MegaRAC-SP-X-BMC.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# MegaRAC SP-X BMC IPMI LDAP Setup
|
||||||
|
|
||||||
|
The MegaRAC SP-X BMC is a service processor firmware stack designed by American Megatrends Inc. (AMI), aimed at providing out-of-band management for servers and computing systems.
|
||||||
|
It's part of the MegaRAC family of management solutions, offering remote server management capabilities, including monitoring, control, and maintenance functionalities, independent of the operating system or system state.
|
||||||
|
This enables administrators to manage systems remotely for tasks such as updates, troubleshooting, and recovery.
|
||||||
|
|
||||||
|
## Setting up LLDAP with MegaRAC SP-X BMC IPMI
|
||||||
|
|
||||||
|
### Pre-requisites
|
||||||
|
- Create and assign the `ipmi` group in LLDAP to a (test) user.
|
||||||
|
- Bind User: It is recommended that you create a separate user account (e.g, `bind_user`) instead of admin for sharing Bind credentials with other services. The bind_user should be a member of the lldap_strict_readonly group to limit access to your LDAP configuration in LLDAP.
|
||||||
|
- Bind Password: password of the user specified above
|
||||||
|
|
||||||
|
### Configuration Steps
|
||||||
|
|
||||||
|
1. **Navigate**: Go to `Settings > External User Settings > LDAP/E-Directory Settings > General Settings`.
|
||||||
|
|
||||||
|
2. **General LDAP Settings**:
|
||||||
|
- **Encryption Type**: `SSL` (or No Encryption if preferred)
|
||||||
|
- **Common Name Type**: `FQDN` (or IP if you use a plain IP address to connect to lldap)
|
||||||
|
- **Server Address**: `fqdn.lldap.tld`
|
||||||
|
- **Port**: `6360` (default for SSL, adjust if necessary to default non ssl `3890`)
|
||||||
|
|
||||||
|
3. **Authentication** (use read-only bind user):
|
||||||
|
- **Bind DN**: `uid=bind_user,ou=people,dc=example,dc=com`
|
||||||
|
- **Password**: `change_bind_user_password`
|
||||||
|
|
||||||
|
4. **Search Configuration**:
|
||||||
|
- **Search Base**: `ou=people,dc=example,dc=com`
|
||||||
|
- **Attribute of User Login**: `uid`
|
||||||
|
|
||||||
|

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

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

|

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

|

|
||||||
|
|
||||||
|
### Expert
|
||||||
|
|
||||||
|
Set `Internal Username` to `user_id`. This is needed to that the user ID used by Nextcloud corresponds to the `user_id` field and not the `UUID` field.
|
||||||
|
|
||||||
## Sharing restrictions
|
## Sharing restrictions
|
||||||
|
|
||||||
Go to Settings > Administration > Sharing and check following boxes :
|
Go to Settings > Administration > Sharing and check following boxes :
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Click `Verify connection` if successful click `Next`
|
|||||||
* Select a template: Generic ldap server
|
* Select a template: Generic ldap server
|
||||||
* User Relative DN: `ou=people`
|
* User Relative DN: `ou=people`
|
||||||
* User subtree: Leave unchecked
|
* User subtree: Leave unchecked
|
||||||
* Object class: person
|
* Object class: `person`
|
||||||
* User Filter: Leave empty to allow all users to log in or `(memberOf=uid=nexus_users,ou=groups,dc=example,dc=com)` for a specific group
|
* User Filter: Leave empty to allow all users to log in or `(memberOf=uid=nexus_users,ou=groups,dc=example,dc=com)` for a specific group
|
||||||
* Username Attribute: `uid`
|
* Username Attribute: `uid`
|
||||||
* Real Name Attribute: `cn`
|
* Real Name Attribute: `cn`
|
||||||
@@ -53,4 +53,4 @@ Click `Create Role`
|
|||||||
* Role Name: e.g. nexus_admin (group in lldap)
|
* Role Name: e.g. nexus_admin (group in lldap)
|
||||||
* Add privileges/roles as needed e.g. under Roles add nx-admin to the "contained" list
|
* Add privileges/roles as needed e.g. under Roles add nx-admin to the "contained" list
|
||||||
|
|
||||||
Click `Save`
|
Click `Save`
|
||||||
|
|||||||
99
example_configs/ocis.md
Normal file
99
example_configs/ocis.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# OCIS (OwnCloud Infinite Scale)
|
||||||
|
|
||||||
|
This is using version 5 which is currently still in RC.
|
||||||
|
|
||||||
|
IMPORTANT: There is a bug/quirk in how the OCIS container handles bind mounts.
|
||||||
|
|
||||||
|
If the bind mount locations (eg. `/srv/ocis/{app,cfg}`) don't exist when the container is started, OCIS creates them with `root` permissions. It then seems to drop permissions to UID 1000 and gives an error because it can't create files in the `{app,cfg}`.
|
||||||
|
|
||||||
|
So you must create the bind mount locations and manually chown them to uid/gid 1000, eg.
|
||||||
|
|
||||||
|
```
|
||||||
|
# cd /srv/ocis
|
||||||
|
# mkdir app cfg
|
||||||
|
# chown 1000:1000 app cfg
|
||||||
|
# docker compose up -d && docker compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## .env
|
||||||
|
|
||||||
|
```
|
||||||
|
OCIS_URL="https://ocis.example.nz"
|
||||||
|
LDAP_BASE_DN="dc=example,dc=nz"
|
||||||
|
LDAP_BIND_PASSWORD=very-secret-yogurt
|
||||||
|
# LLDAP UUID to be given admin permissions
|
||||||
|
LLDAP_ADMIN_UUID=c1c2428a-xxxx-yyyy-zzzz-6cc946bf6809
|
||||||
|
```
|
||||||
|
|
||||||
|
## docker-compose.yml
|
||||||
|
|
||||||
|
```
|
||||||
|
version: "3.7"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
caddy:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
services:
|
||||||
|
ocis:
|
||||||
|
image: owncloud/ocis:5.0.0-rc.4
|
||||||
|
container_name: ocis
|
||||||
|
networks:
|
||||||
|
- caddy
|
||||||
|
entrypoint:
|
||||||
|
- /bin/sh
|
||||||
|
command: ["-c", "ocis init || true; ocis server"]
|
||||||
|
environment:
|
||||||
|
OCIS_URL: ${OCIS_URL}
|
||||||
|
OCIS_LOG_LEVEL: warn
|
||||||
|
OCIS_LOG_COLOR: "false"
|
||||||
|
PROXY_TLS: "false" # do not use SSL between Traefik and oCIS
|
||||||
|
OCIS_INSECURE: "false"
|
||||||
|
# Basic Auth is required for WebDAV clients that don't support OIDC
|
||||||
|
PROXY_ENABLE_BASIC_AUTH: "false"
|
||||||
|
#IDM_ADMIN_PASSWORD: "${ADMIN_PASSWORD}" # Not needed if admin user is in LDAP (?)
|
||||||
|
#OCIS_PASSWORD_POLICY_BANNED_PASSWORDS_LIST: "banned-password-list.txt"
|
||||||
|
|
||||||
|
# Assumes your LLDAP container is named `lldap`
|
||||||
|
OCIS_LDAP_URI: ldap://lldap:3890
|
||||||
|
OCIS_LDAP_INSECURE: "true"
|
||||||
|
OCIS_LDAP_BIND_DN: "uid=admin,ou=people,${LDAP_BASE_DN}"
|
||||||
|
OCIS_LDAP_BIND_PASSWORD: ${LDAP_BIND_PASSWORD}
|
||||||
|
OCIS_ADMIN_USER_ID: ${LLDAP_ADMIN_UUID}
|
||||||
|
|
||||||
|
OCIS_LDAP_USER_ENABLED_ATTRIBUTE: uid
|
||||||
|
GRAPH_LDAP_SERVER_WRITE_ENABLED: "false" # Does your LLDAP bind user have write access?
|
||||||
|
GRAPH_LDAP_REFINT_ENABLED: "false"
|
||||||
|
# Disable the built in LDAP server
|
||||||
|
OCIS_EXCLUDE_RUN_SERVICES: idm
|
||||||
|
# both text and binary cause errors in LLDAP, seems harmless though (?)
|
||||||
|
#IDP_LDAP_UUID_ATTRIBUTE_TYPE: 'text'
|
||||||
|
|
||||||
|
LDAP_LOGIN_ATTRIBUTES: "uid"
|
||||||
|
IDP_LDAP_LOGIN_ATTRIBUTE: "uid"
|
||||||
|
IDP_LDAP_UUID_ATTRIBUTE: "entryuuid"
|
||||||
|
OCIS_LDAP_USER_SCHEMA_ID: "entryuuid"
|
||||||
|
OCIS_LDAP_GROUP_SCHEMA_ID: "uid"
|
||||||
|
OCIS_LDAP_GROUP_SCHEMA_GROUPNAME: "uid"
|
||||||
|
|
||||||
|
OCIS_LDAP_GROUP_BASE_DN: "ou=groups,${LDAP_BASE_DN}"
|
||||||
|
OCIS_LDAP_GROUP_OBJECTCLASS: "groupOfUniqueNames"
|
||||||
|
# can filter which groups are imported, eg: `(&(objectclass=groupOfUniqueNames)(uid=ocis_*))`
|
||||||
|
OCIS_LDAP_GROUP_FILTER: "(objectclass=groupOfUniqueNames)"
|
||||||
|
|
||||||
|
OCIS_LDAP_USER_BASE_DN: "ou=people,${LDAP_BASE_DN}"
|
||||||
|
OCIS_LDAP_USER_OBJECTCLASS: "inetOrgPerson"
|
||||||
|
# Allows all users
|
||||||
|
#OCIS_LDAP_USER_FILTER: "(objectclass=inetOrgPerson)"
|
||||||
|
# Allows users who are in the LLDAP group `ocis_users`
|
||||||
|
OCIS_LDAP_USER_FILTER: "(&(objectclass=person)(memberOf=cn=ocis_users,ou=groups,${LDAP_BASE_DN}))"
|
||||||
|
# NOT WORKING: Used instead of restricting users with OCIS_LDAP_USER_FILTER
|
||||||
|
#OCIS_LDAP_DISABLE_USER_MECHANISM: "group"
|
||||||
|
#OCIS_LDAP_DISABLED_USERS_GROUP_DN: "uid=ocis_disabled,ou=groups,${LDAP_BASE_DN}"
|
||||||
|
volumes:
|
||||||
|
# - ./config/ocis/banned-password-list.txt:/etc/ocis/banned-password-list.txt
|
||||||
|
# IMPORTANT: see note at top about creating/cowning bind mounts
|
||||||
|
- ./cfg:/etc/ocis
|
||||||
|
- ./app:/var/lib/ocis
|
||||||
|
restart: always
|
||||||
|
```
|
||||||
23
example_configs/onedev.md
Normal file
23
example_configs/onedev.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Configuration for OneDev
|
||||||
|
In Onedev, go to `Administration > External Authentication Source > Authenticator` and Select `Generic LDAP`
|
||||||
|
|
||||||
|
* LDAP URL: ldap://lldap_ip_or_hostname:3890 or ldaps://lldap_ip_or_hostname:6360
|
||||||
|
* Authentication Required: On
|
||||||
|
* Manager DN: `uid=admin,ou=people,dc=example,dc=com`
|
||||||
|
* Manager Password: Your bind user's password
|
||||||
|
* User Search Base: `ou=people,dc=example,dc=com`
|
||||||
|
* User Search Filter: `(&(uid={0})(objectclass=person))`
|
||||||
|
* User Full Name Attribute: `displayName`
|
||||||
|
* Email Attribute: mail
|
||||||
|
* User SSH Key Attribute: (Leave Blank)
|
||||||
|
* Group Retrieval: "Search Groups Using Filter"
|
||||||
|
* Group Search Base: `ou=groups,dc=example,dc=com`
|
||||||
|
* Group Search Filter: `(&(uniqueMember={0})(objectclass=groupOfUniqueNames))`
|
||||||
|
* Group Name Attribute: cn
|
||||||
|
* Create User As Guest: Off
|
||||||
|
* Default Group: "No Default Group"
|
||||||
|
* Timeout: 300
|
||||||
|
|
||||||
|
Replace every instance of `dc=example,dc=com` with your configured domain.
|
||||||
|
|
||||||
|
After applying the above settings, users should be able to log in with their user name.
|
||||||
90
example_configs/pam/README.md
Normal file
90
example_configs/pam/README.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Configure lldap
|
||||||
|
|
||||||
|
You MUST use LDAPS. You MUST NOT use plain ldap. Even over a private network
|
||||||
|
this costs you nearly nothing, and passwords will be sent in PLAIN TEXT without
|
||||||
|
it.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[ldaps_options]
|
||||||
|
enabled=true
|
||||||
|
port=6360
|
||||||
|
cert_file="cert.pem"
|
||||||
|
key_file="key.pem"
|
||||||
|
```
|
||||||
|
|
||||||
|
You can generate an SSL certificate for it with the following command. The
|
||||||
|
`subjectAltName` is REQUIRED. Make sure all domains are listed there, even your
|
||||||
|
`CN`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
openssl req -x509 -nodes -newkey rsa:4096 -keyout key.pem -out cert.pem -sha256 -days 36500 -nodes -subj "/CN=lldap.example.net" -addext "subjectAltName = DNS:lldap.example.net"
|
||||||
|
```
|
||||||
|
|
||||||
|
# Install the client packages.
|
||||||
|
|
||||||
|
This guide used `libnss-ldapd` (which is different from `libnss-ldap`).
|
||||||
|
|
||||||
|
PURGE the following ubuntu packages: `libnss-ldap`, `libpam-ldap`
|
||||||
|
|
||||||
|
Install the following ubuntu packages: `libnss-ldapd`, `nslcd`, `nscd`, `libpam-ldapd`
|
||||||
|
|
||||||
|
# Configure the client's `nslcd` settings.
|
||||||
|
|
||||||
|
Edit `/etc/nslcd.conf`. Use the [provided template](./nslcd.conf).
|
||||||
|
|
||||||
|
You will need to set `tls_cacertfile` to a copy of the public portion of your
|
||||||
|
LDAPS certificate, which must be available on the client. This is used to
|
||||||
|
verify the LDAPS server identity.
|
||||||
|
|
||||||
|
You will need to add the `binddn` and `bindpw` settings.
|
||||||
|
|
||||||
|
The provided implementation uses custom attributes to mark users and groups
|
||||||
|
that should be included in the system (for instance, you don't want LDAP
|
||||||
|
accounts of other services to have a matching unix user).
|
||||||
|
|
||||||
|
For users, you need to add an (integer) `unix-uid` attribute to the schema, and
|
||||||
|
manually set the value for the users you want to enable to login with PAM.
|
||||||
|
|
||||||
|
For groups, you need an (integer) `unix-gid` attribute, similarly set manually
|
||||||
|
to some value.
|
||||||
|
|
||||||
|
If you want to change this representation, update the `filter passwd` and
|
||||||
|
`filter group` accordingly.
|
||||||
|
|
||||||
|
You should check whether you need to edit the `pam_authz_search` setting. This
|
||||||
|
is used after authentication, at the PAM `account` stage, to determine whether
|
||||||
|
the user should be allowed to log in. If someone is an LDAP user, even if they
|
||||||
|
use an SSH key to log in, they must still pass this check. The provided example
|
||||||
|
will check for membership of a group named `YOUR_LOGIN_GROUP_FOR_THIS_MACHINE`.
|
||||||
|
|
||||||
|
You should review the `map` settings. These contain custom attributes that you
|
||||||
|
will need to add to lldap and set on your users.
|
||||||
|
|
||||||
|
# Configure the client OS.
|
||||||
|
|
||||||
|
Ensure the `nslcd` and `nscd` services are installed and running. `nslcd`
|
||||||
|
provides LDAP NSS service. `nscd` provides caching for NSS databased. You want
|
||||||
|
the caching.
|
||||||
|
|
||||||
|
```
|
||||||
|
systemctl enable --now nslcd nscd
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure PAM to create the home directory for LDAP users automatically at
|
||||||
|
first login.
|
||||||
|
|
||||||
|
```
|
||||||
|
pam-auth-update --enable mkhomedir
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit /etc/nsswitch.conf and add "ldap" to the END of the "passwd" and "group"
|
||||||
|
lines.
|
||||||
|
|
||||||
|
You're done!
|
||||||
|
|
||||||
|
## Clearing nscd caches.
|
||||||
|
|
||||||
|
If you want to manually clear nscd's caches, run `nscd -i passwd; nscd -i group`.
|
||||||
|
|
||||||
|
[scripting]: https://github.com/lldap/lldap/blob/main/docs/scripting.md
|
||||||
|
|
||||||
59
example_configs/pam/nslcd.conf
Normal file
59
example_configs/pam/nslcd.conf
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# /etc/nslcd.conf
|
||||||
|
# nslcd configuration file. See nslcd.conf(5)
|
||||||
|
# for details.
|
||||||
|
|
||||||
|
# The user and group nslcd should run as.
|
||||||
|
uid nslcd
|
||||||
|
gid nslcd
|
||||||
|
|
||||||
|
# The location at which the LDAP server(s) should be reachable.
|
||||||
|
uri ldaps://lldap.example.net:6360/
|
||||||
|
|
||||||
|
# The search base that will be used for all queries.
|
||||||
|
base dc=example,dc=net
|
||||||
|
|
||||||
|
# The LDAP protocol version to use.
|
||||||
|
#ldap_version 3
|
||||||
|
|
||||||
|
# The DN to bind with for normal lookups.
|
||||||
|
binddn cn=...,ou=people,dc=example,dc=com
|
||||||
|
bindpw ...
|
||||||
|
|
||||||
|
# The DN used for password modifications by root.
|
||||||
|
#rootpwmoddn cn=admin,dc=example,dc=com
|
||||||
|
|
||||||
|
# SSL options
|
||||||
|
#ssl off
|
||||||
|
tls_reqcert demand
|
||||||
|
tls_cacertfile /etc/cert-lldap.example.com.pem
|
||||||
|
|
||||||
|
# The search scope.
|
||||||
|
#scope sub
|
||||||
|
|
||||||
|
reconnect_invalidate passwd group
|
||||||
|
nss_initgroups_ignoreusers ALLLOCAL
|
||||||
|
|
||||||
|
# Do you have users/groups that aren't for linux? These filters determine which user/group objects are used.
|
||||||
|
filter passwd (&(objectClass=posixAccount)(unix-uid=*))
|
||||||
|
filter group (&(objectClass=groupOfUniqueNames)(unix-gid=*))
|
||||||
|
|
||||||
|
# This check is done AFTER authentication, in the pam "account" stage.
|
||||||
|
# Regardless of if they used a LDAP password, or an SSH key, if they're an LDAP user, they have to pass this check.
|
||||||
|
pam_authz_search (&(objectClass=posixAccount)(unix-uid=*)(unix-username=$username)(memberOf=cn=YOUR_LOGIN_GROUP_FOR_THIS_MACHINE,ou=groups,dc=example,dc=com))
|
||||||
|
|
||||||
|
|
||||||
|
map passwd uid unix-username
|
||||||
|
map passwd uidNumber unix-uid
|
||||||
|
map passwd gidNumber unix-gid
|
||||||
|
map passwd gecos unix-username
|
||||||
|
map passwd homeDirectory "/home/${unix-username}"
|
||||||
|
map passwd loginShell unix-shell
|
||||||
|
|
||||||
|
map group gidNumber unix-gid
|
||||||
|
map group memberUid member
|
||||||
|
|
||||||
|
nss_min_uid 1000
|
||||||
|
|
||||||
|
pam_password_prohibit_message "Please use the forgot password link on https://lldap.example.com/ to change your password."
|
||||||
|
|
||||||
|
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
# Configuration for pfSense
|
# Configuration for pfSense
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Replace `dc=example,dc=com` with the same LDAP Base DN that you set via the *LLDAP_LDAP_BASE_DN* environment variable or in `lldap_config.toml`.
|
||||||
|
|
||||||
## Create a LDAP Server
|
## Create a LDAP Server
|
||||||
|
|
||||||
- Login to pfSense
|
- Login to pfSense
|
||||||
@@ -16,7 +19,9 @@
|
|||||||
- Protocol version: `3`
|
- Protocol version: `3`
|
||||||
- Server Timeout: `25`
|
- Server Timeout: `25`
|
||||||
|
|
||||||
(Make sure the host running LLDAP is accessible to pfSense and that you mapped the LLDAP port to the LLDAP host)
|
> [!NOTE]
|
||||||
|
> Make sure the host running LLDAP is accessible to pfSense and that you mapped the LLDAP port to the LLDAP host
|
||||||
|
|
||||||
### Search Scope
|
### Search Scope
|
||||||
```
|
```
|
||||||
Entire Subtree
|
Entire Subtree
|
||||||
@@ -27,18 +32,25 @@ Entire Subtree
|
|||||||
dc=example,dc=com
|
dc=example,dc=com
|
||||||
```
|
```
|
||||||
|
|
||||||
This is the same LDAP Base DN that you set via the *LLDAP_LDAP_BASE_DN* environment variable or in `lldap_config.toml`.
|
|
||||||
### Authentication containers
|
### Authentication containers
|
||||||
|
|
||||||
```
|
```
|
||||||
ou=people
|
ou=people
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: The `Select a container` box may not work for selecting containers. You can just enter the `Authentication containers` directly into the text field.
|
> [!Note]
|
||||||
|
> The `Select a container` seach fuction will not work for selecting containers. You enter the `Authentication containers` directly into the text field.
|
||||||
|
> This is due to Pfsense running the following filter `"(|(ou=*)(cn=Users))"`, and `Organizational Units` is currently not supported.Could not connect to the LDAP server. Please check the LDAP configuration.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> if search button is pressed a warning will show on the bottom of the page: `Could not connect to the LDAP server. Please check the LDAP configuration.`
|
||||||
|
|
||||||
|
|
||||||
### Extended Query
|
### Extended Query
|
||||||
|
|
||||||
Enable extended query: `Checked`
|
Enable extended query:
|
||||||
|
|
||||||
|
- [X] `Checked`
|
||||||
|
|
||||||
### Query:
|
### Query:
|
||||||
|
|
||||||
@@ -49,7 +61,8 @@ Enable extended query: `Checked`
|
|||||||
This example gives you two groups in LLDAP, one for pfSense admin access (`pfsense_admin`) and one for guest access (`pfsense_guest`). You **must** create these exact same groups in both LLDAP and pfSense, then give them the correct permissions in pfSense.
|
This example gives you two groups in LLDAP, one for pfSense admin access (`pfsense_admin`) and one for guest access (`pfsense_guest`). You **must** create these exact same groups in both LLDAP and pfSense, then give them the correct permissions in pfSense.
|
||||||
|
|
||||||
### Bind Anonymous
|
### Bind Anonymous
|
||||||
`Unchecked`
|
|
||||||
|
- [ ] `Unchecked`
|
||||||
|
|
||||||
### Bind credentials
|
### Bind credentials
|
||||||
|
|
||||||
@@ -80,13 +93,18 @@ cn
|
|||||||
memberof
|
memberof
|
||||||
```
|
```
|
||||||
### RFC 2307 Groups
|
### RFC 2307 Groups
|
||||||
`Unchecked`
|
|
||||||
|
- [ ] `Unchecked`
|
||||||
|
|
||||||
### Group Object Class
|
### Group Object Class
|
||||||
`groupOfUniqueNames`
|
```
|
||||||
|
groupOfUniqueNames
|
||||||
|
```
|
||||||
|
|
||||||
### Shell Authentication Group DN
|
### Shell Authentication Group DN
|
||||||
`cn=pfsense_admin,ou=groups,dc=example,dc=com`
|
```
|
||||||
|
cn=pfsense_admin,ou=groups,dc=example,dc=com
|
||||||
|
```
|
||||||
|
|
||||||
(This is only if you want to give a group shell access through LDAP. Leave blank and only the pfSense admin user will have shell access.
|
(This is only if you want to give a group shell access through LDAP. Leave blank and only the pfSense admin user will have shell access.
|
||||||
|
|
||||||
@@ -94,9 +112,9 @@ memberof
|
|||||||
|
|
||||||
Enable the following options on the pfSense configuration page for your LLDAP server (the same page where you entered the prior configuration):
|
Enable the following options on the pfSense configuration page for your LLDAP server (the same page where you entered the prior configuration):
|
||||||
|
|
||||||
- UTF8 Encodes: `Checked`
|
- [X] UTF8 Encodes: `Checked`
|
||||||
- Username Alterations: `Unchecked`
|
- [ ] Username Alterations: `Unchecked`
|
||||||
- Allow unauthenticated bind: `Unchecked`
|
- [ ] Allow unauthenticated bind: `Unchecked`
|
||||||
|
|
||||||
### Create pfSense Groups
|
### Create pfSense Groups
|
||||||
|
|
||||||
@@ -112,6 +130,9 @@ Go to `System > User Manager > Settings` page. Add your LLDAP server configurati
|
|||||||
|
|
||||||
pfSense includes a built-in feature for testing user authentication at `Diagnostics > Authentication`. Select your LLDAP server configuration in the `Authentication Server` to test logins for your LLDAP users. The groups (only the ones you added to pfSense) should show up when tested.
|
pfSense includes a built-in feature for testing user authentication at `Diagnostics > Authentication`. Select your LLDAP server configuration in the `Authentication Server` to test logins for your LLDAP users. The groups (only the ones you added to pfSense) should show up when tested.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> When running `Save and test`, the `Attempting to fetch Organizational Units from` will fail. This is due to Pfsense running the following filter `"(|(ou=*)(cn=Users))"`, and `Organizational Units` is currently not supported.
|
||||||
|
|
||||||
## More Information
|
## More Information
|
||||||
|
|
||||||
Please read the [pfSense docs](https://docs.netgate.com/pfsense/en/latest/usermanager/ldap.html) for more information on LDAP configuration and managing access to pfSense.
|
Please read the [pfSense docs](https://docs.netgate.com/pfsense/en/latest/usermanager/ldap.html) for more information on LDAP configuration and managing access to pfSense.
|
||||||
|
|||||||
15
example_configs/prosody.md
Normal file
15
example_configs/prosody.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Configuration for Prosody XMPP server
|
||||||
|
|
||||||
|
Prosody is setup with virtual hosts, at least one. If you want to have users access only specific virtual hosts, create a group per vHost (I called it `xmpp-example.com`). If not, remove the memberOf part in the filter below. I would also create a read only user (mine is called `query`) with the group `lldap_strict_readonly` to find the users that will be used to bind.
|
||||||
|
|
||||||
|
In `prosody.cfg.lua` you need to set `authentication` to `ldap` and the following settings:
|
||||||
|
|
||||||
|
```authentication = "ldap"
|
||||||
|
ldap_base = "dc=example,dc=com"
|
||||||
|
ldap_server = "lldap_ip:3890"
|
||||||
|
ldap_rootdn = "uid=query,ou=people,dc=example,dc=com"
|
||||||
|
ldap_password = "query-password"
|
||||||
|
ldap_filter = "(&(uid=$user)(memberOf=cn=xmpp-$host,ou=groups,dc=example,dc=com)(objectclass=person))"
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart Prosody and you should be good to go.
|
||||||
@@ -1,15 +1,24 @@
|
|||||||
# Configuration of RADICALE authentification with lldap.
|
# Configuration of Radicale authentication with LLDAP
|
||||||
|
|
||||||
# Fork of the radicale LDAP plugin to work with LLDAP : https://github.com/shroomify-it/radicale-auth-ldap-plugin
|
## Native configuration (requires Radicale >=3.3.0)
|
||||||
|
|
||||||
# Full docker-compose stack : https://github.com/shroomify-it/docker-deploy_radicale-agendav-lldap
|
```ini
|
||||||
|
[auth]
|
||||||
|
type = ldap
|
||||||
|
ldap_uri = ldap://lldap:3890
|
||||||
|
ldap_base = dc=example,dc=com
|
||||||
|
ldap_reader_dn = uid=admin,ou=people,dc=example,dc=com
|
||||||
|
ldap_secret = CHANGEME
|
||||||
|
ldap_filter = (&(objectClass=person)(uid={0}))
|
||||||
|
lc_username = True
|
||||||
|
```
|
||||||
|
|
||||||
# Radicale config file v0.3 (inside docker container /etc/radicale/config https://radicale.org/v3.html#configuration)
|
## Plugin configuration (requires [radicale-auth-ldap](https://github.com/shroomify-it/radicale-auth-ldap-plugin) plugin and Radicale >=3.0)
|
||||||
|
|
||||||
```toml
|
```ini
|
||||||
[auth]
|
[auth]
|
||||||
type = radicale_auth_ldap
|
type = radicale_auth_ldap
|
||||||
ldap_url = ldap://lldap:3890
|
ldap_url = ldap://lldap:3890
|
||||||
ldap_base = dc=example,dc=com
|
ldap_base = dc=example,dc=com
|
||||||
ldap_attribute = uid
|
ldap_attribute = uid
|
||||||
ldap_filter = (objectClass=person)
|
ldap_filter = (objectClass=person)
|
||||||
|
|||||||
57
example_configs/sonarqube.md
Normal file
57
example_configs/sonarqube.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Configuring LDAP in SonarQube
|
||||||
|
|
||||||
|
[SonarQube](https://github.com/SonarSource/sonarqube)
|
||||||
|
|
||||||
|
Continuous Inspection
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
SonarQube can configure ldap through environment variables when deploying using docker-compose
|
||||||
|
|
||||||
|
## docker-compose.yaml
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: "3"
|
||||||
|
|
||||||
|
services:
|
||||||
|
sonarqube:
|
||||||
|
image: sonarqube:community
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
environment:
|
||||||
|
SONAR_JDBC_URL: jdbc:postgresql://db:5432/sonar
|
||||||
|
SONAR_JDBC_USERNAME: sonar
|
||||||
|
SONAR_JDBC_PASSWORD: sonar
|
||||||
|
LDAP_URL: ldap://example.com:3890
|
||||||
|
LDAP_BINDDN: cn=admin,ou=people,dc=example,dc=com
|
||||||
|
LDAP_BINDPASSWORD: passwd
|
||||||
|
LDAP_AUTHENTICATION: simple
|
||||||
|
LDAP_USER_BASEDN: ou=people,dc=example,dc=com
|
||||||
|
LDAP_USER_REQUEST: (&(objectClass=inetOrgPerson)(uid={login})(memberof=cn=sonarqube_users,ou=groups,dc=example,dc=com))
|
||||||
|
LDAP_USER_REALNAMEATTRIBUTE: cn
|
||||||
|
LDAP_USER_EMAILATTRIBUTE: mail
|
||||||
|
volumes:
|
||||||
|
- sonarqube_data:/opt/sonarqube/data
|
||||||
|
- sonarqube_extensions:/opt/sonarqube/extensions
|
||||||
|
- sonarqube_logs:/opt/sonarqube/logs
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
db:
|
||||||
|
image: postgres:12
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: sonar
|
||||||
|
POSTGRES_PASSWORD: sonar
|
||||||
|
volumes:
|
||||||
|
- postgresql:/var/lib/postgresql
|
||||||
|
- postgresql_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
sonarqube_data:
|
||||||
|
sonarqube_extensions:
|
||||||
|
sonarqube_logs:
|
||||||
|
postgresql:
|
||||||
|
postgresql_data:
|
||||||
|
```
|
||||||
|
|
||||||
|
> [SonarQube docker-compose.yaml example](https://docs.sonarsource.com/sonarqube/latest/setup-and-upgrade/install-the-server/installing-sonarqube-from-docker/)
|
||||||
|
|
||||||
@@ -9,12 +9,11 @@ Replace `dc=example,dc=com` with your LLDAP configured domain.
|
|||||||
version: '3'
|
version: '3'
|
||||||
services:
|
services:
|
||||||
ldap_sync:
|
ldap_sync:
|
||||||
image: vividboarder/vaultwarden_ldap:0.6-alpine
|
image: vividboarder/vaultwarden_ldap:2.0.2
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.toml:/config.toml:ro
|
- ./config.toml:/config.toml:ro
|
||||||
environment:
|
environment:
|
||||||
CONFIG_PATH: /config.toml
|
CONFIG_PATH: /config.toml
|
||||||
RUST_BACKTRACE: 1
|
|
||||||
restart: always
|
restart: always
|
||||||
```
|
```
|
||||||
Configuration to use LDAP in `config.toml`
|
Configuration to use LDAP in `config.toml`
|
||||||
@@ -23,6 +22,7 @@ vaultwarden_url = "http://your_bitwarden_url:port"
|
|||||||
vaultwarden_admin_token = "insert_admin_token_vaultwarden"
|
vaultwarden_admin_token = "insert_admin_token_vaultwarden"
|
||||||
ldap_host = "insert_ldap_host"
|
ldap_host = "insert_ldap_host"
|
||||||
ldap_port = 3890
|
ldap_port = 3890
|
||||||
|
ldap_ssl = false # true if using LDAPS
|
||||||
ldap_bind_dn = "uid=admin,ou=people,dc=example,dc=com"
|
ldap_bind_dn = "uid=admin,ou=people,dc=example,dc=com"
|
||||||
ldap_bind_password = "insert_admin_pw_ldap"
|
ldap_bind_password = "insert_admin_pw_ldap"
|
||||||
ldap_search_base_dn = "dc=example,dc=com"
|
ldap_search_base_dn = "dc=example,dc=com"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#! /bin/sh
|
#! /bin/sh
|
||||||
|
|
||||||
function print_random () {
|
print_random () {
|
||||||
LC_ALL=C tr -dc 'A-Za-z0-9!#%&()*+,-./:;<=>?@[\]^_{|}~' </dev/urandom | head -c 32
|
LC_ALL=C tr -dc 'A-Za-z0-9!#%&()*+,-./:;<=>?@[\]^_{|}~' </dev/urandom | head -c 32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,8 @@
|
|||||||
## Break glass in case of emergency: if you lost the admin password, you
|
## Break glass in case of emergency: if you lost the admin password, you
|
||||||
## can set this to true to force a reset of the admin password to the value
|
## can set this to true to force a reset of the admin password to the value
|
||||||
## of ldap_user_pass above.
|
## of ldap_user_pass above.
|
||||||
# force_reset_admin_password = false
|
## Alternatively, you can set it to "always" to reset every time the server starts.
|
||||||
|
# force_ldap_user_pass_reset = false
|
||||||
|
|
||||||
## Database URL.
|
## Database URL.
|
||||||
## This encodes the type of database (SQlite, MySQL, or PostgreSQL)
|
## This encodes the type of database (SQlite, MySQL, or PostgreSQL)
|
||||||
|
|||||||
@@ -182,14 +182,14 @@ impl TryFrom<ResultEntry> for User {
|
|||||||
.attrs
|
.attrs
|
||||||
.get("jpegPhoto")
|
.get("jpegPhoto")
|
||||||
.map(|v| v.iter().map(|s| s.as_bytes().to_vec()).collect::<Vec<_>>())
|
.map(|v| v.iter().map(|s| s.as_bytes().to_vec()).collect::<Vec<_>>())
|
||||||
.or_else(|| entry.bin_attrs.get("jpegPhoto").map(Clone::clone))
|
.or_else(|| entry.bin_attrs.get("jpegPhoto").cloned())
|
||||||
.and_then(|v| v.into_iter().next().filter(|s| !s.is_empty()));
|
.and_then(|v| v.into_iter().next().filter(|s| !s.is_empty()));
|
||||||
let password =
|
let password =
|
||||||
get_optional_attribute("userPassword").or_else(|| get_optional_attribute("password"));
|
get_optional_attribute("userPassword").or_else(|| get_optional_attribute("password"));
|
||||||
Ok(User::new(
|
Ok(User::new(
|
||||||
crate::lldap::CreateUserInput {
|
crate::lldap::CreateUserInput {
|
||||||
id,
|
id,
|
||||||
email,
|
email: Some(email),
|
||||||
display_name,
|
display_name,
|
||||||
first_name,
|
first_name,
|
||||||
last_name,
|
last_name,
|
||||||
|
|||||||
3
schema.graphql
generated
3
schema.graphql
generated
@@ -63,7 +63,7 @@ type Query {
|
|||||||
"The details required to create a user."
|
"The details required to create a user."
|
||||||
input CreateUserInput {
|
input CreateUserInput {
|
||||||
id: String!
|
id: String!
|
||||||
email: String!
|
email: String
|
||||||
displayName: String
|
displayName: String
|
||||||
firstName: String
|
firstName: String
|
||||||
lastName: String
|
lastName: String
|
||||||
@@ -78,6 +78,7 @@ type AttributeSchema {
|
|||||||
isVisible: Boolean!
|
isVisible: Boolean!
|
||||||
isEditable: Boolean!
|
isEditable: Boolean!
|
||||||
isHardcoded: Boolean!
|
isHardcoded: Boolean!
|
||||||
|
isReadonly: Boolean!
|
||||||
}
|
}
|
||||||
|
|
||||||
"The fields that can be updated for a user."
|
"The fields that can be updated for a user."
|
||||||
|
|||||||
@@ -3,14 +3,24 @@
|
|||||||
set -e
|
set -e
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
|
|
||||||
LLDAP_URL="${LLDAP_URL}"
|
LLDAP_URL="${LLDAP_URL:-http://localhost:17170}"
|
||||||
LLDAP_ADMIN_USERNAME="${LLDAP_ADMIN_USERNAME}"
|
LLDAP_ADMIN_USERNAME="${LLDAP_ADMIN_USERNAME:-admin}"
|
||||||
LLDAP_ADMIN_PASSWORD="${LLDAP_ADMIN_PASSWORD}"
|
LLDAP_ADMIN_PASSWORD="${LLDAP_ADMIN_PASSWORD:-password}"
|
||||||
USER_CONFIGS_DIR="${USER_CONFIGS_DIR:-/user-configs}"
|
USER_SCHEMAS_DIR="${USER_SCHEMAS_DIR:-/bootstrap/user-schemas}"
|
||||||
GROUP_CONFIGS_DIR="${GROUP_CONFIGS_DIR:-/group-configs}"
|
GROUP_SCHEMAS_DIR="${GROUP_SCHEMAS_DIR:-/bootstrap/group-schemas}"
|
||||||
|
USER_CONFIGS_DIR="${USER_CONFIGS_DIR:-/bootstrap/user-configs}"
|
||||||
|
GROUP_CONFIGS_DIR="${GROUP_CONFIGS_DIR:-/bootstrap/group-configs}"
|
||||||
LLDAP_SET_PASSWORD_PATH="${LLDAP_SET_PASSWORD_PATH:-/app/lldap_set_password}"
|
LLDAP_SET_PASSWORD_PATH="${LLDAP_SET_PASSWORD_PATH:-/app/lldap_set_password}"
|
||||||
DO_CLEANUP="${DO_CLEANUP:-false}"
|
DO_CLEANUP="${DO_CLEANUP:-false}"
|
||||||
|
|
||||||
|
# Fallback to support legacy defaults
|
||||||
|
if [[ ! -d $USER_CONFIGS_DIR ]] && [[ -d "/user-configs" ]]; then
|
||||||
|
USER_CONFIGS_DIR="/user-configs"
|
||||||
|
fi
|
||||||
|
if [[ ! -d $GROUP_CONFIGS_DIR ]] && [[ -d "/group-configs" ]]; then
|
||||||
|
GROUP_CONFIGS_DIR="/group-configs"
|
||||||
|
fi
|
||||||
|
|
||||||
check_install_dependencies() {
|
check_install_dependencies() {
|
||||||
local commands=('curl' 'jq' 'jo')
|
local commands=('curl' 'jq' 'jo')
|
||||||
local commands_not_found='false'
|
local commands_not_found='false'
|
||||||
@@ -252,7 +262,7 @@ get_users_list() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
user_exists() {
|
user_exists() {
|
||||||
if [[ "$(get_users_list | jq --raw-output --arg id "$1" '.data.users | any(.[]; contains({"id": $id}))')" == 'true' ]]; then
|
if [[ "$(get_users_list | jq --raw-output --arg id "$1" '.data.users | any(.[]; .id == $id)')" == 'true' ]]; then
|
||||||
return 0
|
return 0
|
||||||
else
|
else
|
||||||
return 1
|
return 1
|
||||||
@@ -280,6 +290,80 @@ delete_user() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get_group_property_list() {
|
||||||
|
local query='{"query":"query GetGroupAttributesSchema { schema { groupSchema { attributes { name }}}}","operationName":"GetGroupAttributesSchema"}'
|
||||||
|
make_query <(printf '%s' "$query") <(printf '{}')
|
||||||
|
}
|
||||||
|
group_property_exists() {
|
||||||
|
if [[ "$(get_group_property_list | jq --raw-output --arg name "$1" '.data.schema.groupSchema.attributes | any(.[]; select(.name == $name))')" == 'true' ]]; then
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
create_group_schema_property() {
|
||||||
|
local name="$1"
|
||||||
|
local attributeType="$2"
|
||||||
|
local isEditable="$3"
|
||||||
|
local isList="$4"
|
||||||
|
local isVisible="$5"
|
||||||
|
|
||||||
|
if group_property_exists "$name"; then
|
||||||
|
printf 'Group property "%s" already exists\n' "$name"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
local query='{"query":"mutation CreateGroupAttribute($name: String!, $attributeType: AttributeType!, $isList: Boolean!, $isVisible: Boolean!, $isEditable: Boolean!) {addGroupAttribute(name: $name, attributeType: $attributeType, isList: $isList, isVisible: $isVisible, isEditable: $isEditable) {ok}}","operationName":"CreateGroupAttribute"}'
|
||||||
|
|
||||||
|
local response='' error=''
|
||||||
|
response="$(make_query <(printf '%s' "$query") <(jo -- name="$name" attributeType="$attributeType" isEditable="$isEditable" isList="$isList" isVisible="$isVisible"))"
|
||||||
|
error="$(printf '%s' "$response" | jq --raw-output '.errors | if . != null then .[].message else empty end')"
|
||||||
|
if [[ -n "$error" ]]; then
|
||||||
|
printf '%s\n' "$error"
|
||||||
|
else
|
||||||
|
printf 'Group attribute "%s" successfully created\n' "$name"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
get_user_property_list() {
|
||||||
|
local query='{"query":"query GetUserAttributesSchema { schema { userSchema { attributes { name }}}}","operationName":"GetUserAttributesSchema"}'
|
||||||
|
make_query <(printf '%s' "$query") <(printf '{}')
|
||||||
|
}
|
||||||
|
user_property_exists() {
|
||||||
|
if [[ "$(get_user_property_list | jq --raw-output --arg name "$1" '.data.schema.userSchema.attributes | any(.[]; select(.name == $name))')" == 'true' ]]; then
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
create_user_schema_property() {
|
||||||
|
local name="$1"
|
||||||
|
local attributeType="$2"
|
||||||
|
local isEditable="$3"
|
||||||
|
local isList="$4"
|
||||||
|
local isVisible="$5"
|
||||||
|
|
||||||
|
if user_property_exists "$name"; then
|
||||||
|
printf 'User property "%s" already exists\n' "$name"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
local query='{"query":"mutation CreateUserAttribute($name: String!, $attributeType: AttributeType!, $isList: Boolean!, $isVisible: Boolean!, $isEditable: Boolean!) {addUserAttribute(name: $name, attributeType: $attributeType, isList: $isList, isVisible: $isVisible, isEditable: $isEditable) {ok}}","operationName":"CreateUserAttribute"}'
|
||||||
|
|
||||||
|
local response='' error=''
|
||||||
|
response="$(make_query <(printf '%s' "$query") <(jo -- name="$name" attributeType="$attributeType" isEditable="$isEditable" isList="$isList" isVisible="$isVisible"))"
|
||||||
|
error="$(printf '%s' "$response" | jq --raw-output '.errors | if . != null then .[].message else empty end')"
|
||||||
|
if [[ -n "$error" ]]; then
|
||||||
|
printf '%s\n' "$error"
|
||||||
|
else
|
||||||
|
printf 'User attribute "%s" successfully created\n' "$name"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
__common_user_mutation_query() {
|
__common_user_mutation_query() {
|
||||||
local \
|
local \
|
||||||
query="$1" \
|
query="$1" \
|
||||||
@@ -387,8 +471,18 @@ main() {
|
|||||||
|
|
||||||
local user_config_files=("${USER_CONFIGS_DIR}"/*.json)
|
local user_config_files=("${USER_CONFIGS_DIR}"/*.json)
|
||||||
local group_config_files=("${GROUP_CONFIGS_DIR}"/*.json)
|
local group_config_files=("${GROUP_CONFIGS_DIR}"/*.json)
|
||||||
|
local user_schema_files=()
|
||||||
|
local group_schema_files=()
|
||||||
|
|
||||||
if ! check_configs_validity "${group_config_files[@]}" "${user_config_files[@]}"; then
|
local file=''
|
||||||
|
[[ -d "$USER_SCHEMAS_DIR" ]] && for file in "${USER_SCHEMAS_DIR}"/*.json; do
|
||||||
|
user_schema_files+=("$file")
|
||||||
|
done
|
||||||
|
[[ -d "$GROUP_SCHEMAS_DIR" ]] && for file in "${GROUP_SCHEMAS_DIR}"/*.json; do
|
||||||
|
group_schema_files+=("$file")
|
||||||
|
done
|
||||||
|
|
||||||
|
if ! check_configs_validity "${group_config_files[@]}" "${user_config_files[@]}" "${group_schema_files[@]}" "${user_schema_files[@]}"; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -399,6 +493,28 @@ main() {
|
|||||||
|
|
||||||
auth "$LLDAP_URL" "$LLDAP_ADMIN_USERNAME" "$LLDAP_ADMIN_PASSWORD"
|
auth "$LLDAP_URL" "$LLDAP_ADMIN_USERNAME" "$LLDAP_ADMIN_PASSWORD"
|
||||||
|
|
||||||
|
printf -- '\n--- group schemas ---\n'
|
||||||
|
local group_schema_config_row=''
|
||||||
|
[[ ${#group_schema_files[@]} -gt 0 ]] && while read -r group_schema_config_row; do
|
||||||
|
local field='' name='' attributeType='' isEditable='' isList='' isVisible=''
|
||||||
|
for field in 'name' 'attributeType' 'isEditable' 'isList' 'isVisible'; do
|
||||||
|
declare "$field"="$(printf '%s' "$group_schema_config_row" | jq --raw-output --arg field "$field" '.[$field]')"
|
||||||
|
done
|
||||||
|
create_group_schema_property "$name" "$attributeType" "$isEditable" "$isList" "$isVisible"
|
||||||
|
done < <(jq --compact-output '.[]' -- "${group_schema_files[@]}")
|
||||||
|
printf -- '--- group schemas ---\n'
|
||||||
|
|
||||||
|
printf -- '\n--- user schemas ---\n'
|
||||||
|
local user_schema_config_row=''
|
||||||
|
[[ ${#user_schema_files[@]} -gt 0 ]] && while read -r user_schema_config_row; do
|
||||||
|
local field='' name='' attributeType='' isEditable='' isList='' isVisible=''
|
||||||
|
for field in 'name' 'attributeType' 'isEditable' 'isList' 'isVisible'; do
|
||||||
|
declare "$field"="$(printf '%s' "$user_schema_config_row" | jq --raw-output --arg field "$field" '.[$field]')"
|
||||||
|
done
|
||||||
|
create_user_schema_property "$name" "$attributeType" "$isEditable" "$isList" "$isVisible"
|
||||||
|
done < <(jq --compact-output '.[]' -- "${user_schema_files[@]}")
|
||||||
|
printf -- '--- user schemas ---\n'
|
||||||
|
|
||||||
local redundant_groups=''
|
local redundant_groups=''
|
||||||
redundant_groups="$(get_group_list | jq '[ .data.groups[].displayName ]' | jq --compact-output '. - ["lldap_admin","lldap_password_manager","lldap_strict_readonly"]')"
|
redundant_groups="$(get_group_list | jq '[ .data.groups[].displayName ]' | jq --compact-output '. - ["lldap_admin","lldap_password_manager","lldap_strict_readonly"]')"
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ keywords = ["cli", "ldap", "graphql", "server", "authentication"]
|
|||||||
license = "GPL-3.0-only"
|
license = "GPL-3.0-only"
|
||||||
name = "lldap"
|
name = "lldap"
|
||||||
repository = "https://github.com/lldap/lldap"
|
repository = "https://github.com/lldap/lldap"
|
||||||
version = "0.5.1-alpha"
|
version = "0.6.2-alpha"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
actix = "0.13"
|
actix = "0.13"
|
||||||
@@ -25,7 +25,6 @@ base64 = "0.21"
|
|||||||
bincode = "1.3"
|
bincode = "1.3"
|
||||||
cron = "*"
|
cron = "*"
|
||||||
derive_builder = "0.12"
|
derive_builder = "0.12"
|
||||||
derive_more = "0.99"
|
|
||||||
figment_file_provider_adapter = "0.1"
|
figment_file_provider_adapter = "0.1"
|
||||||
futures = "*"
|
futures = "*"
|
||||||
futures-util = "*"
|
futures-util = "*"
|
||||||
@@ -35,7 +34,7 @@ itertools = "0.10"
|
|||||||
juniper = "0.15"
|
juniper = "0.15"
|
||||||
jwt = "0.16"
|
jwt = "0.16"
|
||||||
lber = "0.4.1"
|
lber = "0.4.1"
|
||||||
ldap3_proto = "^0.4.3"
|
ldap3_proto = "^0.5.1"
|
||||||
log = "*"
|
log = "*"
|
||||||
orion = "0.17"
|
orion = "0.17"
|
||||||
rand_chacha = "0.3"
|
rand_chacha = "0.3"
|
||||||
@@ -64,6 +63,11 @@ version = "*"
|
|||||||
features = ["std", "color", "suggestions", "derive", "env"]
|
features = ["std", "color", "suggestions", "derive", "env"]
|
||||||
version = "4"
|
version = "4"
|
||||||
|
|
||||||
|
[dependencies.derive_more]
|
||||||
|
features = ["debug", "display", "from", "from_str"]
|
||||||
|
default-features = false
|
||||||
|
version = "1"
|
||||||
|
|
||||||
[dependencies.figment]
|
[dependencies.figment]
|
||||||
features = ["env", "toml"]
|
features = ["env", "toml"]
|
||||||
version = "*"
|
version = "*"
|
||||||
@@ -82,7 +86,7 @@ path = "../auth"
|
|||||||
features = ["opaque_server", "opaque_client", "sea_orm"]
|
features = ["opaque_server", "opaque_client", "sea_orm"]
|
||||||
|
|
||||||
[dependencies.opaque-ke]
|
[dependencies.opaque-ke]
|
||||||
version = "0.6"
|
version = "0.7"
|
||||||
|
|
||||||
[dependencies.rand]
|
[dependencies.rand]
|
||||||
features = ["small_rng", "getrandom"]
|
features = ["small_rng", "getrandom"]
|
||||||
@@ -118,9 +122,15 @@ default-features = false
|
|||||||
version = "0.24"
|
version = "0.24"
|
||||||
|
|
||||||
[dependencies.sea-orm]
|
[dependencies.sea-orm]
|
||||||
version= "0.12"
|
version = "0.12"
|
||||||
default-features = false
|
default-features = false
|
||||||
features = ["macros", "with-chrono", "with-uuid", "sqlx-all", "runtime-actix-rustls"]
|
features = [
|
||||||
|
"macros",
|
||||||
|
"with-chrono",
|
||||||
|
"with-uuid",
|
||||||
|
"sqlx-all",
|
||||||
|
"runtime-actix-rustls",
|
||||||
|
]
|
||||||
|
|
||||||
[dependencies.reqwest]
|
[dependencies.reqwest]
|
||||||
version = "0.11"
|
version = "0.11"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use thiserror::Error;
|
|||||||
#[allow(clippy::enum_variant_names)]
|
#[allow(clippy::enum_variant_names)]
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum DomainError {
|
pub enum DomainError {
|
||||||
#[error("Authentication error: `{0}`")]
|
#[error("Authentication error {0}")]
|
||||||
AuthenticationError(String),
|
AuthenticationError(String),
|
||||||
#[error("Database error: `{0}`")]
|
#[error("Database error: `{0}`")]
|
||||||
DatabaseError(#[from] sea_orm::DbErr),
|
DatabaseError(#[from] sea_orm::DbErr),
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ pub enum UserRequestFilter {
|
|||||||
MemberOf(GroupName),
|
MemberOf(GroupName),
|
||||||
// Same, by id.
|
// Same, by id.
|
||||||
MemberOfId(GroupId),
|
MemberOfId(GroupId),
|
||||||
|
CustomAttributePresent(AttributeName),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<bool> for UserRequestFilter {
|
impl From<bool> for UserRequestFilter {
|
||||||
@@ -85,6 +86,7 @@ pub enum GroupRequestFilter {
|
|||||||
// Check if the group contains a user identified by uid.
|
// Check if the group contains a user identified by uid.
|
||||||
Member(UserId),
|
Member(UserId),
|
||||||
AttributeEquality(AttributeName, Serialized),
|
AttributeEquality(AttributeName, Serialized),
|
||||||
|
CustomAttributePresent(AttributeName),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<bool> for GroupRequestFilter {
|
impl From<bool> for GroupRequestFilter {
|
||||||
@@ -145,6 +147,7 @@ pub struct AttributeSchema {
|
|||||||
pub is_visible: bool,
|
pub is_visible: bool,
|
||||||
pub is_editable: bool,
|
pub is_editable: bool,
|
||||||
pub is_hardcoded: bool,
|
pub is_hardcoded: bool,
|
||||||
|
pub is_readonly: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
||||||
|
|||||||
@@ -7,29 +7,28 @@ use tracing::{debug, instrument, warn};
|
|||||||
use crate::domain::{
|
use crate::domain::{
|
||||||
deserialize::deserialize_attribute_value,
|
deserialize::deserialize_attribute_value,
|
||||||
handler::{GroupListerBackendHandler, GroupRequestFilter},
|
handler::{GroupListerBackendHandler, GroupRequestFilter},
|
||||||
ldap::error::LdapError,
|
ldap::{
|
||||||
|
error::{LdapError, LdapResult},
|
||||||
|
utils::{
|
||||||
|
expand_attribute_wildcards, get_custom_attribute,
|
||||||
|
get_group_id_from_distinguished_name_or_plain_name,
|
||||||
|
get_user_id_from_distinguished_name_or_plain_name, map_group_field, ExpandedAttributes,
|
||||||
|
GroupFieldType, LdapInfo,
|
||||||
|
},
|
||||||
|
},
|
||||||
schema::{PublicSchema, SchemaGroupAttributeExtractor},
|
schema::{PublicSchema, SchemaGroupAttributeExtractor},
|
||||||
types::{AttributeName, AttributeType, Group, LdapObjectClass, UserId, Uuid},
|
types::{AttributeName, AttributeType, Group, LdapObjectClass, UserId, Uuid},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
|
||||||
error::LdapResult,
|
|
||||||
utils::{
|
|
||||||
expand_attribute_wildcards, get_custom_attribute, get_group_id_from_distinguished_name,
|
|
||||||
get_user_id_from_distinguished_name, map_group_field, GroupFieldType, LdapInfo,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn get_group_attribute(
|
pub fn get_group_attribute(
|
||||||
group: &Group,
|
group: &Group,
|
||||||
base_dn_str: &str,
|
base_dn_str: &str,
|
||||||
attribute: &str,
|
attribute: &AttributeName,
|
||||||
user_filter: &Option<UserId>,
|
user_filter: &Option<UserId>,
|
||||||
ignored_group_attributes: &[AttributeName],
|
ignored_group_attributes: &[AttributeName],
|
||||||
schema: &PublicSchema,
|
schema: &PublicSchema,
|
||||||
) -> Option<Vec<Vec<u8>>> {
|
) -> Option<Vec<Vec<u8>>> {
|
||||||
let attribute = AttributeName::from(attribute);
|
let attribute_values = match map_group_field(attribute, schema) {
|
||||||
let attribute_values = match map_group_field(&attribute, schema) {
|
|
||||||
GroupFieldType::ObjectClass => {
|
GroupFieldType::ObjectClass => {
|
||||||
let mut classes = vec![b"groupOfUniqueNames".to_vec()];
|
let mut classes = vec![b"groupOfUniqueNames".to_vec()];
|
||||||
classes.extend(
|
classes.extend(
|
||||||
@@ -72,12 +71,12 @@ pub fn get_group_attribute(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
if ignored_group_attributes.contains(&attribute) {
|
if ignored_group_attributes.contains(attribute) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
get_custom_attribute::<SchemaGroupAttributeExtractor>(
|
get_custom_attribute::<SchemaGroupAttributeExtractor>(
|
||||||
&group.attributes,
|
&group.attributes,
|
||||||
&attribute,
|
attribute,
|
||||||
schema,
|
schema,
|
||||||
).or_else(||{warn!(
|
).or_else(||{warn!(
|
||||||
r#"Ignoring unrecognized group attribute: {}\n\
|
r#"Ignoring unrecognized group attribute: {}\n\
|
||||||
@@ -103,33 +102,42 @@ const ALL_GROUP_ATTRIBUTE_KEYS: &[&str] = &[
|
|||||||
"entryuuid",
|
"entryuuid",
|
||||||
];
|
];
|
||||||
|
|
||||||
fn expand_group_attribute_wildcards(attributes: &[String]) -> Vec<&str> {
|
fn expand_group_attribute_wildcards(attributes: &[String]) -> ExpandedAttributes {
|
||||||
expand_attribute_wildcards(attributes, ALL_GROUP_ATTRIBUTE_KEYS)
|
expand_attribute_wildcards(attributes, ALL_GROUP_ATTRIBUTE_KEYS)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn make_ldap_search_group_result_entry(
|
fn make_ldap_search_group_result_entry(
|
||||||
group: Group,
|
group: Group,
|
||||||
base_dn_str: &str,
|
base_dn_str: &str,
|
||||||
expanded_attributes: &[&str],
|
mut expanded_attributes: ExpandedAttributes,
|
||||||
user_filter: &Option<UserId>,
|
user_filter: &Option<UserId>,
|
||||||
ignored_group_attributes: &[AttributeName],
|
ignored_group_attributes: &[AttributeName],
|
||||||
schema: &PublicSchema,
|
schema: &PublicSchema,
|
||||||
) -> LdapSearchResultEntry {
|
) -> LdapSearchResultEntry {
|
||||||
|
if expanded_attributes.include_custom_attributes {
|
||||||
|
expanded_attributes.attribute_keys.extend(
|
||||||
|
group
|
||||||
|
.attributes
|
||||||
|
.iter()
|
||||||
|
.map(|a| (a.name.clone(), a.name.to_string())),
|
||||||
|
);
|
||||||
|
}
|
||||||
LdapSearchResultEntry {
|
LdapSearchResultEntry {
|
||||||
dn: format!("cn={},ou=groups,{}", group.display_name, base_dn_str),
|
dn: format!("cn={},ou=groups,{}", group.display_name, base_dn_str),
|
||||||
attributes: expanded_attributes
|
attributes: expanded_attributes
|
||||||
.iter()
|
.attribute_keys
|
||||||
.filter_map(|a| {
|
.into_iter()
|
||||||
|
.filter_map(|(attribute, name)| {
|
||||||
let values = get_group_attribute(
|
let values = get_group_attribute(
|
||||||
&group,
|
&group,
|
||||||
base_dn_str,
|
base_dn_str,
|
||||||
a,
|
&attribute,
|
||||||
user_filter,
|
user_filter,
|
||||||
ignored_group_attributes,
|
ignored_group_attributes,
|
||||||
schema,
|
schema,
|
||||||
)?;
|
)?;
|
||||||
Some(LdapPartialAttribute {
|
Some(LdapPartialAttribute {
|
||||||
atype: a.to_string(),
|
atype: name,
|
||||||
vals: values,
|
vals: values,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -142,13 +150,13 @@ fn get_group_attribute_equality_filter(
|
|||||||
typ: AttributeType,
|
typ: AttributeType,
|
||||||
is_list: bool,
|
is_list: bool,
|
||||||
value: &str,
|
value: &str,
|
||||||
) -> LdapResult<GroupRequestFilter> {
|
) -> GroupRequestFilter {
|
||||||
deserialize_attribute_value(&[value.to_owned()], typ, is_list)
|
deserialize_attribute_value(&[value.to_owned()], typ, is_list)
|
||||||
.map_err(|e| LdapError {
|
|
||||||
code: LdapResultCode::Other,
|
|
||||||
message: format!("Invalid value for attribute {}: {}", field, e),
|
|
||||||
})
|
|
||||||
.map(|v| GroupRequestFilter::AttributeEquality(field.clone(), v))
|
.map(|v| GroupRequestFilter::AttributeEquality(field.clone(), v))
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
warn!("Invalid value for attribute {}: {}", field, e);
|
||||||
|
GroupRequestFilter::from(false)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn convert_group_filter(
|
fn convert_group_filter(
|
||||||
@@ -163,20 +171,22 @@ fn convert_group_filter(
|
|||||||
let value = value.to_ascii_lowercase();
|
let value = value.to_ascii_lowercase();
|
||||||
match map_group_field(&field, schema) {
|
match map_group_field(&field, schema) {
|
||||||
GroupFieldType::DisplayName => Ok(GroupRequestFilter::DisplayName(value.into())),
|
GroupFieldType::DisplayName => Ok(GroupRequestFilter::DisplayName(value.into())),
|
||||||
GroupFieldType::Uuid => Ok(GroupRequestFilter::Uuid(
|
GroupFieldType::Uuid => Uuid::try_from(value.as_str())
|
||||||
Uuid::try_from(value.as_str()).map_err(|e| LdapError {
|
.map(GroupRequestFilter::Uuid)
|
||||||
code: LdapResultCode::InappropriateMatching,
|
.map_err(|e| LdapError {
|
||||||
|
code: LdapResultCode::Other,
|
||||||
message: format!("Invalid UUID: {:#}", e),
|
message: format!("Invalid UUID: {:#}", e),
|
||||||
})?,
|
}),
|
||||||
)),
|
GroupFieldType::Member => Ok(get_user_id_from_distinguished_name_or_plain_name(
|
||||||
GroupFieldType::Member => {
|
&value,
|
||||||
let user_name = get_user_id_from_distinguished_name(
|
&ldap_info.base_dn,
|
||||||
&value,
|
&ldap_info.base_dn_str,
|
||||||
&ldap_info.base_dn,
|
)
|
||||||
&ldap_info.base_dn_str,
|
.map(GroupRequestFilter::Member)
|
||||||
)?;
|
.unwrap_or_else(|e| {
|
||||||
Ok(GroupRequestFilter::Member(user_name))
|
warn!("Invalid member filter on group: {}", e);
|
||||||
}
|
GroupRequestFilter::from(false)
|
||||||
|
})),
|
||||||
GroupFieldType::ObjectClass => Ok(GroupRequestFilter::from(
|
GroupFieldType::ObjectClass => Ok(GroupRequestFilter::from(
|
||||||
matches!(value.as_str(), "groupofuniquenames" | "groupofnames")
|
matches!(value.as_str(), "groupofuniquenames" | "groupofnames")
|
||||||
|| schema
|
|| schema
|
||||||
@@ -185,7 +195,7 @@ fn convert_group_filter(
|
|||||||
.contains(&LdapObjectClass::from(value)),
|
.contains(&LdapObjectClass::from(value)),
|
||||||
)),
|
)),
|
||||||
GroupFieldType::Dn | GroupFieldType::EntryDn => {
|
GroupFieldType::Dn | GroupFieldType::EntryDn => {
|
||||||
Ok(get_group_id_from_distinguished_name(
|
Ok(get_group_id_from_distinguished_name_or_plain_name(
|
||||||
value.as_str(),
|
value.as_str(),
|
||||||
&ldap_info.base_dn,
|
&ldap_info.base_dn,
|
||||||
&ldap_info.base_dn_str,
|
&ldap_info.base_dn_str,
|
||||||
@@ -206,9 +216,9 @@ fn convert_group_filter(
|
|||||||
}
|
}
|
||||||
Ok(GroupRequestFilter::from(false))
|
Ok(GroupRequestFilter::from(false))
|
||||||
}
|
}
|
||||||
GroupFieldType::Attribute(field, typ, is_list) => {
|
GroupFieldType::Attribute(field, typ, is_list) => Ok(
|
||||||
get_group_attribute_equality_filter(&field, typ, is_list, &value)
|
get_group_attribute_equality_filter(&field, typ, is_list, &value),
|
||||||
}
|
),
|
||||||
GroupFieldType::CreationDate => Err(LdapError {
|
GroupFieldType::CreationDate => Err(LdapError {
|
||||||
code: LdapResultCode::UnwillingToPerform,
|
code: LdapResultCode::UnwillingToPerform,
|
||||||
message: "Creation date filter for groups not supported".to_owned(),
|
message: "Creation date filter for groups not supported".to_owned(),
|
||||||
@@ -224,10 +234,13 @@ fn convert_group_filter(
|
|||||||
LdapFilter::Not(filter) => Ok(GroupRequestFilter::Not(Box::new(rec(filter)?))),
|
LdapFilter::Not(filter) => Ok(GroupRequestFilter::Not(Box::new(rec(filter)?))),
|
||||||
LdapFilter::Present(field) => {
|
LdapFilter::Present(field) => {
|
||||||
let field = AttributeName::from(field.as_str());
|
let field = AttributeName::from(field.as_str());
|
||||||
Ok(GroupRequestFilter::from(!matches!(
|
Ok(match map_group_field(&field, schema) {
|
||||||
map_group_field(&field, schema),
|
GroupFieldType::Attribute(name, _, _) => {
|
||||||
GroupFieldType::NoMatch
|
GroupRequestFilter::CustomAttributePresent(name)
|
||||||
)))
|
}
|
||||||
|
GroupFieldType::NoMatch => GroupRequestFilter::from(false),
|
||||||
|
_ => GroupRequestFilter::from(true),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
LdapFilter::Substring(field, substring_filter) => {
|
LdapFilter::Substring(field, substring_filter) => {
|
||||||
let field = AttributeName::from(field.as_str());
|
let field = AttributeName::from(field.as_str());
|
||||||
@@ -288,7 +301,7 @@ pub fn convert_groups_to_ldap_op<'a>(
|
|||||||
LdapOp::SearchResultEntry(make_ldap_search_group_result_entry(
|
LdapOp::SearchResultEntry(make_ldap_search_group_result_entry(
|
||||||
g,
|
g,
|
||||||
&ldap_info.base_dn_str,
|
&ldap_info.base_dn_str,
|
||||||
expanded_attributes.as_ref().unwrap(),
|
expanded_attributes.clone().unwrap(),
|
||||||
user_filter,
|
user_filter,
|
||||||
&ldap_info.ignored_group_attributes,
|
&ldap_info.ignored_group_attributes,
|
||||||
schema,
|
schema,
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ use crate::domain::{
|
|||||||
ldap::{
|
ldap::{
|
||||||
error::{LdapError, LdapResult},
|
error::{LdapError, LdapResult},
|
||||||
utils::{
|
utils::{
|
||||||
expand_attribute_wildcards, get_custom_attribute, get_group_id_from_distinguished_name,
|
expand_attribute_wildcards, get_custom_attribute,
|
||||||
get_user_id_from_distinguished_name, map_user_field, LdapInfo, UserFieldType,
|
get_group_id_from_distinguished_name_or_plain_name,
|
||||||
|
get_user_id_from_distinguished_name_or_plain_name, map_user_field, ExpandedAttributes,
|
||||||
|
LdapInfo, UserFieldType,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
schema::{PublicSchema, SchemaUserAttributeExtractor},
|
schema::{PublicSchema, SchemaUserAttributeExtractor},
|
||||||
@@ -23,14 +25,13 @@ use crate::domain::{
|
|||||||
|
|
||||||
pub fn get_user_attribute(
|
pub fn get_user_attribute(
|
||||||
user: &User,
|
user: &User,
|
||||||
attribute: &str,
|
attribute: &AttributeName,
|
||||||
base_dn_str: &str,
|
base_dn_str: &str,
|
||||||
groups: Option<&[GroupDetails]>,
|
groups: Option<&[GroupDetails]>,
|
||||||
ignored_user_attributes: &[AttributeName],
|
ignored_user_attributes: &[AttributeName],
|
||||||
schema: &PublicSchema,
|
schema: &PublicSchema,
|
||||||
) -> Option<Vec<Vec<u8>>> {
|
) -> Option<Vec<Vec<u8>>> {
|
||||||
let attribute = AttributeName::from(attribute);
|
let attribute_values = match map_user_field(attribute, schema) {
|
||||||
let attribute_values = match map_user_field(&attribute, schema) {
|
|
||||||
UserFieldType::ObjectClass => {
|
UserFieldType::ObjectClass => {
|
||||||
let mut classes = vec![
|
let mut classes = vec![
|
||||||
b"inetOrgPerson".to_vec(),
|
b"inetOrgPerson".to_vec(),
|
||||||
@@ -91,12 +92,12 @@ pub fn get_user_attribute(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
if ignored_user_attributes.contains(&attribute) {
|
if ignored_user_attributes.contains(attribute) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
get_custom_attribute::<SchemaUserAttributeExtractor>(
|
get_custom_attribute::<SchemaUserAttributeExtractor>(
|
||||||
&user.attributes,
|
&user.attributes,
|
||||||
&attribute,
|
attribute,
|
||||||
schema,
|
schema,
|
||||||
)
|
)
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
@@ -132,27 +133,34 @@ const ALL_USER_ATTRIBUTE_KEYS: &[&str] = &[
|
|||||||
fn make_ldap_search_user_result_entry(
|
fn make_ldap_search_user_result_entry(
|
||||||
user: User,
|
user: User,
|
||||||
base_dn_str: &str,
|
base_dn_str: &str,
|
||||||
expanded_attributes: &[&str],
|
mut expanded_attributes: ExpandedAttributes,
|
||||||
groups: Option<&[GroupDetails]>,
|
groups: Option<&[GroupDetails]>,
|
||||||
ignored_user_attributes: &[AttributeName],
|
ignored_user_attributes: &[AttributeName],
|
||||||
schema: &PublicSchema,
|
schema: &PublicSchema,
|
||||||
) -> LdapSearchResultEntry {
|
) -> LdapSearchResultEntry {
|
||||||
let dn = format!("uid={},ou=people,{}", user.user_id.as_str(), base_dn_str);
|
if expanded_attributes.include_custom_attributes {
|
||||||
|
expanded_attributes.attribute_keys.extend(
|
||||||
|
user.attributes
|
||||||
|
.iter()
|
||||||
|
.map(|a| (a.name.clone(), a.name.to_string())),
|
||||||
|
);
|
||||||
|
}
|
||||||
LdapSearchResultEntry {
|
LdapSearchResultEntry {
|
||||||
dn,
|
dn: format!("uid={},ou=people,{}", user.user_id.as_str(), base_dn_str),
|
||||||
attributes: expanded_attributes
|
attributes: expanded_attributes
|
||||||
.iter()
|
.attribute_keys
|
||||||
.filter_map(|a| {
|
.into_iter()
|
||||||
|
.filter_map(|(attribute, name)| {
|
||||||
let values = get_user_attribute(
|
let values = get_user_attribute(
|
||||||
&user,
|
&user,
|
||||||
a,
|
&attribute,
|
||||||
base_dn_str,
|
base_dn_str,
|
||||||
groups,
|
groups,
|
||||||
ignored_user_attributes,
|
ignored_user_attributes,
|
||||||
schema,
|
schema,
|
||||||
)?;
|
)?;
|
||||||
Some(LdapPartialAttribute {
|
Some(LdapPartialAttribute {
|
||||||
atype: a.to_string(),
|
atype: name,
|
||||||
vals: values,
|
vals: values,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -165,13 +173,13 @@ fn get_user_attribute_equality_filter(
|
|||||||
typ: AttributeType,
|
typ: AttributeType,
|
||||||
is_list: bool,
|
is_list: bool,
|
||||||
value: &str,
|
value: &str,
|
||||||
) -> LdapResult<UserRequestFilter> {
|
) -> UserRequestFilter {
|
||||||
deserialize_attribute_value(&[value.to_owned()], typ, is_list)
|
deserialize_attribute_value(&[value.to_owned()], typ, is_list)
|
||||||
.map_err(|e| LdapError {
|
|
||||||
code: LdapResultCode::Other,
|
|
||||||
message: format!("Invalid value for attribute {}: {}", field, e),
|
|
||||||
})
|
|
||||||
.map(|v| UserRequestFilter::AttributeEquality(field.clone(), v))
|
.map(|v| UserRequestFilter::AttributeEquality(field.clone(), v))
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
warn!("Invalid value for attribute {}: {}", field, e);
|
||||||
|
UserRequestFilter::from(false)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn convert_user_filter(
|
fn convert_user_filter(
|
||||||
@@ -195,10 +203,14 @@ fn convert_user_filter(
|
|||||||
UserFieldType::PrimaryField(UserColumn::UserId) => {
|
UserFieldType::PrimaryField(UserColumn::UserId) => {
|
||||||
Ok(UserRequestFilter::UserId(UserId::new(&value)))
|
Ok(UserRequestFilter::UserId(UserId::new(&value)))
|
||||||
}
|
}
|
||||||
|
UserFieldType::PrimaryField(UserColumn::Email) => Ok(UserRequestFilter::Equality(
|
||||||
|
UserColumn::LowercaseEmail,
|
||||||
|
value,
|
||||||
|
)),
|
||||||
UserFieldType::PrimaryField(field) => Ok(UserRequestFilter::Equality(field, value)),
|
UserFieldType::PrimaryField(field) => Ok(UserRequestFilter::Equality(field, value)),
|
||||||
UserFieldType::Attribute(field, typ, is_list) => {
|
UserFieldType::Attribute(field, typ, is_list) => Ok(
|
||||||
get_user_attribute_equality_filter(&field, typ, is_list, &value)
|
get_user_attribute_equality_filter(&field, typ, is_list, &value),
|
||||||
}
|
),
|
||||||
UserFieldType::NoMatch => {
|
UserFieldType::NoMatch => {
|
||||||
if !ldap_info.ignored_user_attributes.contains(&field) {
|
if !ldap_info.ignored_user_attributes.contains(&field) {
|
||||||
warn!(
|
warn!(
|
||||||
@@ -218,15 +230,18 @@ fn convert_user_filter(
|
|||||||
.extra_user_object_classes
|
.extra_user_object_classes
|
||||||
.contains(&LdapObjectClass::from(value)),
|
.contains(&LdapObjectClass::from(value)),
|
||||||
)),
|
)),
|
||||||
UserFieldType::MemberOf => Ok(UserRequestFilter::MemberOf(
|
UserFieldType::MemberOf => Ok(get_group_id_from_distinguished_name_or_plain_name(
|
||||||
get_group_id_from_distinguished_name(
|
&value,
|
||||||
&value,
|
&ldap_info.base_dn,
|
||||||
&ldap_info.base_dn,
|
&ldap_info.base_dn_str,
|
||||||
&ldap_info.base_dn_str,
|
)
|
||||||
)?,
|
.map(UserRequestFilter::MemberOf)
|
||||||
)),
|
.unwrap_or_else(|e| {
|
||||||
|
warn!("Invalid memberOf filter: {}", e);
|
||||||
|
UserRequestFilter::from(false)
|
||||||
|
})),
|
||||||
UserFieldType::EntryDn | UserFieldType::Dn => {
|
UserFieldType::EntryDn | UserFieldType::Dn => {
|
||||||
Ok(get_user_id_from_distinguished_name(
|
Ok(get_user_id_from_distinguished_name_or_plain_name(
|
||||||
value.as_str(),
|
value.as_str(),
|
||||||
&ldap_info.base_dn,
|
&ldap_info.base_dn,
|
||||||
&ldap_info.base_dn_str,
|
&ldap_info.base_dn_str,
|
||||||
@@ -241,13 +256,13 @@ fn convert_user_filter(
|
|||||||
}
|
}
|
||||||
LdapFilter::Present(field) => {
|
LdapFilter::Present(field) => {
|
||||||
let field = AttributeName::from(field.as_str());
|
let field = AttributeName::from(field.as_str());
|
||||||
// Check that it's a field we support.
|
Ok(match map_user_field(&field, schema) {
|
||||||
Ok(UserRequestFilter::from(
|
UserFieldType::Attribute(name, _, _) => {
|
||||||
field.as_str() == "objectclass"
|
UserRequestFilter::CustomAttributePresent(name)
|
||||||
|| field.as_str() == "dn"
|
}
|
||||||
|| field.as_str() == "distinguishedname"
|
UserFieldType::NoMatch => UserRequestFilter::from(false),
|
||||||
|| !matches!(map_user_field(&field, schema), UserFieldType::NoMatch),
|
_ => UserRequestFilter::from(true),
|
||||||
))
|
})
|
||||||
}
|
}
|
||||||
LdapFilter::Substring(field, substring_filter) => {
|
LdapFilter::Substring(field, substring_filter) => {
|
||||||
let field = AttributeName::from(field.as_str());
|
let field = AttributeName::from(field.as_str());
|
||||||
@@ -269,6 +284,10 @@ fn convert_user_filter(
|
|||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
UserFieldType::NoMatch => Ok(UserRequestFilter::from(false)),
|
UserFieldType::NoMatch => Ok(UserRequestFilter::from(false)),
|
||||||
|
UserFieldType::PrimaryField(UserColumn::Email) => Ok(UserRequestFilter::SubString(
|
||||||
|
UserColumn::LowercaseEmail,
|
||||||
|
substring_filter.clone().into(),
|
||||||
|
)),
|
||||||
UserFieldType::PrimaryField(field) => Ok(UserRequestFilter::SubString(
|
UserFieldType::PrimaryField(field) => Ok(UserRequestFilter::SubString(
|
||||||
field,
|
field,
|
||||||
substring_filter.clone().into(),
|
substring_filter.clone().into(),
|
||||||
@@ -282,7 +301,7 @@ fn convert_user_filter(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn expand_user_attribute_wildcards(attributes: &[String]) -> Vec<&str> {
|
fn expand_user_attribute_wildcards(attributes: &[String]) -> ExpandedAttributes {
|
||||||
expand_attribute_wildcards(attributes, ALL_USER_ATTRIBUTE_KEYS)
|
expand_attribute_wildcards(attributes, ALL_USER_ATTRIBUTE_KEYS)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +340,7 @@ pub fn convert_users_to_ldap_op<'a>(
|
|||||||
LdapOp::SearchResultEntry(make_ldap_search_user_result_entry(
|
LdapOp::SearchResultEntry(make_ldap_search_user_result_entry(
|
||||||
u.user,
|
u.user,
|
||||||
&ldap_info.base_dn_str,
|
&ldap_info.base_dn_str,
|
||||||
expanded_attributes.as_ref().unwrap(),
|
expanded_attributes.clone().unwrap(),
|
||||||
u.groups.as_deref(),
|
u.groups.as_deref(),
|
||||||
&ldap_info.ignored_user_attributes,
|
&ldap_info.ignored_user_attributes,
|
||||||
schema,
|
schema,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use chrono::{NaiveDateTime, TimeZone};
|
use chrono::{NaiveDateTime, TimeZone};
|
||||||
use itertools::Itertools;
|
|
||||||
use ldap3_proto::{proto::LdapSubstringFilter, LdapResultCode};
|
use ldap3_proto::{proto::LdapSubstringFilter, LdapResultCode};
|
||||||
use tracing::{debug, instrument, warn};
|
use tracing::{debug, instrument, warn};
|
||||||
|
|
||||||
@@ -109,30 +110,67 @@ pub fn get_group_id_from_distinguished_name(
|
|||||||
get_id_from_distinguished_name(dn, base_tree, base_dn_str, true).map(GroupName::from)
|
get_id_from_distinguished_name(dn, base_tree, base_dn_str, true).map(GroupName::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn looks_like_distinguished_name(dn: &str) -> bool {
|
||||||
|
dn.contains('=') || dn.contains(',')
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_user_id_from_distinguished_name_or_plain_name(
|
||||||
|
dn: &str,
|
||||||
|
base_tree: &[(String, String)],
|
||||||
|
base_dn_str: &str,
|
||||||
|
) -> LdapResult<UserId> {
|
||||||
|
if !looks_like_distinguished_name(dn) {
|
||||||
|
Ok(UserId::from(dn))
|
||||||
|
} else {
|
||||||
|
get_user_id_from_distinguished_name(dn, base_tree, base_dn_str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_group_id_from_distinguished_name_or_plain_name(
|
||||||
|
dn: &str,
|
||||||
|
base_tree: &[(String, String)],
|
||||||
|
base_dn_str: &str,
|
||||||
|
) -> LdapResult<GroupName> {
|
||||||
|
if !looks_like_distinguished_name(dn) {
|
||||||
|
Ok(GroupName::from(dn))
|
||||||
|
} else {
|
||||||
|
get_group_id_from_distinguished_name(dn, base_tree, base_dn_str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ExpandedAttributes {
|
||||||
|
// Lowercase name to original name.
|
||||||
|
pub attribute_keys: BTreeMap<AttributeName, String>,
|
||||||
|
pub include_custom_attributes: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[instrument(skip(all_attribute_keys), level = "debug")]
|
#[instrument(skip(all_attribute_keys), level = "debug")]
|
||||||
pub fn expand_attribute_wildcards<'a>(
|
pub fn expand_attribute_wildcards(
|
||||||
ldap_attributes: &'a [String],
|
ldap_attributes: &[String],
|
||||||
all_attribute_keys: &'a [&'static str],
|
all_attribute_keys: &[&'static str],
|
||||||
) -> Vec<&'a str> {
|
) -> ExpandedAttributes {
|
||||||
let extra_attributes =
|
let mut include_custom_attributes = false;
|
||||||
|
let mut attributes_out: BTreeMap<_, _> = ldap_attributes
|
||||||
|
.iter()
|
||||||
|
.filter(|&s| s != "*" && s != "+" && s != "1.1")
|
||||||
|
.map(|s| (AttributeName::from(s), s.to_string()))
|
||||||
|
.collect();
|
||||||
|
attributes_out.extend(
|
||||||
if ldap_attributes.iter().any(|x| x == "*") || ldap_attributes.is_empty() {
|
if ldap_attributes.iter().any(|x| x == "*") || ldap_attributes.is_empty() {
|
||||||
|
include_custom_attributes = true;
|
||||||
all_attribute_keys
|
all_attribute_keys
|
||||||
} else {
|
} else {
|
||||||
&[]
|
&[]
|
||||||
}
|
}
|
||||||
.iter()
|
.iter()
|
||||||
.copied();
|
.map(|&s| (AttributeName::from(s), s.to_string())),
|
||||||
let attributes_out = ldap_attributes
|
);
|
||||||
.iter()
|
debug!(?attributes_out);
|
||||||
.map(|s| s.as_str())
|
ExpandedAttributes {
|
||||||
.filter(|&s| s != "*" && s != "+" && s != "1.1");
|
attribute_keys: attributes_out,
|
||||||
|
include_custom_attributes,
|
||||||
// Deduplicate, preserving order
|
}
|
||||||
let resolved_attributes = itertools::chain(attributes_out, extra_attributes)
|
|
||||||
.unique_by(|a| a.to_ascii_lowercase())
|
|
||||||
.collect_vec();
|
|
||||||
debug!(?resolved_attributes);
|
|
||||||
resolved_attributes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_subtree(subtree: &[(String, String)], base_tree: &[(String, String)]) -> bool {
|
pub fn is_subtree(subtree: &[(String, String)], base_tree: &[(String, String)]) -> bool {
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ impl From<Model> for AttributeSchema {
|
|||||||
is_visible: value.is_group_visible,
|
is_visible: value.is_group_visible,
|
||||||
is_editable: value.is_group_editable,
|
is_editable: value.is_group_editable,
|
||||||
is_hardcoded: value.is_hardcoded,
|
is_hardcoded: value.is_hardcoded,
|
||||||
|
is_readonly: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ impl From<Model> for AttributeSchema {
|
|||||||
is_visible: value.is_user_visible,
|
is_visible: value.is_user_visible,
|
||||||
is_editable: value.is_user_editable,
|
is_editable: value.is_user_editable,
|
||||||
is_hardcoded: value.is_hardcoded,
|
is_hardcoded: value.is_hardcoded,
|
||||||
|
is_readonly: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ impl From<Schema> for PublicSchema {
|
|||||||
is_visible: true,
|
is_visible: true,
|
||||||
is_editable: false,
|
is_editable: false,
|
||||||
is_hardcoded: true,
|
is_hardcoded: true,
|
||||||
|
is_readonly: true,
|
||||||
},
|
},
|
||||||
AttributeSchema {
|
AttributeSchema {
|
||||||
name: "creation_date".into(),
|
name: "creation_date".into(),
|
||||||
@@ -51,6 +52,7 @@ impl From<Schema> for PublicSchema {
|
|||||||
is_visible: true,
|
is_visible: true,
|
||||||
is_editable: false,
|
is_editable: false,
|
||||||
is_hardcoded: true,
|
is_hardcoded: true,
|
||||||
|
is_readonly: true,
|
||||||
},
|
},
|
||||||
AttributeSchema {
|
AttributeSchema {
|
||||||
name: "mail".into(),
|
name: "mail".into(),
|
||||||
@@ -59,6 +61,7 @@ impl From<Schema> for PublicSchema {
|
|||||||
is_visible: true,
|
is_visible: true,
|
||||||
is_editable: true,
|
is_editable: true,
|
||||||
is_hardcoded: true,
|
is_hardcoded: true,
|
||||||
|
is_readonly: false,
|
||||||
},
|
},
|
||||||
AttributeSchema {
|
AttributeSchema {
|
||||||
name: "uuid".into(),
|
name: "uuid".into(),
|
||||||
@@ -67,6 +70,7 @@ impl From<Schema> for PublicSchema {
|
|||||||
is_visible: true,
|
is_visible: true,
|
||||||
is_editable: false,
|
is_editable: false,
|
||||||
is_hardcoded: true,
|
is_hardcoded: true,
|
||||||
|
is_readonly: true,
|
||||||
},
|
},
|
||||||
AttributeSchema {
|
AttributeSchema {
|
||||||
name: "display_name".into(),
|
name: "display_name".into(),
|
||||||
@@ -75,6 +79,7 @@ impl From<Schema> for PublicSchema {
|
|||||||
is_visible: true,
|
is_visible: true,
|
||||||
is_editable: true,
|
is_editable: true,
|
||||||
is_hardcoded: true,
|
is_hardcoded: true,
|
||||||
|
is_readonly: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
schema
|
schema
|
||||||
@@ -89,6 +94,7 @@ impl From<Schema> for PublicSchema {
|
|||||||
is_visible: true,
|
is_visible: true,
|
||||||
is_editable: false,
|
is_editable: false,
|
||||||
is_hardcoded: true,
|
is_hardcoded: true,
|
||||||
|
is_readonly: true,
|
||||||
},
|
},
|
||||||
AttributeSchema {
|
AttributeSchema {
|
||||||
name: "creation_date".into(),
|
name: "creation_date".into(),
|
||||||
@@ -97,6 +103,7 @@ impl From<Schema> for PublicSchema {
|
|||||||
is_visible: true,
|
is_visible: true,
|
||||||
is_editable: false,
|
is_editable: false,
|
||||||
is_hardcoded: true,
|
is_hardcoded: true,
|
||||||
|
is_readonly: true,
|
||||||
},
|
},
|
||||||
AttributeSchema {
|
AttributeSchema {
|
||||||
name: "uuid".into(),
|
name: "uuid".into(),
|
||||||
@@ -105,6 +112,7 @@ impl From<Schema> for PublicSchema {
|
|||||||
is_visible: true,
|
is_visible: true,
|
||||||
is_editable: false,
|
is_editable: false,
|
||||||
is_hardcoded: true,
|
is_hardcoded: true,
|
||||||
|
is_readonly: true,
|
||||||
},
|
},
|
||||||
AttributeSchema {
|
AttributeSchema {
|
||||||
name: "display_name".into(),
|
name: "display_name".into(),
|
||||||
@@ -113,6 +121,7 @@ impl From<Schema> for PublicSchema {
|
|||||||
is_visible: true,
|
is_visible: true,
|
||||||
is_editable: true,
|
is_editable: true,
|
||||||
is_hardcoded: true,
|
is_hardcoded: true,
|
||||||
|
is_readonly: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
schema
|
schema
|
||||||
|
|||||||
@@ -16,14 +16,18 @@ use sea_orm::{
|
|||||||
};
|
};
|
||||||
use tracing::instrument;
|
use tracing::instrument;
|
||||||
|
|
||||||
fn attribute_condition(name: AttributeName, value: Serialized) -> Cond {
|
fn attribute_condition(name: AttributeName, value: Option<Serialized>) -> Cond {
|
||||||
Expr::in_subquery(
|
Expr::in_subquery(
|
||||||
Expr::col(GroupColumn::GroupId.as_column_ref()),
|
Expr::col(GroupColumn::GroupId.as_column_ref()),
|
||||||
model::GroupAttributes::find()
|
model::GroupAttributes::find()
|
||||||
.select_only()
|
.select_only()
|
||||||
.column(model::GroupAttributesColumn::GroupId)
|
.column(model::GroupAttributesColumn::GroupId)
|
||||||
.filter(model::GroupAttributesColumn::AttributeName.eq(name))
|
.filter(model::GroupAttributesColumn::AttributeName.eq(name))
|
||||||
.filter(model::GroupAttributesColumn::Value.eq(value))
|
.filter(
|
||||||
|
value
|
||||||
|
.map(|value| model::GroupAttributesColumn::Value.eq(value))
|
||||||
|
.unwrap_or_else(|| SimpleExpr::Value(true.into())),
|
||||||
|
)
|
||||||
.into_query(),
|
.into_query(),
|
||||||
)
|
)
|
||||||
.into_condition()
|
.into_condition()
|
||||||
@@ -67,11 +71,12 @@ fn get_group_filter_expr(filter: GroupRequestFilter) -> Cond {
|
|||||||
.into_condition(),
|
.into_condition(),
|
||||||
DisplayNameSubString(filter) => SimpleExpr::FunctionCall(Func::lower(Expr::col((
|
DisplayNameSubString(filter) => SimpleExpr::FunctionCall(Func::lower(Expr::col((
|
||||||
group_table,
|
group_table,
|
||||||
GroupColumn::DisplayName,
|
GroupColumn::LowercaseDisplayName,
|
||||||
))))
|
))))
|
||||||
.like(filter.to_sql_filter())
|
.like(filter.to_sql_filter())
|
||||||
.into_condition(),
|
.into_condition(),
|
||||||
AttributeEquality(name, value) => attribute_condition(name, value),
|
AttributeEquality(name, value) => attribute_condition(name, Some(value)),
|
||||||
|
CustomAttributePresent(name) => attribute_condition(name, None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -606,4 +611,25 @@ mod tests {
|
|||||||
let details = fixture.handler.get_group_details(group_id).await.unwrap();
|
let details = fixture.handler.get_group_details(group_id).await.unwrap();
|
||||||
assert_eq!(details.attributes, Vec::new());
|
assert_eq!(details.attributes, Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_create_group_duplicate_name() {
|
||||||
|
let fixture = TestFixture::new().await;
|
||||||
|
fixture
|
||||||
|
.handler
|
||||||
|
.create_group(CreateGroupRequest {
|
||||||
|
display_name: "New Group".into(),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
fixture
|
||||||
|
.handler
|
||||||
|
.create_group(CreateGroupRequest {
|
||||||
|
display_name: "neW group".into(),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ use crate::domain::{
|
|||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
sea_query::{
|
sea_query::{
|
||||||
self, all, Alias, BinOper, BlobSize::Blob, ColumnDef, Expr, ForeignKey, ForeignKeyAction,
|
self, all, BinOper, BlobSize::Blob, ColumnDef, Expr, ForeignKey, ForeignKeyAction, Func,
|
||||||
Func, Index, Query, SimpleExpr, Table, Value,
|
Index, Query, SimpleExpr, Table, Value,
|
||||||
},
|
},
|
||||||
ConnectionTrait, DatabaseTransaction, DbErr, DeriveIden, FromQueryResult, Iden, Order,
|
ConnectionTrait, DatabaseTransaction, DbErr, DeriveIden, FromQueryResult, Iden, Order,
|
||||||
Statement, TransactionTrait,
|
Statement, TransactionTrait,
|
||||||
@@ -970,21 +970,15 @@ async fn migrate_to_v8(transaction: DatabaseTransaction) -> Result<DatabaseTrans
|
|||||||
let builder = transaction.get_database_backend();
|
let builder = transaction.get_database_backend();
|
||||||
// Remove duplicate memberships.
|
// Remove duplicate memberships.
|
||||||
#[derive(FromQueryResult)]
|
#[derive(FromQueryResult)]
|
||||||
#[allow(dead_code)]
|
|
||||||
struct MembershipInfo {
|
struct MembershipInfo {
|
||||||
user_id: UserId,
|
user_id: UserId,
|
||||||
group_id: GroupId,
|
group_id: GroupId,
|
||||||
cnt: i64,
|
|
||||||
}
|
}
|
||||||
let mut delete_queries = MembershipInfo::find_by_statement(
|
for MembershipInfo { user_id, group_id } in MembershipInfo::find_by_statement(
|
||||||
builder.build(
|
builder.build(
|
||||||
Query::select()
|
Query::select()
|
||||||
.from(Memberships::Table)
|
.from(Memberships::Table)
|
||||||
.columns([Memberships::UserId, Memberships::GroupId])
|
.columns([Memberships::UserId, Memberships::GroupId])
|
||||||
.expr_as(
|
|
||||||
Expr::count(Expr::col((Memberships::Table, Memberships::UserId))),
|
|
||||||
Alias::new("cnt"),
|
|
||||||
)
|
|
||||||
.group_by_columns([Memberships::UserId, Memberships::GroupId])
|
.group_by_columns([Memberships::UserId, Memberships::GroupId])
|
||||||
.cond_having(all![SimpleExpr::Binary(
|
.cond_having(all![SimpleExpr::Binary(
|
||||||
Box::new(Expr::col((Memberships::Table, Memberships::UserId)).count()),
|
Box::new(Expr::col((Memberships::Table, Memberships::UserId)).count()),
|
||||||
@@ -996,38 +990,29 @@ async fn migrate_to_v8(transaction: DatabaseTransaction) -> Result<DatabaseTrans
|
|||||||
.all(&transaction)
|
.all(&transaction)
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(
|
{
|
||||||
|MembershipInfo {
|
transaction
|
||||||
user_id,
|
.execute(
|
||||||
group_id,
|
builder.build(
|
||||||
cnt,
|
|
||||||
}| {
|
|
||||||
builder
|
|
||||||
.build(
|
|
||||||
Query::delete()
|
Query::delete()
|
||||||
.from_table(Memberships::Table)
|
.from_table(Memberships::Table)
|
||||||
.cond_where(all![
|
.cond_where(all![
|
||||||
Expr::col(Memberships::UserId).eq(user_id),
|
Expr::col(Memberships::UserId).eq(&user_id),
|
||||||
Expr::col(Memberships::GroupId).eq(group_id)
|
Expr::col(Memberships::GroupId).eq(group_id)
|
||||||
])
|
]),
|
||||||
.limit(cnt as u64 - 1),
|
),
|
||||||
)
|
)
|
||||||
.to_owned()
|
.await?;
|
||||||
},
|
transaction
|
||||||
)
|
.execute(
|
||||||
.peekable();
|
builder.build(
|
||||||
if delete_queries.peek().is_some() {
|
Query::insert()
|
||||||
match transaction.get_database_backend() {
|
.into_table(Memberships::Table)
|
||||||
sea_orm::DatabaseBackend::Sqlite => {
|
.columns([Memberships::UserId, Memberships::GroupId])
|
||||||
return Err(DbErr::Migration(format!(
|
.values_panic([user_id.into(), group_id.into()]),
|
||||||
"The Sqlite driver does not support LIMIT in DELETE. Run these queries manually:\n{}" , delete_queries.map(|s| s.to_string()).join("\n"))));
|
),
|
||||||
}
|
)
|
||||||
sea_orm::DatabaseBackend::MySql | sea_orm::DatabaseBackend::Postgres => {
|
.await?;
|
||||||
for query in delete_queries {
|
|
||||||
transaction.execute(query).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
transaction
|
transaction
|
||||||
.execute(
|
.execute(
|
||||||
@@ -1090,6 +1075,47 @@ async fn migrate_to_v9(transaction: DatabaseTransaction) -> Result<DatabaseTrans
|
|||||||
Ok(transaction)
|
Ok(transaction)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn migrate_to_v10(transaction: DatabaseTransaction) -> Result<DatabaseTransaction, DbErr> {
|
||||||
|
let builder = transaction.get_database_backend();
|
||||||
|
if let Err(e) = transaction
|
||||||
|
.execute(
|
||||||
|
builder.build(
|
||||||
|
Index::create()
|
||||||
|
.if_not_exists()
|
||||||
|
.name("unique-group-id")
|
||||||
|
.table(Groups::Table)
|
||||||
|
.col(Groups::LowercaseDisplayName)
|
||||||
|
.unique(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
error!(
|
||||||
|
r#"Found several groups with the same (case-insensitive) display name. Please delete the duplicates"#
|
||||||
|
);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
if let Err(e) = transaction
|
||||||
|
.execute(
|
||||||
|
builder.build(
|
||||||
|
Index::create()
|
||||||
|
.if_not_exists()
|
||||||
|
.name("unique-user-lower-email")
|
||||||
|
.table(Users::Table)
|
||||||
|
.col(Users::LowercaseEmail)
|
||||||
|
.unique(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
error!(
|
||||||
|
r#"Found several users with the same (case-insensitive) email. Please delete the duplicates"#
|
||||||
|
);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
Ok(transaction)
|
||||||
|
}
|
||||||
|
|
||||||
// This is needed to make an array of async functions.
|
// This is needed to make an array of async functions.
|
||||||
macro_rules! to_sync {
|
macro_rules! to_sync {
|
||||||
($l:ident) => {
|
($l:ident) => {
|
||||||
@@ -1119,6 +1145,7 @@ pub async fn migrate_from_version(
|
|||||||
to_sync!(migrate_to_v7),
|
to_sync!(migrate_to_v7),
|
||||||
to_sync!(migrate_to_v8),
|
to_sync!(migrate_to_v8),
|
||||||
to_sync!(migrate_to_v9),
|
to_sync!(migrate_to_v9),
|
||||||
|
to_sync!(migrate_to_v10),
|
||||||
];
|
];
|
||||||
assert_eq!(migrations.len(), (LAST_SCHEMA_VERSION.0 - 1) as usize);
|
assert_eq!(migrations.len(), (LAST_SCHEMA_VERSION.0 - 1) as usize);
|
||||||
for migration in 2..=last_version.0 {
|
for migration in 2..=last_version.0 {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use base64::Engine;
|
|||||||
use lldap_auth::opaque;
|
use lldap_auth::opaque;
|
||||||
use sea_orm::{ActiveModelTrait, ActiveValue, EntityTrait, QuerySelect};
|
use sea_orm::{ActiveModelTrait, ActiveValue, EntityTrait, QuerySelect};
|
||||||
use secstr::SecUtf8;
|
use secstr::SecUtf8;
|
||||||
use tracing::{debug, instrument};
|
use tracing::{debug, info, instrument, warn};
|
||||||
|
|
||||||
type SqlOpaqueHandler = SqlBackendHandler;
|
type SqlOpaqueHandler = SqlBackendHandler;
|
||||||
|
|
||||||
@@ -70,14 +70,15 @@ impl LoginHandler for SqlBackendHandler {
|
|||||||
.get_password_file_for_user(request.name.clone())
|
.get_password_file_for_user(request.name.clone())
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
if let Err(e) = passwords_match(
|
info!(r#"Login attempt for "{}""#, &request.name);
|
||||||
|
if passwords_match(
|
||||||
&password_hash,
|
&password_hash,
|
||||||
&request.password,
|
&request.password,
|
||||||
self.config.get_server_setup(),
|
self.config.get_server_setup(),
|
||||||
&request.name,
|
&request.name,
|
||||||
) {
|
)
|
||||||
debug!(r#"Invalid password for "{}": {}"#, &request.name, e);
|
.is_ok()
|
||||||
} else {
|
{
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -87,7 +88,7 @@ impl LoginHandler for SqlBackendHandler {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
Err(DomainError::AuthenticationError(format!(
|
Err(DomainError::AuthenticationError(format!(
|
||||||
" for user '{}'",
|
r#"for user "{}""#,
|
||||||
request.name
|
request.name
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
@@ -101,6 +102,7 @@ impl OpaqueHandler for SqlOpaqueHandler {
|
|||||||
request: login::ClientLoginStartRequest,
|
request: login::ClientLoginStartRequest,
|
||||||
) -> Result<login::ServerLoginStartResponse> {
|
) -> Result<login::ServerLoginStartResponse> {
|
||||||
let user_id = request.username;
|
let user_id = request.username;
|
||||||
|
info!(r#"OPAQUE login attempt for "{}""#, &user_id);
|
||||||
let maybe_password_file = self
|
let maybe_password_file = self
|
||||||
.get_password_file_for_user(user_id.clone())
|
.get_password_file_for_user(user_id.clone())
|
||||||
.await?
|
.await?
|
||||||
@@ -145,9 +147,16 @@ impl OpaqueHandler for SqlOpaqueHandler {
|
|||||||
)?)?;
|
)?)?;
|
||||||
// Finish the login: this makes sure the client data is correct, and gives a session key we
|
// Finish the login: this makes sure the client data is correct, and gives a session key we
|
||||||
// don't need.
|
// don't need.
|
||||||
let _session_key =
|
match opaque::server::login::finish_login(server_login, request.credential_finalization) {
|
||||||
opaque::server::login::finish_login(server_login, request.credential_finalization)?
|
Ok(session) => {
|
||||||
.session_key;
|
info!(r#"OPAQUE login successful for "{}""#, &username);
|
||||||
|
let _ = session.session_key;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(r#"OPAQUE login attempt failed for "{}""#, &username);
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
Ok(username)
|
Ok(username)
|
||||||
}
|
}
|
||||||
@@ -189,11 +198,12 @@ impl OpaqueHandler for SqlOpaqueHandler {
|
|||||||
opaque::server::registration::get_password_file(request.registration_upload);
|
opaque::server::registration::get_password_file(request.registration_upload);
|
||||||
// Set the user password to the new password.
|
// Set the user password to the new password.
|
||||||
let user_update = model::users::ActiveModel {
|
let user_update = model::users::ActiveModel {
|
||||||
user_id: ActiveValue::Set(username),
|
user_id: ActiveValue::Set(username.clone()),
|
||||||
password_hash: ActiveValue::Set(Some(password_file.serialize())),
|
password_hash: ActiveValue::Set(Some(password_file.serialize())),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
user_update.update(&self.sql_pool).await?;
|
user_update.update(&self.sql_pool).await?;
|
||||||
|
info!(r#"Successfully (re)set password for "{}""#, &username);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,7 +175,9 @@ impl SqlBackendHandler {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::domain::{
|
use crate::domain::{
|
||||||
handler::AttributeList, sql_backend_handler::tests::*, types::AttributeType,
|
handler::{AttributeList, UpdateUserRequest, UserBackendHandler, UserRequestFilter},
|
||||||
|
sql_backend_handler::tests::*,
|
||||||
|
types::{AttributeType, AttributeValue, Serialized},
|
||||||
};
|
};
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
|
|
||||||
@@ -194,6 +196,7 @@ mod tests {
|
|||||||
is_visible: true,
|
is_visible: true,
|
||||||
is_editable: true,
|
is_editable: true,
|
||||||
is_hardcoded: true,
|
is_hardcoded: true,
|
||||||
|
is_readonly: false,
|
||||||
},
|
},
|
||||||
AttributeSchema {
|
AttributeSchema {
|
||||||
name: "first_name".into(),
|
name: "first_name".into(),
|
||||||
@@ -202,6 +205,7 @@ mod tests {
|
|||||||
is_visible: true,
|
is_visible: true,
|
||||||
is_editable: true,
|
is_editable: true,
|
||||||
is_hardcoded: true,
|
is_hardcoded: true,
|
||||||
|
is_readonly: false,
|
||||||
},
|
},
|
||||||
AttributeSchema {
|
AttributeSchema {
|
||||||
name: "last_name".into(),
|
name: "last_name".into(),
|
||||||
@@ -210,6 +214,7 @@ mod tests {
|
|||||||
is_visible: true,
|
is_visible: true,
|
||||||
is_editable: true,
|
is_editable: true,
|
||||||
is_hardcoded: true,
|
is_hardcoded: true,
|
||||||
|
is_readonly: false,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -244,6 +249,7 @@ mod tests {
|
|||||||
is_visible: false,
|
is_visible: false,
|
||||||
is_editable: false,
|
is_editable: false,
|
||||||
is_hardcoded: false,
|
is_hardcoded: false,
|
||||||
|
is_readonly: false,
|
||||||
};
|
};
|
||||||
assert!(fixture
|
assert!(fixture
|
||||||
.handler
|
.handler
|
||||||
@@ -268,6 +274,43 @@ mod tests {
|
|||||||
.contains(&expected_value));
|
.contains(&expected_value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_user_attribute_present_filter() {
|
||||||
|
let fixture = TestFixture::new().await;
|
||||||
|
let new_attribute = CreateAttributeRequest {
|
||||||
|
name: "new_attribute".into(),
|
||||||
|
attribute_type: AttributeType::Integer,
|
||||||
|
is_list: true,
|
||||||
|
is_visible: false,
|
||||||
|
is_editable: false,
|
||||||
|
};
|
||||||
|
fixture
|
||||||
|
.handler
|
||||||
|
.add_user_attribute(new_attribute)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
fixture
|
||||||
|
.handler
|
||||||
|
.update_user(UpdateUserRequest {
|
||||||
|
user_id: "bob".into(),
|
||||||
|
insert_attributes: vec![AttributeValue {
|
||||||
|
name: "new_attribute".into(),
|
||||||
|
value: Serialized::from(&3),
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let users = get_user_names(
|
||||||
|
&fixture.handler,
|
||||||
|
Some(UserRequestFilter::CustomAttributePresent(
|
||||||
|
"new_attribute".into(),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(users, vec!["bob"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_group_attribute_add_and_delete() {
|
async fn test_group_attribute_add_and_delete() {
|
||||||
let fixture = TestFixture::new().await;
|
let fixture = TestFixture::new().await;
|
||||||
@@ -290,6 +333,7 @@ mod tests {
|
|||||||
is_visible: true,
|
is_visible: true,
|
||||||
is_editable: false,
|
is_editable: false,
|
||||||
is_hardcoded: false,
|
is_hardcoded: false,
|
||||||
|
is_readonly: false,
|
||||||
};
|
};
|
||||||
assert!(fixture
|
assert!(fixture
|
||||||
.handler
|
.handler
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ pub type DbConnection = sea_orm::DatabaseConnection;
|
|||||||
#[derive(Copy, PartialEq, Eq, Debug, Clone, PartialOrd, Ord, DeriveValueType)]
|
#[derive(Copy, PartialEq, Eq, Debug, Clone, PartialOrd, Ord, DeriveValueType)]
|
||||||
pub struct SchemaVersion(pub i16);
|
pub struct SchemaVersion(pub i16);
|
||||||
|
|
||||||
pub const LAST_SCHEMA_VERSION: SchemaVersion = SchemaVersion(9);
|
pub const LAST_SCHEMA_VERSION: SchemaVersion = SchemaVersion(10);
|
||||||
|
|
||||||
#[derive(Copy, PartialEq, Eq, Debug, Clone, PartialOrd, Ord)]
|
#[derive(Copy, PartialEq, Eq, Debug, Clone, PartialOrd, Ord)]
|
||||||
pub struct PrivateKeyHash(pub [u8; 32]);
|
pub struct PrivateKeyHash(pub [u8; 32]);
|
||||||
|
|||||||
@@ -22,14 +22,31 @@ use sea_orm::{
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use tracing::instrument;
|
use tracing::instrument;
|
||||||
|
|
||||||
fn attribute_condition(name: AttributeName, value: Serialized) -> Cond {
|
fn attribute_condition(name: AttributeName, value: Option<Serialized>) -> Cond {
|
||||||
Expr::in_subquery(
|
Expr::in_subquery(
|
||||||
Expr::col(UserColumn::UserId.as_column_ref()),
|
Expr::col(UserColumn::UserId.as_column_ref()),
|
||||||
model::UserAttributes::find()
|
model::UserAttributes::find()
|
||||||
.select_only()
|
.select_only()
|
||||||
.column(model::UserAttributesColumn::UserId)
|
.column(model::UserAttributesColumn::UserId)
|
||||||
.filter(model::UserAttributesColumn::AttributeName.eq(name))
|
.filter(model::UserAttributesColumn::AttributeName.eq(name))
|
||||||
.filter(model::UserAttributesColumn::Value.eq(value))
|
.filter(
|
||||||
|
value
|
||||||
|
.map(|value| model::UserAttributesColumn::Value.eq(value))
|
||||||
|
.unwrap_or_else(|| SimpleExpr::Constant(true.into())),
|
||||||
|
)
|
||||||
|
.into_query(),
|
||||||
|
)
|
||||||
|
.into_condition()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user_id_subcondition(filter: Cond) -> Cond {
|
||||||
|
Expr::in_subquery(
|
||||||
|
Expr::col(UserColumn::UserId.as_column_ref()),
|
||||||
|
model::User::find()
|
||||||
|
.find_also_linked(model::memberships::UserToGroup)
|
||||||
|
.select_only()
|
||||||
|
.column(UserColumn::UserId)
|
||||||
|
.filter(filter)
|
||||||
.into_query(),
|
.into_query(),
|
||||||
)
|
)
|
||||||
.into_condition()
|
.into_condition()
|
||||||
@@ -66,13 +83,17 @@ fn get_user_filter_expr(filter: UserRequestFilter) -> Cond {
|
|||||||
ColumnTrait::eq(&column, value).into_condition()
|
ColumnTrait::eq(&column, value).into_condition()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AttributeEquality(column, value) => attribute_condition(column, value),
|
AttributeEquality(column, value) => attribute_condition(column, Some(value)),
|
||||||
MemberOf(group) => Expr::col((group_table, GroupColumn::DisplayName))
|
MemberOf(group) => user_id_subcondition(
|
||||||
.eq(group)
|
Expr::col((group_table, GroupColumn::LowercaseDisplayName))
|
||||||
.into_condition(),
|
.eq(group.as_str().to_lowercase())
|
||||||
MemberOfId(group_id) => Expr::col((group_table, GroupColumn::GroupId))
|
.into_condition(),
|
||||||
.eq(group_id)
|
),
|
||||||
.into_condition(),
|
MemberOfId(group_id) => user_id_subcondition(
|
||||||
|
Expr::col((group_table, GroupColumn::GroupId))
|
||||||
|
.eq(group_id)
|
||||||
|
.into_condition(),
|
||||||
|
),
|
||||||
UserIdSubString(filter) => UserColumn::UserId
|
UserIdSubString(filter) => UserColumn::UserId
|
||||||
.like(filter.to_sql_filter())
|
.like(filter.to_sql_filter())
|
||||||
.into_condition(),
|
.into_condition(),
|
||||||
@@ -81,6 +102,7 @@ fn get_user_filter_expr(filter: UserRequestFilter) -> Cond {
|
|||||||
.like(filter.to_sql_filter())
|
.like(filter.to_sql_filter())
|
||||||
.into_condition()
|
.into_condition()
|
||||||
}
|
}
|
||||||
|
CustomAttributePresent(name) => attribute_condition(name, None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,18 +127,7 @@ impl UserListerBackendHandler for SqlBackendHandler {
|
|||||||
_get_groups: bool,
|
_get_groups: bool,
|
||||||
) -> Result<Vec<UserAndGroups>> {
|
) -> Result<Vec<UserAndGroups>> {
|
||||||
let filters = filters
|
let filters = filters
|
||||||
.map(|f| {
|
.map(get_user_filter_expr)
|
||||||
UserColumn::UserId
|
|
||||||
.in_subquery(
|
|
||||||
model::User::find()
|
|
||||||
.find_also_linked(model::memberships::UserToGroup)
|
|
||||||
.select_only()
|
|
||||||
.column(UserColumn::UserId)
|
|
||||||
.filter(get_user_filter_expr(f))
|
|
||||||
.into_query(),
|
|
||||||
)
|
|
||||||
.into_condition()
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| SimpleExpr::Value(true.into()).into_condition());
|
.unwrap_or_else(|| SimpleExpr::Value(true.into()).into_condition());
|
||||||
let mut users: Vec<_> = model::User::find()
|
let mut users: Vec<_> = model::User::find()
|
||||||
.filter(filters.clone())
|
.filter(filters.clone())
|
||||||
@@ -542,6 +553,12 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(users, vec!["bob", "patrick"]);
|
assert_eq!(users, vec!["bob", "patrick"]);
|
||||||
|
let users = get_user_names(
|
||||||
|
&fixture.handler,
|
||||||
|
Some(UserRequestFilter::MemberOf("best grOUp".into())),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(users, vec!["bob", "patrick"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -569,6 +586,34 @@ mod tests {
|
|||||||
assert_eq!(users, vec!["bob", "patrick"]);
|
assert_eq!(users, vec!["bob", "patrick"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_list_users_filter_several_member_of() {
|
||||||
|
let fixture = TestFixture::new().await;
|
||||||
|
let users = get_user_names(
|
||||||
|
&fixture.handler,
|
||||||
|
Some(UserRequestFilter::And(vec![
|
||||||
|
UserRequestFilter::MemberOf("Best Group".into()),
|
||||||
|
UserRequestFilter::MemberOf("Worst Group".into()),
|
||||||
|
])),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(users, vec!["patrick"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_list_users_filter_several_member_of_id() {
|
||||||
|
let fixture = TestFixture::new().await;
|
||||||
|
let users = get_user_names(
|
||||||
|
&fixture.handler,
|
||||||
|
Some(UserRequestFilter::And(vec![
|
||||||
|
UserRequestFilter::MemberOfId(fixture.groups[0]),
|
||||||
|
UserRequestFilter::MemberOfId(fixture.groups[1]),
|
||||||
|
])),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(users, vec!["patrick"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[should_panic]
|
#[should_panic]
|
||||||
async fn test_list_users_invalid_userid_filter() {
|
async fn test_list_users_invalid_userid_filter() {
|
||||||
@@ -1121,4 +1166,29 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect_err("Should have failed");
|
.expect_err("Should have failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_create_user_duplicate_email() {
|
||||||
|
let fixture = TestFixture::new().await;
|
||||||
|
|
||||||
|
fixture
|
||||||
|
.handler
|
||||||
|
.create_user(CreateUserRequest {
|
||||||
|
user_id: UserId::new("james"),
|
||||||
|
email: "email".into(),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
fixture
|
||||||
|
.handler
|
||||||
|
.create_user(CreateUserRequest {
|
||||||
|
user_id: UserId::new("john"),
|
||||||
|
email: "eMail".into(),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,22 @@ use strum::{EnumString, IntoStaticStr};
|
|||||||
pub use super::model::UserColumn;
|
pub use super::model::UserColumn;
|
||||||
pub use lldap_auth::types::UserId;
|
pub use lldap_auth::types::UserId;
|
||||||
|
|
||||||
#[derive(PartialEq, Hash, Eq, Clone, Debug, Default, Serialize, Deserialize, DeriveValueType)]
|
#[derive(
|
||||||
|
PartialEq,
|
||||||
|
Hash,
|
||||||
|
Eq,
|
||||||
|
Clone,
|
||||||
|
Default,
|
||||||
|
Serialize,
|
||||||
|
Deserialize,
|
||||||
|
DeriveValueType,
|
||||||
|
derive_more::Debug,
|
||||||
|
derive_more::Display,
|
||||||
|
)]
|
||||||
#[serde(try_from = "&str")]
|
#[serde(try_from = "&str")]
|
||||||
#[sea_orm(column_type = "String(Some(36))")]
|
#[sea_orm(column_type = "String(Some(36))")]
|
||||||
|
#[debug(r#""{_0}""#)]
|
||||||
|
#[display("{_0}")]
|
||||||
pub struct Uuid(String);
|
pub struct Uuid(String);
|
||||||
|
|
||||||
impl Uuid {
|
impl Uuid {
|
||||||
@@ -53,12 +66,6 @@ impl<'a> std::convert::TryFrom<&'a str> for Uuid {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::string::ToString for Uuid {
|
|
||||||
fn to_string(&self) -> String {
|
|
||||||
self.0.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! uuid {
|
macro_rules! uuid {
|
||||||
@@ -144,7 +151,17 @@ fn compare_str_case_insensitive(s1: &str, s2: &str) -> Ordering {
|
|||||||
|
|
||||||
macro_rules! make_case_insensitive_comparable_string {
|
macro_rules! make_case_insensitive_comparable_string {
|
||||||
($c:ident) => {
|
($c:ident) => {
|
||||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, DeriveValueType)]
|
#[derive(
|
||||||
|
Clone,
|
||||||
|
Default,
|
||||||
|
Serialize,
|
||||||
|
Deserialize,
|
||||||
|
DeriveValueType,
|
||||||
|
derive_more::Debug,
|
||||||
|
derive_more::Display,
|
||||||
|
)]
|
||||||
|
#[debug(r#""{_0}""#)]
|
||||||
|
#[display("{_0}")]
|
||||||
pub struct $c(String);
|
pub struct $c(String);
|
||||||
|
|
||||||
impl PartialEq for $c {
|
impl PartialEq for $c {
|
||||||
@@ -199,12 +216,6 @@ macro_rules! make_case_insensitive_comparable_string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for $c {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
|
||||||
write!(f, "{}", self.0.as_str())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&$c> for Value {
|
impl From<&$c> for Value {
|
||||||
fn from(user_id: &$c) -> Self {
|
fn from(user_id: &$c) -> Self {
|
||||||
user_id.as_str().into()
|
user_id.as_str().into()
|
||||||
@@ -429,7 +440,6 @@ impl Default for User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(
|
#[derive(
|
||||||
Debug,
|
|
||||||
Copy,
|
Copy,
|
||||||
Clone,
|
Clone,
|
||||||
PartialEq,
|
PartialEq,
|
||||||
@@ -440,7 +450,9 @@ impl Default for User {
|
|||||||
Serialize,
|
Serialize,
|
||||||
Deserialize,
|
Deserialize,
|
||||||
DeriveValueType,
|
DeriveValueType,
|
||||||
|
derive_more::Debug,
|
||||||
)]
|
)]
|
||||||
|
#[debug("{_0}")]
|
||||||
pub struct GroupId(pub i32);
|
pub struct GroupId(pub i32);
|
||||||
|
|
||||||
impl TryFromU64 for GroupId {
|
impl TryFromU64 for GroupId {
|
||||||
@@ -559,14 +571,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_serialized_i64_len() {
|
fn test_serialized_i64_len() {
|
||||||
assert_eq!(SERIALIZED_I64_LEN, Serialized::from(&0i64).0.len());
|
assert_eq!(SERIALIZED_I64_LEN, Serialized::from(&0i64).0.len());
|
||||||
assert_eq!(
|
assert_eq!(SERIALIZED_I64_LEN, Serialized::from(&i64::MAX).0.len());
|
||||||
SERIALIZED_I64_LEN,
|
assert_eq!(SERIALIZED_I64_LEN, Serialized::from(&i64::MIN).0.len());
|
||||||
Serialized::from(&i64::max_value()).0.len()
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
SERIALIZED_I64_LEN,
|
|
||||||
Serialized::from(&i64::min_value()).0.len()
|
|
||||||
);
|
|
||||||
assert_eq!(SERIALIZED_I64_LEN, Serialized::from(&-1000i64).0.len());
|
assert_eq!(SERIALIZED_I64_LEN, Serialized::from(&-1000i64).0.len());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -314,8 +314,8 @@ pub struct UserRestrictedListerBackendHandler<'a, Handler> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl<'a, Handler: ReadSchemaBackendHandler + Sync> ReadSchemaBackendHandler
|
impl<Handler: ReadSchemaBackendHandler + Sync> ReadSchemaBackendHandler
|
||||||
for UserRestrictedListerBackendHandler<'a, Handler>
|
for UserRestrictedListerBackendHandler<'_, Handler>
|
||||||
{
|
{
|
||||||
async fn get_schema(&self) -> Result<Schema> {
|
async fn get_schema(&self) -> Result<Schema> {
|
||||||
let mut schema = self.handler.get_schema().await?;
|
let mut schema = self.handler.get_schema().await?;
|
||||||
@@ -331,8 +331,8 @@ impl<'a, Handler: ReadSchemaBackendHandler + Sync> ReadSchemaBackendHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl<'a, Handler: UserListerBackendHandler + Sync> UserListerBackendHandler
|
impl<Handler: UserListerBackendHandler + Sync> UserListerBackendHandler
|
||||||
for UserRestrictedListerBackendHandler<'a, Handler>
|
for UserRestrictedListerBackendHandler<'_, Handler>
|
||||||
{
|
{
|
||||||
async fn list_users(
|
async fn list_users(
|
||||||
&self,
|
&self,
|
||||||
@@ -354,8 +354,8 @@ impl<'a, Handler: UserListerBackendHandler + Sync> UserListerBackendHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl<'a, Handler: GroupListerBackendHandler + Sync> GroupListerBackendHandler
|
impl<Handler: GroupListerBackendHandler + Sync> GroupListerBackendHandler
|
||||||
for UserRestrictedListerBackendHandler<'a, Handler>
|
for UserRestrictedListerBackendHandler<'_, Handler>
|
||||||
{
|
{
|
||||||
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>> {
|
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>> {
|
||||||
let group_filter = self
|
let group_filter = self
|
||||||
@@ -379,7 +379,7 @@ pub trait UserAndGroupListerBackendHandler:
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl<'a, Handler: GroupListerBackendHandler + UserListerBackendHandler + Sync>
|
impl<Handler: GroupListerBackendHandler + UserListerBackendHandler + Sync>
|
||||||
UserAndGroupListerBackendHandler for UserRestrictedListerBackendHandler<'a, Handler>
|
UserAndGroupListerBackendHandler for UserRestrictedListerBackendHandler<'_, Handler>
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -401,11 +401,14 @@ async fn opaque_login_finish<Backend>(
|
|||||||
where
|
where
|
||||||
Backend: TcpBackendHandler + BackendHandler + OpaqueHandler + 'static,
|
Backend: TcpBackendHandler + BackendHandler + OpaqueHandler + 'static,
|
||||||
{
|
{
|
||||||
let name = data
|
match data
|
||||||
.get_opaque_handler()
|
.get_opaque_handler()
|
||||||
.login_finish(request.into_inner())
|
.login_finish(request.into_inner())
|
||||||
.await?;
|
.await
|
||||||
get_login_successful_response(&data, &name).await
|
{
|
||||||
|
Ok(name) => get_login_successful_response(&data, &name).await,
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn opaque_login_finish_handler<Backend>(
|
async fn opaque_login_finish_handler<Backend>(
|
||||||
@@ -449,31 +452,6 @@ where
|
|||||||
.unwrap_or_else(error_to_http_response)
|
.unwrap_or_else(error_to_http_response)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip_all, level = "debug", fields(name = %request.name))]
|
|
||||||
async fn post_authorize<Backend>(
|
|
||||||
data: web::Data<AppState<Backend>>,
|
|
||||||
request: web::Json<BindRequest>,
|
|
||||||
) -> TcpResult<HttpResponse>
|
|
||||||
where
|
|
||||||
Backend: TcpBackendHandler + BackendHandler + LoginHandler + 'static,
|
|
||||||
{
|
|
||||||
let name = request.name.clone();
|
|
||||||
data.get_login_handler().bind(request.into_inner()).await?;
|
|
||||||
get_login_successful_response(&data, &name).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn post_authorize_handler<Backend>(
|
|
||||||
data: web::Data<AppState<Backend>>,
|
|
||||||
request: web::Json<BindRequest>,
|
|
||||||
) -> HttpResponse
|
|
||||||
where
|
|
||||||
Backend: TcpBackendHandler + BackendHandler + LoginHandler + 'static,
|
|
||||||
{
|
|
||||||
post_authorize(data, request)
|
|
||||||
.await
|
|
||||||
.unwrap_or_else(error_to_http_response)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip_all, level = "debug")]
|
#[instrument(skip_all, level = "debug")]
|
||||||
async fn opaque_register_start<Backend>(
|
async fn opaque_register_start<Backend>(
|
||||||
request: actix_web::HttpRequest,
|
request: actix_web::HttpRequest,
|
||||||
@@ -648,32 +626,28 @@ pub fn configure_server<Backend>(cfg: &mut web::ServiceConfig, enable_password_r
|
|||||||
where
|
where
|
||||||
Backend: TcpBackendHandler + LoginHandler + OpaqueHandler + BackendHandler + 'static,
|
Backend: TcpBackendHandler + LoginHandler + OpaqueHandler + BackendHandler + 'static,
|
||||||
{
|
{
|
||||||
cfg.service(web::resource("").route(web::post().to(post_authorize_handler::<Backend>)))
|
cfg.service(
|
||||||
.service(
|
web::resource("/opaque/login/start").route(web::post().to(opaque_login_start::<Backend>)),
|
||||||
web::resource("/opaque/login/start")
|
)
|
||||||
.route(web::post().to(opaque_login_start::<Backend>)),
|
.service(
|
||||||
)
|
web::resource("/opaque/login/finish")
|
||||||
.service(
|
.route(web::post().to(opaque_login_finish_handler::<Backend>)),
|
||||||
web::resource("/opaque/login/finish")
|
)
|
||||||
.route(web::post().to(opaque_login_finish_handler::<Backend>)),
|
.service(web::resource("/simple/login").route(web::post().to(simple_login_handler::<Backend>)))
|
||||||
)
|
.service(web::resource("/refresh").route(web::get().to(get_refresh_handler::<Backend>)))
|
||||||
.service(
|
.service(web::resource("/logout").route(web::get().to(get_logout_handler::<Backend>)))
|
||||||
web::resource("/simple/login").route(web::post().to(simple_login_handler::<Backend>)),
|
.service(
|
||||||
)
|
web::scope("/opaque/register")
|
||||||
.service(web::resource("/refresh").route(web::get().to(get_refresh_handler::<Backend>)))
|
.wrap(CookieToHeaderTranslatorFactory)
|
||||||
.service(web::resource("/logout").route(web::get().to(get_logout_handler::<Backend>)))
|
.service(
|
||||||
.service(
|
web::resource("/start")
|
||||||
web::scope("/opaque/register")
|
.route(web::post().to(opaque_register_start_handler::<Backend>)),
|
||||||
.wrap(CookieToHeaderTranslatorFactory)
|
)
|
||||||
.service(
|
.service(
|
||||||
web::resource("/start")
|
web::resource("/finish")
|
||||||
.route(web::post().to(opaque_register_start_handler::<Backend>)),
|
.route(web::post().to(opaque_register_finish_handler::<Backend>)),
|
||||||
)
|
),
|
||||||
.service(
|
);
|
||||||
web::resource("/finish")
|
|
||||||
.route(web::post().to(opaque_register_finish_handler::<Backend>)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if enable_password_reset {
|
if enable_password_reset {
|
||||||
cfg.service(
|
cfg.service(
|
||||||
web::resource("/reset/step1/{user_id}")
|
web::resource("/reset/step1/{user_id}")
|
||||||
@@ -683,5 +657,9 @@ where
|
|||||||
web::resource("/reset/step2/{token}")
|
web::resource("/reset/step2/{token}")
|
||||||
.route(web::get().to(get_password_reset_step2_handler::<Backend>)),
|
.route(web::get().to(get_password_reset_step2_handler::<Backend>)),
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
cfg.service(
|
||||||
|
web::resource("/reset/step1/{user_id}").route(web::post().to(HttpResponse::NotFound)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,78 @@
|
|||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
use clap::{builder::EnumValueParser, Parser};
|
use clap::{builder::EnumValueParser, Parser};
|
||||||
use lettre::message::Mailbox;
|
use lettre::message::Mailbox;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use strum::{EnumString, IntoStaticStr};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use crate::infra::database_string::DatabaseUrl;
|
use crate::infra::database_string::DatabaseUrl;
|
||||||
|
|
||||||
|
// Can be deserialized from either a boolean or a string, to facilitate migration.
|
||||||
|
#[derive(Copy, Clone, Debug, Serialize, Default, EnumString, IntoStaticStr)]
|
||||||
|
#[strum(ascii_case_insensitive)]
|
||||||
|
pub enum TrueFalseAlways {
|
||||||
|
#[default]
|
||||||
|
False,
|
||||||
|
True,
|
||||||
|
Always,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrueFalseAlways {
|
||||||
|
pub fn is_positive(&self) -> bool {
|
||||||
|
matches!(self, TrueFalseAlways::True | TrueFalseAlways::Always)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_yes(&self) -> bool {
|
||||||
|
matches!(self, TrueFalseAlways::True)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for TrueFalseAlways {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
use serde::de::{self};
|
||||||
|
|
||||||
|
struct Visitor;
|
||||||
|
|
||||||
|
impl serde::de::Visitor<'_> for Visitor {
|
||||||
|
type Value = TrueFalseAlways;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||||
|
formatter.write_str("true, false or always")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E>
|
||||||
|
where
|
||||||
|
E: de::Error,
|
||||||
|
{
|
||||||
|
if value {
|
||||||
|
Ok(TrueFalseAlways::True)
|
||||||
|
} else {
|
||||||
|
Ok(TrueFalseAlways::False)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
|
||||||
|
where
|
||||||
|
E: de::Error,
|
||||||
|
{
|
||||||
|
match TrueFalseAlways::from_str(value) {
|
||||||
|
Ok(v) => Ok(v),
|
||||||
|
Err(_) => Err(de::Error::unknown_variant(
|
||||||
|
value,
|
||||||
|
&["true", "false", "always"],
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deserializer.deserialize_any(Visitor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// lldap is a lightweight LDAP server
|
/// lldap is a lightweight LDAP server
|
||||||
#[derive(Debug, Parser, Clone)]
|
#[derive(Debug, Parser, Clone)]
|
||||||
#[clap(version, author)]
|
#[clap(version, author)]
|
||||||
@@ -92,8 +160,10 @@ pub struct RunOpts {
|
|||||||
pub database_url: Option<DatabaseUrl>,
|
pub database_url: Option<DatabaseUrl>,
|
||||||
|
|
||||||
/// Force admin password reset to the config value.
|
/// Force admin password reset to the config value.
|
||||||
#[clap(long, env = "LLDAP_FORCE_LADP_USER_PASS_RESET")]
|
/// If set to true, it will be a one-time reset that doesn't start the server.
|
||||||
pub force_ldap_user_pass_reset: Option<bool>,
|
/// You can set it to "always" to force a reset every time the server starts.
|
||||||
|
#[clap(long, env = "LLDAP_FORCE_LDAP_USER_PASS_RESET")]
|
||||||
|
pub force_ldap_user_pass_reset: Option<TrueFalseAlways>,
|
||||||
|
|
||||||
/// Force update of the private key after a key change.
|
/// Force update of the private key after a key change.
|
||||||
#[clap(long, env = "LLDAP_FORCE_UPDATE_PRIVATE_KEY")]
|
#[clap(long, env = "LLDAP_FORCE_UPDATE_PRIVATE_KEY")]
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
domain::{
|
domain::{
|
||||||
sql_tables::{ConfigLocation, PrivateKeyHash, PrivateKeyInfo, PrivateKeyLocation},
|
sql_tables::{ConfigLocation, PrivateKeyHash, PrivateKeyInfo, PrivateKeyLocation},
|
||||||
types::{AttributeName, UserId},
|
types::{AttributeName, UserId},
|
||||||
},
|
},
|
||||||
infra::{
|
infra::{
|
||||||
cli::{GeneralConfigOpts, LdapsOpts, RunOpts, SmtpEncryption, SmtpOpts, TestEmailOpts},
|
cli::{
|
||||||
|
GeneralConfigOpts, LdapsOpts, RunOpts, SmtpEncryption, SmtpOpts, TestEmailOpts,
|
||||||
|
TrueFalseAlways,
|
||||||
|
},
|
||||||
database_string::DatabaseUrl,
|
database_string::DatabaseUrl,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -14,13 +19,19 @@ use figment::{
|
|||||||
Figment,
|
Figment,
|
||||||
};
|
};
|
||||||
use figment_file_provider_adapter::FileAdapter;
|
use figment_file_provider_adapter::FileAdapter;
|
||||||
use lettre::message::Mailbox;
|
|
||||||
use lldap_auth::opaque::{server::ServerSetup, KeyPair};
|
use lldap_auth::opaque::{server::ServerSetup, KeyPair};
|
||||||
use secstr::SecUtf8;
|
use secstr::SecUtf8;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize, derive_builder::Builder)]
|
#[derive(
|
||||||
|
Clone, Deserialize, Serialize, derive_more::FromStr, derive_more::Debug, derive_more::Display,
|
||||||
|
)]
|
||||||
|
#[debug(r#""{_0}""#)]
|
||||||
|
#[display("{_0}")]
|
||||||
|
pub struct Mailbox(pub lettre::message::Mailbox);
|
||||||
|
|
||||||
|
#[derive(Clone, derive_more::Debug, Deserialize, Serialize, derive_builder::Builder)]
|
||||||
#[builder(pattern = "owned")]
|
#[builder(pattern = "owned")]
|
||||||
pub struct MailOptions {
|
pub struct MailOptions {
|
||||||
#[builder(default = "false")]
|
#[builder(default = "false")]
|
||||||
@@ -40,6 +51,8 @@ pub struct MailOptions {
|
|||||||
#[builder(default = "SmtpEncryption::Tls")]
|
#[builder(default = "SmtpEncryption::Tls")]
|
||||||
pub smtp_encryption: SmtpEncryption,
|
pub smtp_encryption: SmtpEncryption,
|
||||||
/// Deprecated.
|
/// Deprecated.
|
||||||
|
#[debug(skip)]
|
||||||
|
#[serde(skip)]
|
||||||
#[builder(default = "None")]
|
#[builder(default = "None")]
|
||||||
pub tls_required: Option<bool>,
|
pub tls_required: Option<bool>,
|
||||||
}
|
}
|
||||||
@@ -69,7 +82,11 @@ impl std::default::Default for LdapsOptions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize, derive_builder::Builder)]
|
#[derive(Clone, Deserialize, Serialize, derive_more::Debug)]
|
||||||
|
#[debug(r#""{_0}""#)]
|
||||||
|
pub struct HttpUrl(pub Url);
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize, Serialize, derive_builder::Builder, derive_more::Debug)]
|
||||||
#[builder(pattern = "owned", build_fn(name = "private_build"))]
|
#[builder(pattern = "owned", build_fn(name = "private_build"))]
|
||||||
pub struct Configuration {
|
pub struct Configuration {
|
||||||
#[builder(default = r#"String::from("0.0.0.0")"#)]
|
#[builder(default = r#"String::from("0.0.0.0")"#)]
|
||||||
@@ -80,18 +97,18 @@ pub struct Configuration {
|
|||||||
pub http_host: String,
|
pub http_host: String,
|
||||||
#[builder(default = "17170")]
|
#[builder(default = "17170")]
|
||||||
pub http_port: u16,
|
pub http_port: u16,
|
||||||
#[builder(default = r#"SecUtf8::from("secretjwtsecret")"#)]
|
#[builder(default)]
|
||||||
pub jwt_secret: SecUtf8,
|
pub jwt_secret: Option<SecUtf8>,
|
||||||
#[builder(default = r#"String::from("dc=example,dc=com")"#)]
|
#[builder(default = r#"String::from("dc=example,dc=com")"#)]
|
||||||
pub ldap_base_dn: String,
|
pub ldap_base_dn: String,
|
||||||
#[builder(default = r#"UserId::new("admin")"#)]
|
#[builder(default = r#"UserId::new("admin")"#)]
|
||||||
pub ldap_user_dn: UserId,
|
pub ldap_user_dn: UserId,
|
||||||
#[builder(default)]
|
#[builder(default)]
|
||||||
pub ldap_user_email: String,
|
pub ldap_user_email: String,
|
||||||
#[builder(default = r#"SecUtf8::from("password")"#)]
|
#[builder(default)]
|
||||||
pub ldap_user_pass: SecUtf8,
|
pub ldap_user_pass: Option<SecUtf8>,
|
||||||
#[builder(default = "false")]
|
#[builder(default)]
|
||||||
pub force_ldap_user_pass_reset: bool,
|
pub force_ldap_user_pass_reset: TrueFalseAlways,
|
||||||
#[builder(default = "false")]
|
#[builder(default = "false")]
|
||||||
pub force_update_private_key: bool,
|
pub force_update_private_key: bool,
|
||||||
#[builder(default = r#"DatabaseUrl::from("sqlite://users.db?mode=rwc")"#)]
|
#[builder(default = r#"DatabaseUrl::from("sqlite://users.db?mode=rwc")"#)]
|
||||||
@@ -112,8 +129,9 @@ pub struct Configuration {
|
|||||||
pub smtp_options: MailOptions,
|
pub smtp_options: MailOptions,
|
||||||
#[builder(default)]
|
#[builder(default)]
|
||||||
pub ldaps_options: LdapsOptions,
|
pub ldaps_options: LdapsOptions,
|
||||||
#[builder(default = r#"Url::parse("http://localhost").unwrap()"#)]
|
#[builder(default = r#"HttpUrl(Url::parse("http://localhost").unwrap())"#)]
|
||||||
pub http_url: Url,
|
pub http_url: HttpUrl,
|
||||||
|
#[debug(skip)]
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
#[builder(field(private), default = "None")]
|
#[builder(field(private), default = "None")]
|
||||||
server_setup: Option<ServerSetupConfig>,
|
server_setup: Option<ServerSetupConfig>,
|
||||||
@@ -229,16 +247,22 @@ fn generate_random_private_key() -> ServerSetup {
|
|||||||
ServerSetup::new(&mut rng)
|
ServerSetup::new(&mut rng)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn set_mode(permissions: &mut std::fs::Permissions) {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
permissions.set_mode(0o400);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn set_mode(_: &mut std::fs::Permissions) {}
|
||||||
|
|
||||||
fn write_to_readonly_file(path: &std::path::Path, buffer: &[u8]) -> Result<()> {
|
fn write_to_readonly_file(path: &std::path::Path, buffer: &[u8]) -> Result<()> {
|
||||||
use std::{fs::File, io::Write};
|
use std::{fs::File, io::Write};
|
||||||
assert!(!path.exists());
|
assert!(!path.exists());
|
||||||
let mut file = File::create(path)?;
|
let mut file = File::create(path)?;
|
||||||
let mut permissions = file.metadata()?.permissions();
|
let mut permissions = file.metadata()?.permissions();
|
||||||
permissions.set_readonly(true);
|
permissions.set_readonly(true);
|
||||||
if cfg!(unix) {
|
set_mode(&mut permissions);
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
permissions.set_mode(0o400);
|
|
||||||
}
|
|
||||||
file.set_permissions(permissions)?;
|
file.set_permissions(permissions)?;
|
||||||
Ok(file.write_all(buffer)?)
|
Ok(file.write_all(buffer)?)
|
||||||
}
|
}
|
||||||
@@ -337,9 +361,9 @@ fn get_server_setup<L: Into<PrivateKeyLocationOrFigment>>(
|
|||||||
file_path
|
file_path
|
||||||
);
|
);
|
||||||
} else if file_path == "server_key" {
|
} else if file_path == "server_key" {
|
||||||
eprintln!("WARNING: A key_seed was given, we will ignore the server_key and generate one from the seed!");
|
eprintln!("WARNING: A key_seed was given, we will ignore the key_file and generate one from the seed! Set key_file to an empty string in the config to silence this message.");
|
||||||
} else {
|
} else {
|
||||||
println!("Generating the key from the key_seed");
|
println!("Generating the private key from the key_seed");
|
||||||
}
|
}
|
||||||
use rand::SeedableRng;
|
use rand::SeedableRng;
|
||||||
let mut rng = rand_chacha::ChaCha20Rng::from_seed(stable_hash(key_seed.as_bytes()));
|
let mut rng = rand_chacha::ChaCha20Rng::from_seed(stable_hash(key_seed.as_bytes()));
|
||||||
@@ -410,7 +434,7 @@ impl ConfigOverrider for RunOpts {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(url) = self.http_url.as_ref() {
|
if let Some(url) = self.http_url.as_ref() {
|
||||||
config.http_url = url.clone();
|
config.http_url = HttpUrl(url.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(database_url) = self.database_url.as_ref() {
|
if let Some(database_url) = self.database_url.as_ref() {
|
||||||
@@ -445,10 +469,10 @@ impl ConfigOverrider for LdapsOpts {
|
|||||||
config.ldaps_options.port = port;
|
config.ldaps_options.port = port;
|
||||||
}
|
}
|
||||||
if let Some(path) = self.ldaps_cert_file.as_ref() {
|
if let Some(path) = self.ldaps_cert_file.as_ref() {
|
||||||
config.ldaps_options.cert_file = path.clone();
|
config.ldaps_options.cert_file.clone_from(path);
|
||||||
}
|
}
|
||||||
if let Some(path) = self.ldaps_key_file.as_ref() {
|
if let Some(path) = self.ldaps_key_file.as_ref() {
|
||||||
config.ldaps_options.key_file = path.clone();
|
config.ldaps_options.key_file.clone_from(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -464,19 +488,19 @@ impl ConfigOverrider for GeneralConfigOpts {
|
|||||||
impl ConfigOverrider for SmtpOpts {
|
impl ConfigOverrider for SmtpOpts {
|
||||||
fn override_config(&self, config: &mut Configuration) {
|
fn override_config(&self, config: &mut Configuration) {
|
||||||
if let Some(from) = &self.smtp_from {
|
if let Some(from) = &self.smtp_from {
|
||||||
config.smtp_options.from = Some(from.clone());
|
config.smtp_options.from = Some(Mailbox(from.clone()));
|
||||||
}
|
}
|
||||||
if let Some(reply_to) = &self.smtp_reply_to {
|
if let Some(reply_to) = &self.smtp_reply_to {
|
||||||
config.smtp_options.reply_to = Some(reply_to.clone());
|
config.smtp_options.reply_to = Some(Mailbox(reply_to.clone()));
|
||||||
}
|
}
|
||||||
if let Some(server) = &self.smtp_server {
|
if let Some(server) = &self.smtp_server {
|
||||||
config.smtp_options.server = server.clone();
|
config.smtp_options.server.clone_from(server);
|
||||||
}
|
}
|
||||||
if let Some(port) = self.smtp_port {
|
if let Some(port) = self.smtp_port {
|
||||||
config.smtp_options.port = port;
|
config.smtp_options.port = port;
|
||||||
}
|
}
|
||||||
if let Some(user) = &self.smtp_user {
|
if let Some(user) = &self.smtp_user {
|
||||||
config.smtp_options.user = user.clone();
|
config.smtp_options.user.clone_from(user);
|
||||||
}
|
}
|
||||||
if let Some(password) = &self.smtp_password {
|
if let Some(password) = &self.smtp_password {
|
||||||
config.smtp_options.password = SecUtf8::from(password.clone());
|
config.smtp_options.password = SecUtf8::from(password.clone());
|
||||||
@@ -493,6 +517,46 @@ impl ConfigOverrider for SmtpOpts {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn extract_keys(dict: &figment::value::Dict) -> HashSet<String> {
|
||||||
|
use figment::value::{Dict, Value};
|
||||||
|
fn process_value(value: &Dict, keys: &mut HashSet<String>, path: &mut Vec<String>) {
|
||||||
|
for (key, value) in value {
|
||||||
|
match value {
|
||||||
|
Value::Dict(_, dict) => {
|
||||||
|
path.push(format!("{}__", key.to_ascii_uppercase()));
|
||||||
|
process_value(dict, keys, path);
|
||||||
|
path.pop();
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
keys.insert(format!(
|
||||||
|
"LLDAP_{}{}",
|
||||||
|
path.join(""),
|
||||||
|
key.to_ascii_uppercase()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut keys = HashSet::new();
|
||||||
|
let mut path = Vec::new();
|
||||||
|
process_value(dict, &mut keys, &mut path);
|
||||||
|
keys
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expected_keys(dict: &figment::value::Dict) -> HashSet<String> {
|
||||||
|
let mut keys = extract_keys(dict);
|
||||||
|
// CLI-only values.
|
||||||
|
keys.insert("LLDAP_CONFIG_FILE".to_string());
|
||||||
|
keys.insert("LLDAP_TEST_EMAIL_TO".to_string());
|
||||||
|
// Alternate spellings from clap.
|
||||||
|
keys.insert("LLDAP_SERVER_KEY_FILE".to_string());
|
||||||
|
keys.insert("LLDAP_SERVER_KEY_SEED".to_string());
|
||||||
|
keys.insert("LLDAP_SMTP_OPTIONS__TO".to_string());
|
||||||
|
// Deprecated
|
||||||
|
keys.insert("LLDAP_SMTP_OPTIONS__TLS_REQUIRED".to_string());
|
||||||
|
keys
|
||||||
|
}
|
||||||
|
|
||||||
pub fn init<C>(overrides: C) -> Result<Configuration>
|
pub fn init<C>(overrides: C) -> Result<Configuration>
|
||||||
where
|
where
|
||||||
C: TopLevelCommandOpts + ConfigOverrider,
|
C: TopLevelCommandOpts + ConfigOverrider,
|
||||||
@@ -503,19 +567,37 @@ where
|
|||||||
);
|
);
|
||||||
|
|
||||||
let ignore_keys = ["key_file", "cert_file"];
|
let ignore_keys = ["key_file", "cert_file"];
|
||||||
|
let env_variable_provider =
|
||||||
|
|| FileAdapter::wrap(Env::prefixed("LLDAP_").split("__")).ignore(&ignore_keys);
|
||||||
let figment_config = Figment::from(Serialized::defaults(
|
let figment_config = Figment::from(Serialized::defaults(
|
||||||
ConfigurationBuilder::default().private_build().unwrap(),
|
ConfigurationBuilder::default().private_build().unwrap(),
|
||||||
))
|
))
|
||||||
.merge(
|
.merge(
|
||||||
FileAdapter::wrap(Toml::file(&overrides.general_config().config_file)).ignore(&ignore_keys),
|
FileAdapter::wrap(Toml::file(&overrides.general_config().config_file)).ignore(&ignore_keys),
|
||||||
)
|
)
|
||||||
.merge(FileAdapter::wrap(Env::prefixed("LLDAP_").split("__")).ignore(&ignore_keys));
|
.merge(env_variable_provider());
|
||||||
let mut config: Configuration = figment_config.extract()?;
|
let mut config: Configuration = figment_config.extract()?;
|
||||||
|
|
||||||
overrides.override_config(&mut config);
|
overrides.override_config(&mut config);
|
||||||
if config.verbose {
|
if config.verbose {
|
||||||
println!("Configuration: {:#?}", &config);
|
println!("Configuration: {:#?}", &config);
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
use figment::{Profile, Provider};
|
||||||
|
let expected_keys = expected_keys(
|
||||||
|
&Figment::from(Serialized::defaults(
|
||||||
|
ConfigurationBuilder::default().private_build().unwrap(),
|
||||||
|
))
|
||||||
|
.data()
|
||||||
|
.unwrap()[&Profile::default()],
|
||||||
|
);
|
||||||
|
extract_keys(&env_variable_provider().data().unwrap()[&Profile::default()])
|
||||||
|
.iter()
|
||||||
|
.filter(|k| !expected_keys.contains(k.as_str()))
|
||||||
|
.for_each(|k| {
|
||||||
|
eprintln!("WARNING: Unknown environment variable: LLDAP_{}", k);
|
||||||
|
});
|
||||||
|
}
|
||||||
config.server_setup = Some(get_server_setup(
|
config.server_setup = Some(get_server_setup(
|
||||||
&config.key_file,
|
&config.key_file,
|
||||||
config
|
config
|
||||||
@@ -525,11 +607,24 @@ where
|
|||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
figment_config,
|
figment_config,
|
||||||
)?);
|
)?);
|
||||||
if config.jwt_secret == SecUtf8::from("secretjwtsecret") {
|
if config.jwt_secret.is_none() {
|
||||||
println!("WARNING: Default JWT secret used! This is highly unsafe and can allow attackers to log in as admin.");
|
use rand::{seq::SliceRandom, Rng};
|
||||||
}
|
struct Symbols;
|
||||||
if config.ldap_user_pass == SecUtf8::from("password") {
|
|
||||||
println!("WARNING: Unsecure default admin password is used.");
|
impl rand::prelude::Distribution<char> for Symbols {
|
||||||
|
fn sample<R: Rng + ?Sized>(&self, rng: &mut R) -> char {
|
||||||
|
*b"01234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz+,-./:;<=>?_~!@#$%^&*()[]{}:;".choose(rng).unwrap() as char
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bail!("The JWT secret must be initialized to a random string, preferably at least 32 characters long. \
|
||||||
|
Either set the `jwt_secret` config value or the `LLDAP_JWT_SECRET` environment variable. \
|
||||||
|
You can generate the value by running\n\
|
||||||
|
LC_ALL=C tr -dc 'A-Za-z0-9!#%&'\\''()*+,-./:;<=>?@[\\]^_{{|}}~' </dev/urandom | head -c 32; echo ''\n\
|
||||||
|
or you can use this random value: {}",
|
||||||
|
rand::thread_rng()
|
||||||
|
.sample_iter(&Symbols)
|
||||||
|
.take(32)
|
||||||
|
.collect::<String>());
|
||||||
}
|
}
|
||||||
if config.smtp_options.tls_required.is_some() {
|
if config.smtp_options.tls_required.is_some() {
|
||||||
println!("DEPRECATED: smtp_options.tls_required field is deprecated, it never did anything. You can replace it with smtp_options.smtp_encryption.");
|
println!("DEPRECATED: smtp_options.tls_required field is deprecated, it never did anything. You can replace it with smtp_options.smtp_encryption.");
|
||||||
@@ -587,7 +682,9 @@ mod tests {
|
|||||||
fn figment_location_extraction_key_file() {
|
fn figment_location_extraction_key_file() {
|
||||||
Jail::expect_with(|jail| {
|
Jail::expect_with(|jail| {
|
||||||
jail.create_file("lldap_config.toml", r#"key_file = "test""#)?;
|
jail.create_file("lldap_config.toml", r#"key_file = "test""#)?;
|
||||||
|
jail.clear_env();
|
||||||
jail.set_env("LLDAP_KEY_SEED", "a123");
|
jail.set_env("LLDAP_KEY_SEED", "a123");
|
||||||
|
jail.set_env("LLDAP_JWT_SECRET", "secret");
|
||||||
let ignore_keys = ["key_file", "cert_file"];
|
let ignore_keys = ["key_file", "cert_file"];
|
||||||
let figment_config = Figment::from(Serialized::defaults(
|
let figment_config = Figment::from(Serialized::defaults(
|
||||||
ConfigurationBuilder::default().private_build().unwrap(),
|
ConfigurationBuilder::default().private_build().unwrap(),
|
||||||
@@ -614,7 +711,9 @@ mod tests {
|
|||||||
fn check_server_setup_key_extraction_seed_success_with_nonexistant_file() {
|
fn check_server_setup_key_extraction_seed_success_with_nonexistant_file() {
|
||||||
Jail::expect_with(|jail| {
|
Jail::expect_with(|jail| {
|
||||||
jail.create_file("lldap_config.toml", r#"key_file = "test""#)?;
|
jail.create_file("lldap_config.toml", r#"key_file = "test""#)?;
|
||||||
|
jail.clear_env();
|
||||||
jail.set_env("LLDAP_KEY_SEED", "a123");
|
jail.set_env("LLDAP_KEY_SEED", "a123");
|
||||||
|
jail.set_env("LLDAP_JWT_SECRET", "secret");
|
||||||
init(default_run_opts()).unwrap();
|
init(default_run_opts()).unwrap();
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
@@ -624,7 +723,9 @@ mod tests {
|
|||||||
fn check_server_setup_key_extraction_seed_failure_with_existing_file() {
|
fn check_server_setup_key_extraction_seed_failure_with_existing_file() {
|
||||||
Jail::expect_with(|jail| {
|
Jail::expect_with(|jail| {
|
||||||
jail.create_file("lldap_config.toml", r#"key_file = "test""#)?;
|
jail.create_file("lldap_config.toml", r#"key_file = "test""#)?;
|
||||||
|
jail.clear_env();
|
||||||
jail.set_env("LLDAP_KEY_SEED", "a123");
|
jail.set_env("LLDAP_KEY_SEED", "a123");
|
||||||
|
jail.set_env("LLDAP_JWT_SECRET", "secret");
|
||||||
write_random_key(jail, "test");
|
write_random_key(jail, "test");
|
||||||
init(default_run_opts()).unwrap_err();
|
init(default_run_opts()).unwrap_err();
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -635,6 +736,8 @@ mod tests {
|
|||||||
fn check_server_setup_key_extraction_file_success_with_existing_file() {
|
fn check_server_setup_key_extraction_file_success_with_existing_file() {
|
||||||
Jail::expect_with(|jail| {
|
Jail::expect_with(|jail| {
|
||||||
jail.create_file("lldap_config.toml", r#"key_file = "test""#)?;
|
jail.create_file("lldap_config.toml", r#"key_file = "test""#)?;
|
||||||
|
jail.clear_env();
|
||||||
|
jail.set_env("LLDAP_JWT_SECRET", "secret");
|
||||||
write_random_key(jail, "test");
|
write_random_key(jail, "test");
|
||||||
init(default_run_opts()).unwrap();
|
init(default_run_opts()).unwrap();
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -645,6 +748,8 @@ mod tests {
|
|||||||
fn check_server_setup_key_extraction_file_success_with_nonexistent_file() {
|
fn check_server_setup_key_extraction_file_success_with_nonexistent_file() {
|
||||||
Jail::expect_with(|jail| {
|
Jail::expect_with(|jail| {
|
||||||
jail.create_file("lldap_config.toml", r#"key_file = "test""#)?;
|
jail.create_file("lldap_config.toml", r#"key_file = "test""#)?;
|
||||||
|
jail.clear_env();
|
||||||
|
jail.set_env("LLDAP_JWT_SECRET", "secret");
|
||||||
init(default_run_opts()).unwrap();
|
init(default_run_opts()).unwrap();
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
@@ -654,6 +759,8 @@ mod tests {
|
|||||||
fn check_server_setup_key_extraction_file_with_previous_different_file() {
|
fn check_server_setup_key_extraction_file_with_previous_different_file() {
|
||||||
Jail::expect_with(|jail| {
|
Jail::expect_with(|jail| {
|
||||||
jail.create_file("lldap_config.toml", r#"key_file = "test""#)?;
|
jail.create_file("lldap_config.toml", r#"key_file = "test""#)?;
|
||||||
|
jail.clear_env();
|
||||||
|
jail.set_env("LLDAP_JWT_SECRET", "secret");
|
||||||
write_random_key(jail, "test");
|
write_random_key(jail, "test");
|
||||||
let config = init(default_run_opts()).unwrap();
|
let config = init(default_run_opts()).unwrap();
|
||||||
let info = config.get_private_key_info();
|
let info = config.get_private_key_info();
|
||||||
@@ -684,6 +791,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn check_server_setup_key_extraction_file_to_seed() {
|
fn check_server_setup_key_extraction_file_to_seed() {
|
||||||
Jail::expect_with(|jail| {
|
Jail::expect_with(|jail| {
|
||||||
|
jail.clear_env();
|
||||||
|
jail.set_env("LLDAP_JWT_SECRET", "secret");
|
||||||
jail.create_file("lldap_config.toml", "")?;
|
jail.create_file("lldap_config.toml", "")?;
|
||||||
write_random_key(jail, "server_key");
|
write_random_key(jail, "server_key");
|
||||||
init(default_run_opts()).unwrap();
|
init(default_run_opts()).unwrap();
|
||||||
@@ -700,6 +809,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn check_server_setup_key_extraction_file_to_seed_removed_file() {
|
fn check_server_setup_key_extraction_file_to_seed_removed_file() {
|
||||||
Jail::expect_with(|jail| {
|
Jail::expect_with(|jail| {
|
||||||
|
jail.clear_env();
|
||||||
|
jail.set_env("LLDAP_JWT_SECRET", "secret");
|
||||||
jail.create_file("lldap_config.toml", "")?;
|
jail.create_file("lldap_config.toml", "")?;
|
||||||
write_random_key(jail, "server_key");
|
write_random_key(jail, "server_key");
|
||||||
let config = init(default_run_opts()).unwrap();
|
let config = init(default_run_opts()).unwrap();
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Serialize, Deserialize, derive_more::Display)]
|
||||||
|
#[display("{_0}")]
|
||||||
pub struct DatabaseUrl(Url);
|
pub struct DatabaseUrl(Url);
|
||||||
|
|
||||||
impl From<Url> for DatabaseUrl {
|
impl From<Url> for DatabaseUrl {
|
||||||
@@ -16,25 +17,25 @@ impl From<&str> for DatabaseUrl {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl DatabaseUrl {
|
||||||
|
pub fn db_type(&self) -> &str {
|
||||||
|
self.0.scheme()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for DatabaseUrl {
|
impl std::fmt::Debug for DatabaseUrl {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
if self.0.password().is_some() {
|
if self.0.password().is_some() {
|
||||||
let mut url = self.0.clone();
|
let mut url = self.0.clone();
|
||||||
// It can fail for URLs that cannot have a password, like "mailto:bob@example".
|
// It can fail for URLs that cannot have a password, like "mailto:bob@example".
|
||||||
let _ = url.set_password(Some("***PASSWORD***"));
|
let _ = url.set_password(Some("***PASSWORD***"));
|
||||||
f.write_fmt(format_args!("{}", url))
|
f.write_fmt(format_args!(r#""{}""#, url))
|
||||||
} else {
|
} else {
|
||||||
f.write_fmt(format_args!("{}", self.0))
|
f.write_fmt(format_args!(r#""{}""#, self.0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToString for DatabaseUrl {
|
|
||||||
fn to_string(&self) -> String {
|
|
||||||
self.0.to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -44,7 +45,7 @@ mod tests {
|
|||||||
let url = DatabaseUrl::from("postgres://user:pass@localhost:5432/dbname");
|
let url = DatabaseUrl::from("postgres://user:pass@localhost:5432/dbname");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format!("{:?}", url),
|
format!("{:?}", url),
|
||||||
"postgres://user:***PASSWORD***@localhost:5432/dbname"
|
r#""postgres://user:***PASSWORD***@localhost:5432/dbname""#
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
url.to_string(),
|
url.to_string(),
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user