mirror of
https://github.com/lldap/lldap.git
synced 2026-07-13 23:16:35 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7119d96712 | |||
| 2973529c97 | |||
| 442c70b6d2 | |||
| 64140b4939 | |||
| 6ebeee4126 | |||
| a05ae617a1 | |||
| 7538059f6a | |||
| ee4a62e1e2 | |||
| 8a6ce87fb5 | |||
| af670dbc93 | |||
| 5840b3009d | |||
| 18f814ba02 | |||
| b55caae3cc | |||
| 93b4840e93 | |||
| e0e0da9ebf | |||
| 3316f54133 | |||
| c012c2891b | |||
| d459ac0c78 | |||
| c9f9a687a3 | |||
| 4c47d06c9b | |||
| e88db526b4 | |||
| e947b8eef0 | |||
| ee72b571d0 | |||
| cf492db570 | |||
| 6120a0dca5 | |||
| 523d418459 | |||
| 55225bc15b |
@@ -1,4 +1,4 @@
|
|||||||
FROM rust:1.85
|
FROM rust:1.74
|
||||||
|
|
||||||
ARG USERNAME=lldapdev
|
ARG USERNAME=lldapdev
|
||||||
# We need to keep the user as 1001 to match the GitHub runner's UID.
|
# We need to keep the user as 1001 to match the GitHub runner's UID.
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
* @nitnelave
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
# LLDAP - Light LDAP implementation for authentication
|
|
||||||
|
|
||||||
LLDAP is a lightweight LDAP authentication server written in Rust with a WebAssembly frontend. It provides an opinionated, simplified LDAP interface for authentication and integrates with many popular services.
|
|
||||||
|
|
||||||
**ALWAYS reference these instructions first and fallback to search or bash commands only when you encounter unexpected information that does not match the info here.**
|
|
||||||
|
|
||||||
## Working Effectively
|
|
||||||
|
|
||||||
### Bootstrap and Build the Repository
|
|
||||||
- Install dependencies: `sudo apt-get update && sudo apt-get install -y curl gzip binaryen`
|
|
||||||
- Install Rust if not available: `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` then `source ~/.cargo/env`
|
|
||||||
- Install wasm-pack for frontend: `cargo install wasm-pack` -- takes 90 seconds. NEVER CANCEL. Set timeout to 180+ seconds.
|
|
||||||
- Build entire workspace: `cargo build --workspace` -- takes 3-4 minutes. NEVER CANCEL. Set timeout to 300+ seconds.
|
|
||||||
- Build release server binary: `cargo build --release -p lldap` -- takes 5-6 minutes. NEVER CANCEL. Set timeout to 420+ seconds.
|
|
||||||
- Build frontend WASM: `./app/build.sh` -- takes 3-4 minutes including wasm-pack installation. NEVER CANCEL. Set timeout to 300+ seconds.
|
|
||||||
|
|
||||||
### Testing and Validation
|
|
||||||
- Run all tests: `cargo test --workspace` -- takes 2-3 minutes. NEVER CANCEL. Set timeout to 240+ seconds.
|
|
||||||
- Check formatting: `cargo fmt --all --check` -- takes <5 seconds.
|
|
||||||
- Run linting: `cargo clippy --tests --all -- -D warnings` -- takes 60-90 seconds. NEVER CANCEL. Set timeout to 120+ seconds.
|
|
||||||
- Export GraphQL schema: `./export_schema.sh` -- takes 70-80 seconds. NEVER CANCEL. Set timeout to 120+ seconds.
|
|
||||||
|
|
||||||
### Running the Application
|
|
||||||
- **ALWAYS run the build steps first before starting the server.**
|
|
||||||
- Start development server: `cargo run -- run --config-file <config_file>`
|
|
||||||
- **CRITICAL**: Server requires a valid configuration file. Use `lldap_config.docker_template.toml` as reference.
|
|
||||||
- **CRITICAL**: Avoid key conflicts by removing existing `server_key*` files when testing with `key_seed` in config.
|
|
||||||
- Server binds to:
|
|
||||||
- LDAP: port 3890 (configurable)
|
|
||||||
- Web interface: port 17170 (configurable)
|
|
||||||
- LDAPS: port 6360 (optional, disabled by default)
|
|
||||||
|
|
||||||
### Manual Validation Requirements
|
|
||||||
- **ALWAYS test both LDAP and web interfaces after making changes.**
|
|
||||||
- Test web interface: `curl -s http://localhost:17170/` should return HTML with "LLDAP Administration" title.
|
|
||||||
- Test GraphQL API: `curl -s -X POST -H "Content-Type: application/json" -d '{"query": "query { __schema { queryType { name } } }"}' http://localhost:17170/api/graphql`
|
|
||||||
- Run healthcheck: `cargo run -- healthcheck --config-file <config_file>` (requires running server)
|
|
||||||
- **ALWAYS ensure server starts without errors and serves the web interface before considering changes complete.**
|
|
||||||
|
|
||||||
## Validation Scenarios
|
|
||||||
|
|
||||||
After making code changes, ALWAYS:
|
|
||||||
1. **Build validation**: Run `cargo build --workspace` to ensure compilation succeeds.
|
|
||||||
2. **Test validation**: Run `cargo test --workspace` to ensure existing functionality works.
|
|
||||||
3. **Lint validation**: Run `cargo clippy --tests --all -- -D warnings` to catch potential issues.
|
|
||||||
4. **Format validation**: Run `cargo fmt --all --check` to ensure code style compliance.
|
|
||||||
5. **Frontend validation**: Run `./app/build.sh` to ensure WASM compilation succeeds.
|
|
||||||
6. **Runtime validation**: Start the server and verify web interface accessibility.
|
|
||||||
7. **Schema validation**: If GraphQL changes made, run `./export_schema.sh` to update schema.
|
|
||||||
|
|
||||||
### Test User Scenarios
|
|
||||||
- **Login flow**: Access web interface at `http://localhost:17170`, attempt login with admin/password (default).
|
|
||||||
- **LDAP binding**: Test LDAP connection on port 3890 with appropriate LDAP tools if available.
|
|
||||||
- **Configuration changes**: Test with different configuration files to validate config parsing.
|
|
||||||
|
|
||||||
## Project Structure and Key Components
|
|
||||||
|
|
||||||
### Backend (Rust)
|
|
||||||
- **Server**: `/server` - Main application binary
|
|
||||||
- **Crates**: `/crates/*` - Modularized components:
|
|
||||||
- `auth` - Authentication and OPAQUE protocol
|
|
||||||
- `domain*` - Domain models and handlers
|
|
||||||
- `ldap` - LDAP protocol implementation
|
|
||||||
- `graphql-server` - GraphQL API server
|
|
||||||
- `sql-backend-handler` - Database operations
|
|
||||||
- `validation` - Input validation utilities
|
|
||||||
|
|
||||||
### Frontend (Rust + WASM)
|
|
||||||
- **App**: `/app` - Yew-based WebAssembly frontend
|
|
||||||
- **Build**: `./app/build.sh` - Compiles Rust to WASM using wasm-pack
|
|
||||||
- **Assets**: `/app/static` - Static web assets
|
|
||||||
|
|
||||||
### Configuration and Deployment
|
|
||||||
- **Config template**: `lldap_config.docker_template.toml` - Reference configuration
|
|
||||||
- **Docker**: `Dockerfile` - Container build definition
|
|
||||||
- **Scripts**:
|
|
||||||
- `prepare-release.sh` - Cross-platform release builds
|
|
||||||
- `export_schema.sh` - GraphQL schema export
|
|
||||||
- `generate_secrets.sh` - Random secret generation
|
|
||||||
- `scripts/bootstrap.sh` - User/group management script
|
|
||||||
|
|
||||||
## Common Development Workflows
|
|
||||||
|
|
||||||
### Making Backend Changes
|
|
||||||
1. Edit Rust code in `/server` or `/crates`
|
|
||||||
2. Run `cargo build --workspace` to test compilation
|
|
||||||
3. Run `cargo test --workspace` to ensure tests pass
|
|
||||||
4. Run `cargo clippy --tests --all -- -D warnings` to check for warnings
|
|
||||||
5. If GraphQL schema affected, run `./export_schema.sh`
|
|
||||||
6. Test by running server and validating functionality
|
|
||||||
|
|
||||||
### Making Frontend Changes
|
|
||||||
1. Edit code in `/app/src`
|
|
||||||
2. Run `./app/build.sh` to rebuild WASM package
|
|
||||||
3. Start server and test web interface functionality
|
|
||||||
4. Verify no JavaScript errors in browser console
|
|
||||||
|
|
||||||
### Adding New Dependencies
|
|
||||||
- Backend: Add to appropriate `Cargo.toml` in `/server` or `/crates/*`
|
|
||||||
- Frontend: Add to `/app/Cargo.toml`
|
|
||||||
- **Always rebuild after dependency changes**
|
|
||||||
|
|
||||||
## CI/CD Integration
|
|
||||||
|
|
||||||
The repository uses GitHub Actions (`.github/workflows/rust.yml`):
|
|
||||||
- **Build job**: Validates workspace compilation
|
|
||||||
- **Test job**: Runs full test suite
|
|
||||||
- **Clippy job**: Linting with warnings as errors
|
|
||||||
- **Format job**: Code formatting validation
|
|
||||||
- **Coverage job**: Code coverage analysis
|
|
||||||
|
|
||||||
**ALWAYS ensure your changes pass all CI checks by running equivalent commands locally.**
|
|
||||||
|
|
||||||
## Timing Expectations and Timeouts
|
|
||||||
|
|
||||||
| Command | Expected Time | Timeout Setting |
|
|
||||||
|---------|---------------|-----------------|
|
|
||||||
| `cargo build --workspace` | 3-4 minutes | 300+ seconds |
|
|
||||||
| `cargo build --release -p lldap` | 5-6 minutes | 420+ seconds |
|
|
||||||
| `cargo test --workspace` | 2-3 minutes | 240+ seconds |
|
|
||||||
| `./app/build.sh` | 3-4 minutes | 300+ seconds |
|
|
||||||
| `cargo clippy --tests --all -- -D warnings` | 60-90 seconds | 120+ seconds |
|
|
||||||
| `./export_schema.sh` | 70-80 seconds | 120+ seconds |
|
|
||||||
| `cargo install wasm-pack` | 90 seconds | 180+ seconds |
|
|
||||||
|
|
||||||
**NEVER CANCEL** any of these commands. Builds may take longer on slower systems.
|
|
||||||
|
|
||||||
## Troubleshooting Common Issues
|
|
||||||
|
|
||||||
### Build Issues
|
|
||||||
- **Missing wasm-pack**: Run `cargo install wasm-pack`
|
|
||||||
- **Missing binaryen**: Run `sudo apt-get install -y binaryen` or disable wasm-opt
|
|
||||||
- **Clippy warnings**: Fix all warnings as they are treated as errors in CI
|
|
||||||
- **GraphQL schema mismatch**: Run `./export_schema.sh` to update schema
|
|
||||||
|
|
||||||
### Runtime Issues
|
|
||||||
- **Key conflicts**: Remove `server_key*` files when using `key_seed` in config
|
|
||||||
- **Port conflicts**: Check if ports 3890/17170 are available
|
|
||||||
- **Database issues**: Ensure database URL in config is valid and accessible
|
|
||||||
- **Asset missing**: Ensure frontend is built with `./app/build.sh`
|
|
||||||
|
|
||||||
### Development Environment
|
|
||||||
- **Rust version**: Use stable Rust toolchain (2024 edition)
|
|
||||||
- **System dependencies**: curl, gzip, build tools
|
|
||||||
- **Database**: SQLite (default), MySQL, or PostgreSQL supported
|
|
||||||
|
|
||||||
## Configuration Reference
|
|
||||||
|
|
||||||
Essential configuration parameters:
|
|
||||||
- `ldap_base_dn`: LDAP base DN (e.g., "dc=example,dc=com")
|
|
||||||
- `ldap_user_dn`: Admin user DN
|
|
||||||
- `ldap_user_pass`: Admin password
|
|
||||||
- `jwt_secret`: Secret for JWT tokens (generate with `./generate_secrets.sh`)
|
|
||||||
- `key_seed`: Encryption key seed
|
|
||||||
- `database_url`: Database connection string
|
|
||||||
- `http_port`: Web interface port (default: 17170)
|
|
||||||
- `ldap_port`: LDAP server port (default: 3890)
|
|
||||||
|
|
||||||
**Always use the provided config template as starting point for new configurations.**
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
name: Copilot Setup Steps for LLDAP Development
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Update package list
|
|
||||||
run: sudo apt-get update
|
|
||||||
|
|
||||||
- name: Install system dependencies
|
|
||||||
run: sudo apt-get install -y curl gzip binaryen build-essential
|
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
|
||||||
run: |
|
|
||||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
|
||||||
source ~/.cargo/env
|
|
||||||
echo 'source ~/.cargo/env' >> ~/.bashrc
|
|
||||||
|
|
||||||
- name: Install wasm-pack for frontend builds
|
|
||||||
run: |
|
|
||||||
source ~/.cargo/env
|
|
||||||
cargo install wasm-pack
|
|
||||||
|
|
||||||
- name: Verify installations
|
|
||||||
run: |
|
|
||||||
source ~/.cargo/env
|
|
||||||
rustc --version
|
|
||||||
cargo --version
|
|
||||||
wasm-pack --version
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
FROM localhost:5000/lldap/lldap:alpine-base
|
FROM localhost:5000/lldap/lldap:alpine-base
|
||||||
# Taken directly from https://github.com/tianon/gosu/blob/master/INSTALL.md
|
# Taken directly from https://github.com/tianon/gosu/blob/master/INSTALL.md
|
||||||
ENV GOSU_VERSION=1.17
|
ENV GOSU_VERSION 1.17
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
\
|
\
|
||||||
apk add --no-cache --virtual .gosu-deps \
|
apk add --no-cache --virtual .gosu-deps \
|
||||||
@@ -15,18 +15,7 @@ RUN set -eux; \
|
|||||||
\
|
\
|
||||||
# verify the signature
|
# verify the signature
|
||||||
export GNUPGHOME="$(mktemp -d)"; \
|
export GNUPGHOME="$(mktemp -d)"; \
|
||||||
for server in \
|
gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \
|
||||||
hkps://keys.openpgp.org \
|
|
||||||
ha.pool.sks-keyservers.net \
|
|
||||||
hkp://p80.pool.sks-keyservers.net:80 \
|
|
||||||
keyserver.ubuntu.com \
|
|
||||||
hkp://keyserver.ubuntu.com:80 \
|
|
||||||
pgp.mit.edu \
|
|
||||||
; do \
|
|
||||||
if gpg --batch --keyserver "$server" --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; then \
|
|
||||||
break; \
|
|
||||||
fi; \
|
|
||||||
done; \
|
|
||||||
gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
|
gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
|
||||||
gpgconf --kill all; \
|
gpgconf --kill all; \
|
||||||
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
|
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
|
||||||
|
|||||||
@@ -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.19
|
FROM alpine:3.16
|
||||||
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 jq curl jo && \
|
RUN apk add --no-cache tini ca-certificates bash tzdata && \
|
||||||
addgroup -g $GID $USER && \
|
addgroup -g $GID $USER && \
|
||||||
adduser \
|
adduser \
|
||||||
--disabled-password \
|
--disabled-password \
|
||||||
@@ -80,6 +80,5 @@ 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"]
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
FROM localhost:5000/lldap/lldap:debian-base
|
FROM localhost:5000/lldap/lldap:debian-base
|
||||||
# Taken directly from https://github.com/tianon/gosu/blob/master/INSTALL.md
|
# Taken directly from https://github.com/tianon/gosu/blob/master/INSTALL.md
|
||||||
ENV GOSU_VERSION=1.17
|
ENV GOSU_VERSION 1.17
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
# save list of currently installed packages for later so we can clean up
|
# save list of currently installed packages for later so we can clean up
|
||||||
savedAptMark="$(apt-mark showmanual)"; \
|
savedAptMark="$(apt-mark showmanual)"; \
|
||||||
for i in 1 2 3; do \
|
apt-get update; \
|
||||||
apt-get update && \
|
apt-get install -y --no-install-recommends ca-certificates gnupg wget; \
|
||||||
apt-get install -y --no-install-recommends wget ca-certificates gnupg && \
|
rm -rf /var/lib/apt/lists/*; \
|
||||||
apt-get clean && \
|
|
||||||
rm -rf /var/lib/apt/lists/* && break || sleep 5; \
|
|
||||||
done; \
|
|
||||||
\
|
\
|
||||||
dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \
|
dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \
|
||||||
wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \
|
wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \
|
||||||
@@ -17,18 +14,7 @@ RUN set -eux; \
|
|||||||
\
|
\
|
||||||
# verify the signature
|
# verify the signature
|
||||||
export GNUPGHOME="$(mktemp -d)"; \
|
export GNUPGHOME="$(mktemp -d)"; \
|
||||||
for server in \
|
gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \
|
||||||
hkps://keys.openpgp.org \
|
|
||||||
ha.pool.sks-keyservers.net \
|
|
||||||
hkp://p80.pool.sks-keyservers.net:80 \
|
|
||||||
keyserver.ubuntu.com \
|
|
||||||
hkp://keyserver.ubuntu.com:80 \
|
|
||||||
pgp.mit.edu \
|
|
||||||
; do \
|
|
||||||
if gpg --batch --keyserver "$server" --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; then \
|
|
||||||
break; \
|
|
||||||
fi; \
|
|
||||||
done; \
|
|
||||||
gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
|
gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
|
||||||
gpgconf --kill all; \
|
gpgconf --kill all; \
|
||||||
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
|
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
|
||||||
|
|||||||
@@ -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 jq curl jo && \
|
apt install -y --no-install-recommends tini openssl ca-certificates tzdata && \
|
||||||
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,7 +74,6 @@ 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"]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Keep tracking base image
|
# Keep tracking base image
|
||||||
FROM rust:1.85-slim-bookworm
|
FROM rust:1.74-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,8 +34,7 @@ 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"]
|
||||||
|
|||||||
@@ -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 #
|
# lldap/rust-dev:latest #
|
||||||
#######################################################################################
|
#######################################################################################
|
||||||
# Cargo build
|
# Cargo build
|
||||||
### armv7, aarch64 and amd64 is musl based
|
### armv7, aarch64 and amd64 is musl based
|
||||||
@@ -87,7 +87,7 @@ jobs:
|
|||||||
image: lldap/rust-dev:latest
|
image: lldap/rust-dev:latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5.0.0
|
uses: actions/checkout@v4.1.1
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
@@ -132,7 +132,7 @@ jobs:
|
|||||||
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
|
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5.0.0
|
uses: actions/checkout@v4.1.1
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
@@ -216,8 +216,6 @@ 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
|
||||||
@@ -229,8 +227,6 @@ 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
|
||||||
@@ -242,8 +238,6 @@ 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: |
|
||||||
@@ -300,7 +294,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout scripts
|
- name: Checkout scripts
|
||||||
uses: actions/checkout@v5.0.0
|
uses: actions/checkout@v4.1.1
|
||||||
with:
|
with:
|
||||||
sparse-checkout: 'scripts'
|
sparse-checkout: 'scripts'
|
||||||
|
|
||||||
@@ -330,9 +324,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
|
||||||
|
|
||||||
@@ -356,11 +350,8 @@ 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
|
bin/lldap create_schema -d postgres://lldapuser:lldappass@localhost:5432/lldap
|
||||||
|
|
||||||
- name: Copy converted db to postgress and import
|
- name: Copy converted db to postgress and import
|
||||||
run: |
|
run: |
|
||||||
@@ -377,10 +368,7 @@ 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
|
||||||
env:
|
run: bin/lldap create_schema -d mysql://lldapuser:lldappass@localhost:3306/lldap
|
||||||
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: |
|
||||||
@@ -396,10 +384,7 @@ 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
|
||||||
env:
|
run: bin/lldap create_schema -d mysql://lldapuser:lldappass@localhost:3307/lldap
|
||||||
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: |
|
||||||
@@ -414,9 +399,10 @@ 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
|
||||||
@@ -425,9 +411,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
|
||||||
@@ -436,9 +422,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
|
||||||
@@ -496,7 +482,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5.0.0
|
uses: actions/checkout@v4.1.1
|
||||||
|
|
||||||
- name: Download all artifacts
|
- name: Download all artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
@@ -526,7 +512,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@v6
|
uses: docker/build-push-action@v5
|
||||||
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
|
||||||
@@ -627,7 +613,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@v6
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
@@ -641,7 +627,7 @@ jobs:
|
|||||||
|
|
||||||
### This docker build always the last, due :latest tag pushed multiple times, for whatever variants may added in future add docker build above this
|
### 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@v6
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
@@ -752,9 +738,5 @@ 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 }}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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.
|
||||||
+29
-29
@@ -8,7 +8,6 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
MSRV: 1.85.0
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
pre_job:
|
pre_job:
|
||||||
@@ -34,19 +33,14 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout sources
|
- name: Checkout sources
|
||||||
uses: actions/checkout@v5.0.0
|
uses: actions/checkout@v4.1.1
|
||||||
- name: Install Rust
|
|
||||||
id: toolchain
|
|
||||||
uses: dtolnay/rust-toolchain@master
|
|
||||||
with:
|
|
||||||
toolchain: "${{ env.MSRV }}"
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
- name: Build
|
- name: Build
|
||||||
run: cargo build --verbose --workspace
|
run: cargo build --verbose --workspace
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: cargo +${{steps.toolchain.outputs.name}} test --verbose --workspace
|
run: cargo test --verbose --workspace
|
||||||
- name: Generate GraphQL schema
|
- name: Generate GraphQL schema
|
||||||
run: cargo +${{steps.toolchain.outputs.name}} run -- export_graphql_schema -o generated_schema.graphql
|
run: cargo run -- export_graphql_schema -o generated_schema.graphql
|
||||||
- name: Check schema
|
- name: Check schema
|
||||||
run: diff schema.graphql generated_schema.graphql || (echo "The schema file is out of date. Please run `./export_schema.sh`" && false)
|
run: diff schema.graphql generated_schema.graphql || (echo "The schema file is out of date. Please run `./export_schema.sh`" && false)
|
||||||
|
|
||||||
@@ -58,15 +52,15 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout sources
|
- name: Checkout sources
|
||||||
uses: actions/checkout@v5.0.0
|
uses: actions/checkout@v4.1.1
|
||||||
- name: Install Rust
|
|
||||||
id: toolchain
|
|
||||||
uses: dtolnay/rust-toolchain@master
|
|
||||||
with:
|
|
||||||
toolchain: "${{ env.MSRV }}"
|
|
||||||
components: clippy
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
- run: cargo +${{steps.toolchain.outputs.name}} clippy --tests --workspace -- -D warnings
|
|
||||||
|
- name: Run cargo clippy
|
||||||
|
uses: actions-rs/cargo@v1
|
||||||
|
with:
|
||||||
|
command: clippy
|
||||||
|
args: --tests --all -- -D warnings
|
||||||
|
|
||||||
format:
|
format:
|
||||||
name: cargo fmt
|
name: cargo fmt
|
||||||
@@ -75,15 +69,15 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout sources
|
- name: Checkout sources
|
||||||
uses: actions/checkout@v5.0.0
|
uses: actions/checkout@v4.1.1
|
||||||
- name: Install Rust
|
|
||||||
id: toolchain
|
|
||||||
uses: dtolnay/rust-toolchain@master
|
|
||||||
with:
|
|
||||||
toolchain: "${{ env.MSRV }}"
|
|
||||||
components: rustfmt
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
- run: cargo +${{steps.toolchain.outputs.name}} fmt --check --all
|
|
||||||
|
- name: Run cargo fmt
|
||||||
|
uses: actions-rs/cargo@v1
|
||||||
|
with:
|
||||||
|
command: fmt
|
||||||
|
args: --all -- --check
|
||||||
|
|
||||||
coverage:
|
coverage:
|
||||||
name: Code coverage
|
name: Code coverage
|
||||||
@@ -94,7 +88,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout sources
|
- name: Checkout sources
|
||||||
uses: actions/checkout@v5.0.0
|
uses: actions/checkout@v4.1.1
|
||||||
|
|
||||||
- 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
|
||||||
@@ -107,10 +101,16 @@ 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 (main)
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v4
|
uses: codecov/codecov-action@v3
|
||||||
|
if: github.ref != 'refs/heads/main' || github.event_name != 'push'
|
||||||
|
with:
|
||||||
|
files: lcov.info
|
||||||
|
fail_ci_if_error: true
|
||||||
|
- name: Upload coverage to Codecov (main)
|
||||||
|
uses: codecov/codecov-action@v3
|
||||||
|
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||||
with:
|
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 }}
|
||||||
|
|||||||
+1
-157
@@ -5,162 +5,6 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
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.2] 2025-07-21
|
|
||||||
|
|
||||||
Small release, focused on LDAP improvements and ongoing maintenance.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- LDAP
|
|
||||||
- Support for searching groups by their `groupid`
|
|
||||||
- Support for `whoamiOID`
|
|
||||||
- Support for creating groups
|
|
||||||
- Support for subschema entry
|
|
||||||
- Custom assets path.
|
|
||||||
- New endpoint for requesting client settings
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- A missing JWT secret now prevents startup.
|
|
||||||
- Attributes with invalid characters (such as underscores) cannot be created anymore.
|
|
||||||
- Searching custom (string) attributes is now case insensitive.
|
|
||||||
- Using the top-level `firstName`, `lastName` and `avatar` GraphQL fields for users is now deprecated. Use the `attributes` field instead.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- `lldap_set_password` now uses the system's SSL certificates.
|
|
||||||
|
|
||||||
### Cleanups
|
|
||||||
|
|
||||||
- Split the main `lldap` crate into many sub-crates
|
|
||||||
- Various dependency version bumps
|
|
||||||
- Upgraded to 2024 Rust edition
|
|
||||||
- Docs/FAQ improvements
|
|
||||||
|
|
||||||
### Bootstrap script
|
|
||||||
|
|
||||||
- Custom attributes support
|
|
||||||
- Read the paswsord from a file
|
|
||||||
- Resilient to no user or group files
|
|
||||||
|
|
||||||
### New services
|
|
||||||
|
|
||||||
- Discord integration (Discord role to LLDAP user)
|
|
||||||
- HashiCorp
|
|
||||||
- Jellyfin 2FA with Duo
|
|
||||||
- Kimai
|
|
||||||
- Mailcow
|
|
||||||
- Peertube
|
|
||||||
- Penpot
|
|
||||||
- PgAdmin
|
|
||||||
- Project Quay
|
|
||||||
- Quadlet
|
|
||||||
- Snipe-IT
|
|
||||||
- SSSD
|
|
||||||
- Stalwart
|
|
||||||
- UnifiOS
|
|
||||||
|
|
||||||
## [0.6.1] 2024-11-22
|
|
||||||
|
|
||||||
Small release, mainly to fix a migration issue with Sqlite and Postgresql.
|
|
||||||
|
|
||||||
### 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
|
||||||
@@ -227,7 +71,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`.
|
||||||
|
|||||||
Generated
+1152
-1664
File diff suppressed because it is too large
Load Diff
+12
-21
@@ -1,21 +1,15 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = [
|
members = [
|
||||||
"server",
|
"server",
|
||||||
"app",
|
"auth",
|
||||||
"migration-tool",
|
"app",
|
||||||
"set-password",
|
"migration-tool",
|
||||||
"crates/*",
|
"set-password",
|
||||||
]
|
]
|
||||||
default-members = ["server"]
|
|
||||||
resolver = "2"
|
|
||||||
|
|
||||||
[workspace.package]
|
default-members = ["server"]
|
||||||
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
|
|
||||||
documentation = "https://github.com/lldap/lldap"
|
resolver = "2"
|
||||||
edition = "2024"
|
|
||||||
homepage = "https://github.com/lldap/lldap"
|
|
||||||
license = "GPL-3.0-only"
|
|
||||||
repository = "https://github.com/lldap/lldap"
|
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = true
|
lto = true
|
||||||
@@ -23,12 +17,9 @@ 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/'
|
||||||
|
|
||||||
[workspace.dependencies.sea-orm]
|
|
||||||
version = "1.1.8"
|
|
||||||
default-features = false
|
|
||||||
|
|
||||||
[workspace.dependencies.serde]
|
|
||||||
version = "1"
|
|
||||||
|
|||||||
+3
-4
@@ -1,5 +1,5 @@
|
|||||||
# Build image
|
# Build image
|
||||||
FROM rust:alpine3.21 AS chef
|
FROM rust:alpine3.16 AS chef
|
||||||
|
|
||||||
RUN set -x \
|
RUN set -x \
|
||||||
# Add user
|
# Add user
|
||||||
@@ -41,9 +41,9 @@ 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.21
|
FROM alpine:3.16
|
||||||
|
|
||||||
ENV GOSU_VERSION=1.14
|
ENV GOSU_VERSION 1.14
|
||||||
# Fetch gosu from git
|
# Fetch gosu from git
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
\
|
\
|
||||||
@@ -80,7 +80,6 @@ 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 \
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
build-dev-container:
|
|
||||||
docker buildx build --tag lldap/rust-dev --file .github/workflows/Dockerfile.dev --push .github/workflows
|
|
||||||
|
|
||||||
prepare-release:
|
|
||||||
./prepare-release.sh
|
|
||||||
@@ -34,14 +34,27 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
- [About](#about)
|
- [About](#about)
|
||||||
- [Installation](docs/install.md)
|
- [Installation](#installation)
|
||||||
|
- [With Docker](#with-docker)
|
||||||
|
- [With Kubernetes](#with-kubernetes)
|
||||||
|
- [From a package repository](#from-a-package-repository)
|
||||||
|
- [From source](#from-source)
|
||||||
|
- [Backend](#backend)
|
||||||
|
- [Frontend](#frontend)
|
||||||
|
- [Cross-compilation](#cross-compilation)
|
||||||
- [Usage](#usage)
|
- [Usage](#usage)
|
||||||
- [Recommended architecture](#recommended-architecture)
|
- [Recommended architecture](#recommended-architecture)
|
||||||
- [Client configuration](#client-configuration)
|
- [Client configuration](#client-configuration)
|
||||||
- [Known compatible services](#known-compatible-services)
|
- [Compatible services](#compatible-services)
|
||||||
- [General configuration guide](#general-configuration-guide)
|
- [General configuration guide](#general-configuration-guide)
|
||||||
|
- [Sample client configurations](#sample-client-configurations)
|
||||||
- [Incompatible services](#incompatible-services)
|
- [Incompatible services](#incompatible-services)
|
||||||
- [Frequently Asked Questions](#frequently-asked-questions)
|
- [Migrating from SQLite](#migrating-from-sqlite)
|
||||||
|
- [Comparisons with other services](#comparisons-with-other-services)
|
||||||
|
- [vs OpenLDAP](#vs-openldap)
|
||||||
|
- [vs FreeIPA](#vs-freeipa)
|
||||||
|
- [vs Kanidm](#vs-kanidm)
|
||||||
|
- [I can't log in!](#i-cant-log-in)
|
||||||
- [Contributions](#contributions)
|
- [Contributions](#contributions)
|
||||||
|
|
||||||
## About
|
## About
|
||||||
@@ -83,9 +96,179 @@ MySQL/MariaDB or PostgreSQL.
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
It's possible to install lldap from OCI images ([docker](docs/install.md#with-docker)/[podman](docs/install.md#with-podman)), from [Kubernetes](docs/install.md#with-kubernetes), or from [a regular distribution package manager](docs/install.md/#from-a-package-repository) (Archlinux, Debian, CentOS, Fedora, OpenSuse, Ubuntu, FreeBSD).
|
### With Docker
|
||||||
|
|
||||||
Building [from source](docs/install.md#from-source) and [cross-compiling](docs/install.md#cross-compilation) to a different hardware architecture is also supported.
|
The image is available at `lldap/lldap`. You should persist the `/data`
|
||||||
|
folder, which contains your configuration and the SQLite database (you can
|
||||||
|
remove this step if you use a different DB and configure with environment
|
||||||
|
variables only).
|
||||||
|
|
||||||
|
Configure the server by copying the `lldap_config.docker_template.toml` to
|
||||||
|
`/data/lldap_config.toml` and updating the configuration values (especially the
|
||||||
|
`jwt_secret` and `ldap_user_pass`, unless you override them with env variables).
|
||||||
|
Environment variables should be prefixed with `LLDAP_` to override the
|
||||||
|
configuration.
|
||||||
|
|
||||||
|
If the `lldap_config.toml` doesn't exist when starting up, LLDAP will use
|
||||||
|
default one. The default admin password is `password`, you can change the
|
||||||
|
password later using the web interface.
|
||||||
|
|
||||||
|
Secrets can also be set through a file. The filename should be specified by the
|
||||||
|
variables `LLDAP_JWT_SECRET_FILE` or `LLDAP_KEY_SEED_FILE`, and the file
|
||||||
|
contents are loaded into the respective configuration parameters. Note that
|
||||||
|
`_FILE` variables take precedence.
|
||||||
|
|
||||||
|
Example for docker compose:
|
||||||
|
|
||||||
|
- You can use either the `:latest` tag image or `:stable` as used in this example.
|
||||||
|
- `:latest` tag image contains recently pushed code or feature tests, in which some instability can be expected.
|
||||||
|
- If `UID` and `GID` no defined LLDAP will use default `UID` and `GID` number `1000`.
|
||||||
|
- If no `TZ` is set, default `UTC` timezone will be used.
|
||||||
|
- You can generate the secrets by running `./generate_secrets.sh`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: "3"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
lldap_data:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
services:
|
||||||
|
lldap:
|
||||||
|
image: lldap/lldap:stable
|
||||||
|
ports:
|
||||||
|
# For LDAP, not recommended to expose, see Usage section.
|
||||||
|
#- "3890:3890"
|
||||||
|
# For LDAPS (LDAP Over SSL), enable port if LLDAP_LDAPS_OPTIONS__ENABLED set true, look env below
|
||||||
|
#- "6360:6360"
|
||||||
|
# For the web front-end
|
||||||
|
- "17170:17170"
|
||||||
|
volumes:
|
||||||
|
- "lldap_data:/data"
|
||||||
|
# Alternatively, you can mount a local folder
|
||||||
|
# - "./lldap_data:/data"
|
||||||
|
environment:
|
||||||
|
- UID=####
|
||||||
|
- GID=####
|
||||||
|
- TZ=####/####
|
||||||
|
- LLDAP_JWT_SECRET=REPLACE_WITH_RANDOM
|
||||||
|
- LLDAP_KEY_SEED=REPLACE_WITH_RANDOM
|
||||||
|
- LLDAP_LDAP_BASE_DN=dc=example,dc=com
|
||||||
|
# If using LDAPS, set enabled true and configure cert and key path
|
||||||
|
# - LLDAP_LDAPS_OPTIONS__ENABLED=true
|
||||||
|
# - LLDAP_LDAPS_OPTIONS__CERT_FILE=/path/to/certfile.crt
|
||||||
|
# - LLDAP_LDAPS_OPTIONS__KEY_FILE=/path/to/keyfile.key
|
||||||
|
# You can also set a different database:
|
||||||
|
# - LLDAP_DATABASE_URL=mysql://mysql-user:password@mysql-server/my-database
|
||||||
|
# - LLDAP_DATABASE_URL=postgres://postgres-user:password@postgres-server/my-database
|
||||||
|
```
|
||||||
|
|
||||||
|
Then the service will listen on two ports, one for LDAP and one for the web
|
||||||
|
front-end.
|
||||||
|
|
||||||
|
### With Kubernetes
|
||||||
|
|
||||||
|
See https://github.com/Evantage-WS/lldap-kubernetes for a LLDAP deployment for Kubernetes
|
||||||
|
|
||||||
|
You can bootstrap your lldap instance (users, groups)
|
||||||
|
using [bootstrap.sh](example_configs/bootstrap/bootstrap.md#kubernetes-job).
|
||||||
|
It can be run by Argo CD for managing users in git-opt way, or as a one-shot job.
|
||||||
|
|
||||||
|
### From a package repository
|
||||||
|
|
||||||
|
**Do not open issues in this repository for problems with third-party
|
||||||
|
pre-built packages. Report issues downstream.**
|
||||||
|
|
||||||
|
Depending on the distribution you use, it might be possible to install lldap
|
||||||
|
from a package repository, officially supported by the distribution or
|
||||||
|
community contributed.
|
||||||
|
|
||||||
|
#### Debian, CentOS Fedora, OpenSUSE, Ubuntu
|
||||||
|
|
||||||
|
The package for these distributions can be found at [LLDAP OBS](https://software.opensuse.org//download.html?project=home%3AMasgalor%3ALLDAP&package=lldap).
|
||||||
|
- When using the distributed package, the default login is `admin/password`. You can change that from the web UI after starting the service.
|
||||||
|
|
||||||
|
#### Arch Linux
|
||||||
|
|
||||||
|
Arch Linux offers unofficial support through the [Arch User Repository
|
||||||
|
(AUR)](https://wiki.archlinux.org/title/Arch_User_Repository).
|
||||||
|
Available package descriptions in AUR are:
|
||||||
|
|
||||||
|
- [lldap](https://aur.archlinux.org/packages/lldap) - Builds the latest stable version.
|
||||||
|
- [lldap-bin](https://aur.archlinux.org/packages/lldap-bin) - Uses the latest
|
||||||
|
pre-compiled binaries from the [releases in this repository](https://github.com/lldap/lldap/releases).
|
||||||
|
This package is recommended if you want to run lldap on a system with
|
||||||
|
limited resources.
|
||||||
|
- [lldap-git](https://aur.archlinux.org/packages/lldap-git) - Builds the
|
||||||
|
latest main branch code.
|
||||||
|
|
||||||
|
The package descriptions can be used
|
||||||
|
[to create and install packages](https://wiki.archlinux.org/title/Arch_User_Repository#Getting_started).
|
||||||
|
Each package places lldap's configuration file at `/etc/lldap.toml` and offers
|
||||||
|
[systemd service](https://wiki.archlinux.org/title/systemd#Using_units)
|
||||||
|
`lldap.service` to (auto-)start and stop lldap.
|
||||||
|
|
||||||
|
### From source
|
||||||
|
|
||||||
|
#### Backend
|
||||||
|
|
||||||
|
To compile the project, you'll need:
|
||||||
|
|
||||||
|
- curl and gzip: `sudo apt install curl gzip`
|
||||||
|
- Rust/Cargo: [rustup.rs](https://rustup.rs/)
|
||||||
|
|
||||||
|
Then you can compile the server (and the migration tool if you want):
|
||||||
|
|
||||||
|
```shell
|
||||||
|
cargo build --release -p lldap -p lldap_migration_tool
|
||||||
|
```
|
||||||
|
|
||||||
|
The resulting binaries will be in `./target/release/`. Alternatively, you can
|
||||||
|
just run `cargo run -- run` to run the server.
|
||||||
|
|
||||||
|
#### Frontend
|
||||||
|
|
||||||
|
To bring up the server, you'll need to compile the frontend. In addition to
|
||||||
|
`cargo`, you'll need WASM-pack, which can be installed by running `cargo install wasm-pack`.
|
||||||
|
|
||||||
|
Then you can build the frontend files with
|
||||||
|
|
||||||
|
```shell
|
||||||
|
./app/build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
(you'll need to run this after every front-end change to update the WASM
|
||||||
|
package served).
|
||||||
|
|
||||||
|
The default config is in `src/infra/configuration.rs`, but you can override it
|
||||||
|
by creating an `lldap_config.toml`, setting environment variables or passing
|
||||||
|
arguments to `cargo run`. Have a look at the docker template:
|
||||||
|
`lldap_config.docker_template.toml`.
|
||||||
|
|
||||||
|
You can also install it as a systemd service, see
|
||||||
|
[lldap.service](example_configs/lldap.service).
|
||||||
|
|
||||||
|
### Cross-compilation
|
||||||
|
|
||||||
|
Docker images are provided for AMD64, ARM64 and ARM/V7.
|
||||||
|
|
||||||
|
If you want to cross-compile yourself, you can do so by installing
|
||||||
|
[`cross`](https://github.com/rust-embedded/cross):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo install cross
|
||||||
|
cross build --target=armv7-unknown-linux-musleabihf -p lldap --release
|
||||||
|
./app/build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
(Replace `armv7-unknown-linux-musleabihf` with the correct Rust target for your
|
||||||
|
device.)
|
||||||
|
|
||||||
|
You can then get the compiled server binary in
|
||||||
|
`target/armv7-unknown-linux-musleabihf/release/lldap` and the various needed files
|
||||||
|
(`index.html`, `main.js`, `pkg` folder) in the `app` folder. Copy them to the
|
||||||
|
Raspberry Pi (or other target), with the folder structure maintained (`app`
|
||||||
|
files in an `app` folder next to the binary).
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -94,16 +277,10 @@ create users, set passwords, add them to groups and so on. Users can also
|
|||||||
connect to the web UI and change their information, or request a password reset
|
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).
|
||||||
|
|
||||||
You can create and manage custom attributes through the Web UI, or through the
|
Creating and managing custom attributes is currently in Beta. It's not
|
||||||
community-contributed CLI frontend (
|
supported in the Web UI. The recommended way is to use
|
||||||
[Zepmann/lldap-cli](https://github.com/Zepmann/lldap-cli)). This is necessary
|
[Zepmann/lldap-cli](https://github.com/Zepmann/lldap-cli), a
|
||||||
for some service integrations.
|
community-contributed CLI frontend.
|
||||||
|
|
||||||
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.
|
||||||
@@ -136,7 +313,7 @@ If you are using containers, a sample architecture could look like this:
|
|||||||
|
|
||||||
## Client configuration
|
## Client configuration
|
||||||
|
|
||||||
### Known compatible services
|
### Compatible services
|
||||||
|
|
||||||
Most services that can use LDAP as an authentication provider should work out
|
Most services that can use LDAP as an authentication provider should work out
|
||||||
of the box. For new services, it's possible that they require a bit of tweaking
|
of the box. For new services, it's possible that they require a bit of tweaking
|
||||||
@@ -144,13 +321,6 @@ on LLDAP's side to make things work. In that case, just create an issue with
|
|||||||
the relevant details (logs of the service, LLDAP logs with `verbose=true` in
|
the relevant details (logs of the service, LLDAP logs with `verbose=true` in
|
||||||
the config).
|
the config).
|
||||||
|
|
||||||
Some specific clients have been tested to work and come with sample
|
|
||||||
configuration files, or guides. See the [`example_configs`](example_configs)
|
|
||||||
folder for example configs for integration with specific services.
|
|
||||||
|
|
||||||
Integration with Linux accounts is possible, through PAM and nslcd. See [PAM
|
|
||||||
configuration guide](example_configs/pam/README.md). Integration with Windows (e.g. Samba) is WIP.
|
|
||||||
|
|
||||||
### General configuration guide
|
### General configuration guide
|
||||||
|
|
||||||
To configure the services that will talk to LLDAP, here are the values:
|
To configure the services that will talk to LLDAP, here are the values:
|
||||||
@@ -170,9 +340,67 @@ filter like: `(memberOf=cn=admins,ou=groups,dc=example,dc=com)`.
|
|||||||
The administrator group for LLDAP is `lldap_admin`: anyone in this group has
|
The administrator group for LLDAP is `lldap_admin`: anyone in this group has
|
||||||
admin rights in the Web UI. Most LDAP integrations should instead use a user in
|
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. To prevent privilege escalation users in the
|
administration access to many services.
|
||||||
`lldap_password_manager` group are not allowed to change passwords of admins in the
|
|
||||||
`lldap_admin` group.
|
### Sample client configurations
|
||||||
|
|
||||||
|
Some specific clients have been tested to work and come with sample
|
||||||
|
configuration files, or guides. See the [`example_configs`](example_configs)
|
||||||
|
folder for help with:
|
||||||
|
|
||||||
|
- [Airsonic Advanced](example_configs/airsonic-advanced.md)
|
||||||
|
- [Apache Guacamole](example_configs/apacheguacamole.md)
|
||||||
|
- [Apereo CAS Server](example_configs/apereo_cas_server.md)
|
||||||
|
- [Authelia](example_configs/authelia_config.yml)
|
||||||
|
- [Authentik](example_configs/authentik.md)
|
||||||
|
- [Bookstack](example_configs/bookstack.env.example)
|
||||||
|
- [Calibre-Web](example_configs/calibre_web.md)
|
||||||
|
- [Dell iDRAC](example_configs/dell_idrac.md)
|
||||||
|
- [Dex](example_configs/dex_config.yml)
|
||||||
|
- [Dokuwiki](example_configs/dokuwiki.md)
|
||||||
|
- [Dolibarr](example_configs/dolibarr.md)
|
||||||
|
- [Ejabberd](example_configs/ejabberd.md)
|
||||||
|
- [Emby](example_configs/emby.md)
|
||||||
|
- [Ergo IRCd](example_configs/ergo.md)
|
||||||
|
- [Gitea](example_configs/gitea.md)
|
||||||
|
- [GitLab](example_configs/gitlab.md)
|
||||||
|
- [Grafana](example_configs/grafana_ldap_config.toml)
|
||||||
|
- [Grocy](example_configs/grocy.md)
|
||||||
|
- [Hedgedoc](example_configs/hedgedoc.md)
|
||||||
|
- [Home Assistant](example_configs/home-assistant.md)
|
||||||
|
- [Jellyfin](example_configs/jellyfin.md)
|
||||||
|
- [Jenkins](example_configs/jenkins.md)
|
||||||
|
- [Jitsi Meet](example_configs/jitsi_meet.conf)
|
||||||
|
- [Kasm](example_configs/kasm.md)
|
||||||
|
- [KeyCloak](example_configs/keycloak.md)
|
||||||
|
- [LibreNMS](example_configs/librenms.md)
|
||||||
|
- [Maddy](example_configs/maddy.md)
|
||||||
|
- [Mastodon](example_configs/mastodon.env.example)
|
||||||
|
- [Matrix](example_configs/matrix_synapse.yml)
|
||||||
|
- [Mealie](example_configs/mealie.md)
|
||||||
|
- [MinIO](example_configs/minio.md)
|
||||||
|
- [Nextcloud](example_configs/nextcloud.md)
|
||||||
|
- [Nexus](example_configs/nexus.md)
|
||||||
|
- [Organizr](example_configs/Organizr.md)
|
||||||
|
- [Portainer](example_configs/portainer.md)
|
||||||
|
- [PowerDNS Admin](example_configs/powerdns_admin.md)
|
||||||
|
- [Proxmox VE](example_configs/proxmox.md)
|
||||||
|
- [Radicale](example_configs/radicale.md)
|
||||||
|
- [Rancher](example_configs/rancher.md)
|
||||||
|
- [Seafile](example_configs/seafile.md)
|
||||||
|
- [Shaarli](example_configs/shaarli.md)
|
||||||
|
- [Squid](example_configs/squid.md)
|
||||||
|
- [Syncthing](example_configs/syncthing.md)
|
||||||
|
- [TheLounge](example_configs/thelounge.md)
|
||||||
|
- [Traccar](example_configs/traccar.xml)
|
||||||
|
- [Vaultwarden](example_configs/vaultwarden.md)
|
||||||
|
- [WeKan](example_configs/wekan.md)
|
||||||
|
- [WG Portal](example_configs/wg_portal.env.example)
|
||||||
|
- [WikiJS](example_configs/wikijs.md)
|
||||||
|
- [XBackBone](example_configs/xbackbone_config.php)
|
||||||
|
- [Zendto](example_configs/zendto.md)
|
||||||
|
- [Zitadel](example_configs/zitadel.md)
|
||||||
|
- [Zulip](example_configs/zulip.md)
|
||||||
|
|
||||||
### Incompatible services
|
### Incompatible services
|
||||||
|
|
||||||
@@ -195,16 +423,76 @@ it duplicates the places from which a password hash could leak.
|
|||||||
In that category, the most prominent is Synology. It is, to date, the only
|
In that category, the most prominent is Synology. It is, to date, the only
|
||||||
service that seems definitely incompatible with LLDAP.
|
service that seems definitely incompatible with LLDAP.
|
||||||
|
|
||||||
## Frequently Asked Questions
|
## Migrating from SQLite
|
||||||
|
|
||||||
- [I can't login](docs/faq.md#i-cant-log-in)
|
If you started with an SQLite database and would like to migrate to
|
||||||
- [Discord Integration](docs/faq.md#discord-integration)
|
MySQL/MariaDB or PostgreSQL, check out the [DB
|
||||||
- [Migrating from SQLite](docs/faq.md#migrating-from-sqlite)
|
migration docs](/docs/database_migration.md).
|
||||||
- How does lldap compare [with OpenLDAP](docs/faq.md#how-does-lldap-compare-with-openldap)? [With FreeIPA](docs/faq.md#how-does-lldap-compare-with-freeipa)? [With Kanidm](docs/faq.md#how-does-lldap-compare-with-kanidm)?
|
|
||||||
- [Does lldap support vhosts?](docs/faq.md#does-lldap-support-vhosts)
|
## Comparisons with other services
|
||||||
- [Does lldap provide commercial support contracts?](docs/faq.md#does-lldap-provide-commercial-support-contracts)
|
|
||||||
- [Can I make a donation to fund development?](docs/faq.md#can-i-make-a-donation-to-fund-development)
|
### vs OpenLDAP
|
||||||
- [Is lldap sustainable? Can we depend on it for our infrastructure?](docs/faq.md#is-lldap-sustainable-can-we-depend-on-it-for-our-infrastructure)
|
|
||||||
|
[OpenLDAP](https://www.openldap.org) is a monster of a service that implements
|
||||||
|
all of LDAP and all of its extensions, plus some of its own. That said, if you
|
||||||
|
need all that flexibility, it might be what you need! Note that installation
|
||||||
|
can be a bit painful (figuring out how to use `slapd`) and people have mixed
|
||||||
|
experiences following tutorials online. If you don't configure it properly, you
|
||||||
|
might end up storing passwords in clear, so a breach of your server would
|
||||||
|
reveal all the stored passwords!
|
||||||
|
|
||||||
|
OpenLDAP doesn't come with a UI: if you want a web interface, you'll have to
|
||||||
|
install one (not that many look nice) and configure it.
|
||||||
|
|
||||||
|
LLDAP is much simpler to setup, has a much smaller image (10x smaller, 20x if
|
||||||
|
you add PhpLdapAdmin), and comes packed with its own purpose-built web UI.
|
||||||
|
However, it's not as flexible as OpenLDAP.
|
||||||
|
|
||||||
|
### vs FreeIPA
|
||||||
|
|
||||||
|
[FreeIPA](http://www.freeipa.org) is the one-stop shop for identity management:
|
||||||
|
LDAP, Kerberos, NTP, DNS, Samba, you name it, it has it. In addition to user
|
||||||
|
management, it also does security policies, single sign-on, certificate
|
||||||
|
management, linux account management and so on.
|
||||||
|
|
||||||
|
If you need all of that, go for it! Keep in mind that a more complex system is
|
||||||
|
more complex to maintain, though.
|
||||||
|
|
||||||
|
LLDAP is much lighter to run (<10 MB RAM including the DB), easier to
|
||||||
|
configure (no messing around with DNS or security policies) and simpler to
|
||||||
|
use. It also comes conveniently packed in a docker container.
|
||||||
|
|
||||||
|
### vs Kanidm
|
||||||
|
|
||||||
|
[Kanidm](https://kanidm.com) is an up-and-coming Rust identity management
|
||||||
|
platform, covering all your bases: OAuth, Linux accounts, SSH keys, Radius,
|
||||||
|
WebAuthn. It comes with a (read-only) LDAPS server.
|
||||||
|
|
||||||
|
It's fairly easy to install and does much more; but their LDAP server is
|
||||||
|
read-only, and by having more moving parts it is inherently more complex. If
|
||||||
|
you don't need to modify the users through LDAP and you're planning on
|
||||||
|
installing something like [KeyCloak](https://www.keycloak.org) to provide
|
||||||
|
modern identity protocols, check out Kanidm.
|
||||||
|
|
||||||
|
## I can't log in!
|
||||||
|
|
||||||
|
If you just set up the server, can get to the login page but the password you
|
||||||
|
set isn't working, try the following:
|
||||||
|
|
||||||
|
- (For docker): Make sure that the `/data` folder is persistent, either to a
|
||||||
|
docker volume or mounted from the host filesystem.
|
||||||
|
- Check if there is a `lldap_config.toml` file (either in `/data` for docker
|
||||||
|
or in the current directory). If there isn't, copy
|
||||||
|
`lldap_config.docker_template.toml` there, and fill in the various values
|
||||||
|
(passwords, secrets, ...).
|
||||||
|
- Check if there is a `users.db` file (either in `/data` for docker or where
|
||||||
|
you specified the DB URL, which defaults to the current directory). If
|
||||||
|
there isn't, check that the user running the command (user with ID 10001
|
||||||
|
for docker) has the rights to write to the `/data` folder. If in doubt, you
|
||||||
|
can `chmod 777 /data` (or whatever the folder) to make it world-writeable.
|
||||||
|
- Make sure you restart the server.
|
||||||
|
- If it's still not working, join the
|
||||||
|
[Discord server](https://discord.gg/h5PEdRMNyP) to ask for help.
|
||||||
|
|
||||||
## Contributions
|
## Contributions
|
||||||
|
|
||||||
|
|||||||
+12
-43
@@ -1,13 +1,13 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lldap_app"
|
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
|
||||||
version = "0.6.2"
|
|
||||||
description = "Frontend for LLDAP"
|
description = "Frontend for LLDAP"
|
||||||
edition.workspace = true
|
edition = "2021"
|
||||||
|
homepage = "https://github.com/lldap/lldap"
|
||||||
|
license = "GPL-3.0-only"
|
||||||
|
name = "lldap_app"
|
||||||
|
repository = "https://github.com/lldap/lldap"
|
||||||
|
version = "0.5.1-alpha"
|
||||||
include = ["src/**/*", "queries/**/*", "Cargo.toml", "../schema.graphql"]
|
include = ["src/**/*", "queries/**/*", "Cargo.toml", "../schema.graphql"]
|
||||||
authors.workspace = true
|
|
||||||
homepage.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
repository.workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
@@ -19,11 +19,12 @@ graphql_client = "0.10"
|
|||||||
http = "0.2"
|
http = "0.2"
|
||||||
jwt = "0.13"
|
jwt = "0.13"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
|
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 = "0.14"
|
validator_derive = "*"
|
||||||
wasm-bindgen = "0.2.100"
|
wasm-bindgen = "0.2"
|
||||||
wasm-bindgen-futures = "*"
|
wasm-bindgen-futures = "*"
|
||||||
yew = "0.19.3"
|
yew = "0.19.3"
|
||||||
yew-router = "0.16"
|
yew-router = "0.16"
|
||||||
@@ -36,11 +37,9 @@ version = "0.3"
|
|||||||
features = [
|
features = [
|
||||||
"Document",
|
"Document",
|
||||||
"Element",
|
"Element",
|
||||||
"Event",
|
|
||||||
"FileReader",
|
"FileReader",
|
||||||
"FormData",
|
"FormData",
|
||||||
"HtmlDocument",
|
"HtmlDocument",
|
||||||
"HtmlFormElement",
|
|
||||||
"HtmlInputElement",
|
"HtmlInputElement",
|
||||||
"HtmlOptionElement",
|
"HtmlOptionElement",
|
||||||
"HtmlOptionsCollection",
|
"HtmlOptionsCollection",
|
||||||
@@ -55,33 +54,15 @@ features = [
|
|||||||
"wasmbind"
|
"wasmbind"
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependencies.derive_more]
|
|
||||||
features = ["debug", "display", "from", "from_str"]
|
|
||||||
default-features = false
|
|
||||||
version = "1"
|
|
||||||
|
|
||||||
[dependencies.lldap_auth]
|
[dependencies.lldap_auth]
|
||||||
path = "../crates/auth"
|
path = "../auth"
|
||||||
features = [ "opaque_client" ]
|
features = [ "opaque_client" ]
|
||||||
|
|
||||||
[dependencies.lldap_frontend_options]
|
|
||||||
path = "../crates/frontend-options"
|
|
||||||
|
|
||||||
[dependencies.lldap_validation]
|
|
||||||
path = "../crates/validation"
|
|
||||||
|
|
||||||
[dependencies.image]
|
[dependencies.image]
|
||||||
features = ["jpeg"]
|
features = ["jpeg"]
|
||||||
default-features = false
|
default-features = false
|
||||||
version = "0.24"
|
version = "0.24"
|
||||||
|
|
||||||
[dependencies.serde]
|
|
||||||
workspace = true
|
|
||||||
|
|
||||||
[dependencies.strum]
|
|
||||||
features = ["derive"]
|
|
||||||
version = "0.25"
|
|
||||||
|
|
||||||
[dependencies.yew_form]
|
[dependencies.yew_form]
|
||||||
git = "https://github.com/jfbilodeau/yew_form"
|
git = "https://github.com/jfbilodeau/yew_form"
|
||||||
rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9"
|
rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9"
|
||||||
@@ -92,15 +73,3 @@ rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9"
|
|||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
crate-type = ["cdylib"]
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
[lints.rust]
|
|
||||||
unexpected_cfgs = { level = "warn", check-cfg = [
|
|
||||||
'cfg(wasm_bindgen_unstable_test_coverage)',
|
|
||||||
] }
|
|
||||||
|
|
||||||
[package.metadata.wasm-pack.profile.dev]
|
|
||||||
wasm-opt = ['--enable-bulk-memory']
|
|
||||||
[package.metadata.wasm-pack.profile.profiling]
|
|
||||||
wasm-opt = ['--enable-bulk-memory']
|
|
||||||
[package.metadata.wasm-pack.profile.release]
|
|
||||||
wasm-opt = ['--enable-bulk-memory']
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
mutation CreateGroup($group: CreateGroupInput!) {
|
mutation CreateGroup($name: String!) {
|
||||||
createGroupWithDetails(request: $group) {
|
createGroup(name: $name) {
|
||||||
id
|
id
|
||||||
displayName
|
displayName
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ query GetGroupAttributesSchema {
|
|||||||
isList
|
isList
|
||||||
isVisible
|
isVisible
|
||||||
isHardcoded
|
isHardcoded
|
||||||
isReadonly
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,22 +8,5 @@ query GetGroupDetails($id: Int!) {
|
|||||||
id
|
id
|
||||||
displayName
|
displayName
|
||||||
}
|
}
|
||||||
attributes {
|
|
||||||
name
|
|
||||||
value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
schema {
|
|
||||||
groupSchema {
|
|
||||||
attributes {
|
|
||||||
name
|
|
||||||
attributeType
|
|
||||||
isList
|
|
||||||
isVisible
|
|
||||||
isEditable
|
|
||||||
isHardcoded
|
|
||||||
isReadonly
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ query GetUserAttributesSchema {
|
|||||||
isVisible
|
isVisible
|
||||||
isEditable
|
isEditable
|
||||||
isHardcoded
|
isHardcoded
|
||||||
isReadonly
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,29 +2,28 @@ query GetUserDetails($id: String!) {
|
|||||||
user(userId: $id) {
|
user(userId: $id) {
|
||||||
id
|
id
|
||||||
email
|
email
|
||||||
avatar
|
|
||||||
displayName
|
displayName
|
||||||
|
firstName
|
||||||
|
lastName
|
||||||
|
avatar
|
||||||
creationDate
|
creationDate
|
||||||
uuid
|
uuid
|
||||||
groups {
|
groups {
|
||||||
id
|
id
|
||||||
displayName
|
displayName
|
||||||
}
|
}
|
||||||
attributes {
|
attributes {
|
||||||
name
|
name
|
||||||
value
|
value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
schema {
|
schema {
|
||||||
userSchema {
|
user_attrubutes {
|
||||||
attributes {
|
attributes {
|
||||||
name
|
name
|
||||||
attributeType
|
attributeType
|
||||||
isList
|
|
||||||
isVisible
|
|
||||||
isEditable
|
isEditable
|
||||||
isHardcoded
|
isHardcoded
|
||||||
isReadonly
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
mutation UpdateGroup($group: UpdateGroupInput!) {
|
|
||||||
updateGroup(group: $group) {
|
|
||||||
ok
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -155,13 +155,8 @@ impl Component for AddGroupMemberComponent {
|
|||||||
let to_add_user_list = self.get_selectable_user_list(ctx, user_list);
|
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={name} key={user.id} />
|
<SelectOption value={user.id.clone()} text={user.display_name.clone()} key={user.id} />
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
html! {
|
html! {
|
||||||
|
|||||||
+27
-37
@@ -21,16 +21,16 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use gloo_console::error;
|
use gloo_console::error;
|
||||||
use lldap_frontend_options::Options;
|
|
||||||
use yew::{
|
use yew::{
|
||||||
Context, function_component,
|
function_component,
|
||||||
html::Scope,
|
html::Scope,
|
||||||
prelude::{Component, Html, html},
|
prelude::{html, Component, Html},
|
||||||
|
Context,
|
||||||
};
|
};
|
||||||
use yew_router::{
|
use yew_router::{
|
||||||
BrowserRouter, Switch,
|
|
||||||
prelude::{History, Location},
|
prelude::{History, Location},
|
||||||
scope_ext::RouterScopeExt,
|
scope_ext::RouterScopeExt,
|
||||||
|
BrowserRouter, Switch,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[function_component(AppContainer)]
|
#[function_component(AppContainer)]
|
||||||
@@ -51,7 +51,7 @@ pub struct App {
|
|||||||
pub enum Msg {
|
pub enum Msg {
|
||||||
Login((String, bool)),
|
Login((String, bool)),
|
||||||
Logout,
|
Logout,
|
||||||
SettingsReceived(anyhow::Result<Options>),
|
PasswordResetProbeFinished(anyhow::Result<bool>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Component for App {
|
impl Component for App {
|
||||||
@@ -76,8 +76,9 @@ impl Component for App {
|
|||||||
redirect_to: Self::get_redirect_route(ctx),
|
redirect_to: Self::get_redirect_route(ctx),
|
||||||
password_reset_enabled: None,
|
password_reset_enabled: None,
|
||||||
};
|
};
|
||||||
ctx.link()
|
ctx.link().send_future(async move {
|
||||||
.send_future(async move { Msg::SettingsReceived(HostService::get_settings().await) });
|
Msg::PasswordResetProbeFinished(HostService::probe_password_reset().await)
|
||||||
|
});
|
||||||
app.apply_initial_redirections(ctx);
|
app.apply_initial_redirections(ctx);
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
@@ -102,11 +103,14 @@ impl Component for App {
|
|||||||
self.redirect_to = None;
|
self.redirect_to = None;
|
||||||
history.push(AppRoute::Login);
|
history.push(AppRoute::Login);
|
||||||
}
|
}
|
||||||
Msg::SettingsReceived(Ok(settings)) => {
|
Msg::PasswordResetProbeFinished(Ok(enabled)) => {
|
||||||
self.password_reset_enabled = Some(settings.password_reset_enabled);
|
self.password_reset_enabled = Some(enabled);
|
||||||
}
|
}
|
||||||
Msg::SettingsReceived(Err(err)) => {
|
Msg::PasswordResetProbeFinished(Err(err)) => {
|
||||||
error!(err.to_string());
|
self.password_reset_enabled = Some(false);
|
||||||
|
error!(&format!(
|
||||||
|
"Could not probe for password reset support: {err:#}"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
@@ -122,7 +126,7 @@ impl Component for App {
|
|||||||
<Banner is_admin={is_admin} username={username} on_logged_out={link.callback(|_| Msg::Logout)} />
|
<Banner is_admin={is_admin} username={username} on_logged_out={link.callback(|_| Msg::Logout)} />
|
||||||
<div class="container py-3 bg-kug">
|
<div class="container py-3 bg-kug">
|
||||||
<div class="row justify-content-center" style="padding-bottom: 80px;">
|
<div class="row justify-content-center" style="padding-bottom: 80px;">
|
||||||
<main class="py-3">
|
<main class="py-3" style="max-width: 1000px">
|
||||||
<Switch<AppRoute>
|
<Switch<AppRoute>
|
||||||
render={Switch::render(move |routes| Self::dispatch_route(routes, &link, is_admin, password_reset_enabled))}
|
render={Switch::render(move |routes| Self::dispatch_route(routes, &link, is_admin, password_reset_enabled))}
|
||||||
/>
|
/>
|
||||||
@@ -196,21 +200,15 @@ impl App {
|
|||||||
AppRoute::CreateUser => html! {
|
AppRoute::CreateUser => html! {
|
||||||
<CreateUserForm/>
|
<CreateUserForm/>
|
||||||
},
|
},
|
||||||
AppRoute::Index | AppRoute::ListUsers => {
|
AppRoute::Index | AppRoute::ListUsers => html! {
|
||||||
let user_button = html! {
|
<div>
|
||||||
|
<UserTable />
|
||||||
<Link classes="btn btn-primary" to={AppRoute::CreateUser}>
|
<Link classes="btn btn-primary" to={AppRoute::CreateUser}>
|
||||||
<i class="bi-person-plus me-2"></i>
|
<i class="bi-person-plus me-2"></i>
|
||||||
{"Create a user"}
|
{"Create a user"}
|
||||||
</Link>
|
</Link>
|
||||||
};
|
</div>
|
||||||
html! {
|
},
|
||||||
<div>
|
|
||||||
{ user_button.clone() }
|
|
||||||
<UserTable />
|
|
||||||
{ user_button }
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AppRoute::CreateGroup => html! {
|
AppRoute::CreateGroup => html! {
|
||||||
<CreateGroupForm/>
|
<CreateGroupForm/>
|
||||||
},
|
},
|
||||||
@@ -220,23 +218,15 @@ impl App {
|
|||||||
AppRoute::CreateGroupAttribute => html! {
|
AppRoute::CreateGroupAttribute => html! {
|
||||||
<CreateGroupAttributeForm/>
|
<CreateGroupAttributeForm/>
|
||||||
},
|
},
|
||||||
AppRoute::ListGroups => {
|
AppRoute::ListGroups => html! {
|
||||||
let group_button = html! {
|
<div>
|
||||||
|
<GroupTable />
|
||||||
<Link classes="btn btn-primary" to={AppRoute::CreateGroup}>
|
<Link classes="btn btn-primary" to={AppRoute::CreateGroup}>
|
||||||
<i class="bi-plus-circle me-2"></i>
|
<i class="bi-plus-circle me-2"></i>
|
||||||
{"Create a group"}
|
{"Create a group"}
|
||||||
</Link>
|
</Link>
|
||||||
};
|
</div>
|
||||||
// Note: There's a weird bug when switching from the users page to the groups page
|
},
|
||||||
// where the two groups buttons are at the bottom. I don't know why.
|
|
||||||
html! {
|
|
||||||
<div>
|
|
||||||
{ group_button.clone() }
|
|
||||||
<GroupTable />
|
|
||||||
{ group_button }
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AppRoute::ListUserSchema => html! {
|
AppRoute::ListUserSchema => html! {
|
||||||
<ListUserSchema />
|
<ListUserSchema />
|
||||||
},
|
},
|
||||||
@@ -244,7 +234,7 @@ impl App {
|
|||||||
<ListGroupSchema />
|
<ListGroupSchema />
|
||||||
},
|
},
|
||||||
AppRoute::GroupDetails { group_id } => html! {
|
AppRoute::GroupDetails { group_id } => html! {
|
||||||
<GroupDetails group_id={*group_id} is_admin={is_admin} />
|
<GroupDetails group_id={*group_id} />
|
||||||
},
|
},
|
||||||
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} />
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
use crate::infra::functional::{LoadableResult, use_graphql_call};
|
use crate::infra::functional::{use_graphql_call, LoadableResult};
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use yew::{Properties, function_component, html, virtual_dom::AttrValue};
|
use yew::{function_component, html, virtual_dom::AttrValue, Properties};
|
||||||
|
|
||||||
#[derive(GraphQLQuery)]
|
#[derive(GraphQLQuery)]
|
||||||
#[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"
|
||||||
)]
|
)]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use crate::components::{
|
|||||||
router::{AppRoute, Link},
|
router::{AppRoute, Link},
|
||||||
};
|
};
|
||||||
use wasm_bindgen::prelude::wasm_bindgen;
|
use wasm_bindgen::prelude::wasm_bindgen;
|
||||||
use yew::{Callback, Properties, function_component, html};
|
use yew::{function_component, html, Callback, Properties};
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct Props {
|
pub struct Props {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use crate::{
|
|||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{Result, anyhow, bail};
|
use anyhow::{anyhow, bail, Result};
|
||||||
use gloo_console::error;
|
use gloo_console::error;
|
||||||
use lldap_auth::*;
|
use lldap_auth::*;
|
||||||
use validator_derive::Validate;
|
use validator_derive::Validate;
|
||||||
|
|||||||
@@ -1,22 +1,11 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::{
|
components::{
|
||||||
form::{
|
form::{field::Field, submit::Submit},
|
||||||
attribute_input::{ListAttributeInput, SingleAttributeInput},
|
|
||||||
field::Field,
|
|
||||||
submit::Submit,
|
|
||||||
},
|
|
||||||
router::AppRoute,
|
router::AppRoute,
|
||||||
},
|
},
|
||||||
infra::{
|
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
|
||||||
form_utils::{
|
|
||||||
AttributeValue, EmailIsRequired, GraphQlAttributeSchema, IsAdmin,
|
|
||||||
read_all_form_attributes,
|
|
||||||
},
|
|
||||||
schema::AttributeType,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
use anyhow::{Result, ensure};
|
use anyhow::{bail, 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;
|
||||||
@@ -24,32 +13,6 @@ 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",
|
|
||||||
extern_enums("AttributeType")
|
|
||||||
)]
|
|
||||||
pub struct GetGroupAttributesSchema;
|
|
||||||
|
|
||||||
use get_group_attributes_schema::ResponseData;
|
|
||||||
|
|
||||||
pub type Attribute =
|
|
||||||
get_group_attributes_schema::GetGroupAttributesSchemaSchemaGroupSchemaAttributes;
|
|
||||||
|
|
||||||
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",
|
||||||
@@ -62,8 +25,6 @@ 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)]
|
||||||
@@ -74,7 +35,6 @@ 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>),
|
||||||
}
|
}
|
||||||
@@ -88,33 +48,12 @@ impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
|||||||
match msg {
|
match msg {
|
||||||
Msg::Update => Ok(true),
|
Msg::Update => Ok(true),
|
||||||
Msg::SubmitForm => {
|
Msg::SubmitForm => {
|
||||||
ensure!(self.form.validate(), "Check the form for errors");
|
if !self.form.validate() {
|
||||||
|
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 {
|
||||||
group: create_group::CreateGroupInput {
|
name: model.groupname,
|
||||||
displayName: model.groupname,
|
|
||||||
attributes,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
self.common.call_graphql::<CreateGroup, _>(
|
self.common.call_graphql::<CreateGroup, _>(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -127,16 +66,11 @@ impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
|||||||
Msg::CreateGroupResponse(response) => {
|
Msg::CreateGroupResponse(response) => {
|
||||||
log!(&format!(
|
log!(&format!(
|
||||||
"Created group '{}'",
|
"Created group '{}'",
|
||||||
&response?.create_group_with_details.display_name
|
&response?.create_group.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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,22 +83,11 @@ impl Component for CreateGroupForm {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = ();
|
type Properties = ();
|
||||||
|
|
||||||
fn create(ctx: &Context<Self>) -> Self {
|
fn create(_: &Context<Self>) -> Self {
|
||||||
let mut component = Self {
|
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 {
|
||||||
@@ -175,8 +98,7 @@ 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>
|
||||||
@@ -186,14 +108,6 @@ 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})} />
|
||||||
@@ -210,21 +124,3 @@ impl Component for CreateGroupForm {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_custom_attribute_input(attribute_schema: &Attribute) -> Html {
|
|
||||||
if attribute_schema.is_list {
|
|
||||||
html! {
|
|
||||||
<ListAttributeInput
|
|
||||||
name={attribute_schema.name.clone()}
|
|
||||||
attribute_type={attribute_schema.attribute_type}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
html! {
|
|
||||||
<SingleAttributeInput
|
|
||||||
name={attribute_schema.name.clone()}
|
|
||||||
attribute_type={attribute_schema.attribute_type}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,15 +3,15 @@ use crate::{
|
|||||||
form::{checkbox::CheckBox, field::Field, select::Select, submit::Submit},
|
form::{checkbox::CheckBox, field::Field, select::Select, submit::Submit},
|
||||||
router::AppRoute,
|
router::AppRoute,
|
||||||
},
|
},
|
||||||
|
convert_attribute_type,
|
||||||
infra::{
|
infra::{
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
schema::{AttributeType, validate_attribute_type},
|
schema::{validate_attribute_type, AttributeType},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{bail, Result};
|
||||||
use gloo_console::log;
|
use gloo_console::log;
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use lldap_validation::attributes::validate_attribute_name;
|
|
||||||
use validator_derive::Validate;
|
use validator_derive::Validate;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew_form_derive::Model;
|
use yew_form_derive::Model;
|
||||||
@@ -22,11 +22,12 @@ use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
|||||||
schema_path = "../schema.graphql",
|
schema_path = "../schema.graphql",
|
||||||
query_path = "queries/create_group_attribute.graphql",
|
query_path = "queries/create_group_attribute.graphql",
|
||||||
response_derives = "Debug",
|
response_derives = "Debug",
|
||||||
custom_scalars_module = "crate::infra::graphql",
|
custom_scalars_module = "crate::infra::graphql"
|
||||||
extern_enums("AttributeType")
|
|
||||||
)]
|
)]
|
||||||
pub struct CreateGroupAttribute;
|
pub struct CreateGroupAttribute;
|
||||||
|
|
||||||
|
convert_attribute_type!(create_group_attribute::AttributeType);
|
||||||
|
|
||||||
pub struct CreateGroupAttributeForm {
|
pub struct CreateGroupAttributeForm {
|
||||||
common: CommonComponentParts<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
form: yew_form::Form<CreateGroupAttributeModel>,
|
form: yew_form::Form<CreateGroupAttributeModel>,
|
||||||
@@ -61,18 +62,10 @@ impl CommonComponent<CreateGroupAttributeForm> for CreateGroupAttributeForm {
|
|||||||
bail!("Check the form for errors");
|
bail!("Check the form for errors");
|
||||||
}
|
}
|
||||||
let model = self.form.model();
|
let model = self.form.model();
|
||||||
validate_attribute_name(&model.attribute_name).or_else(|invalid_chars| {
|
let attribute_type = model.attribute_type.parse::<AttributeType>().unwrap();
|
||||||
let invalid = String::from_iter(invalid_chars);
|
|
||||||
bail!(
|
|
||||||
"Attribute name contains one or more invalid characters: {}",
|
|
||||||
invalid
|
|
||||||
);
|
|
||||||
})?;
|
|
||||||
let attribute_type =
|
|
||||||
AttributeType::try_from(model.attribute_type.as_str()).unwrap();
|
|
||||||
let req = create_group_attribute::Variables {
|
let req = create_group_attribute::Variables {
|
||||||
name: model.attribute_name,
|
name: model.attribute_name,
|
||||||
attribute_type,
|
attribute_type: create_group_attribute::AttributeType::from(attribute_type),
|
||||||
is_list: model.is_list,
|
is_list: model.is_list,
|
||||||
is_visible: model.is_visible,
|
is_visible: model.is_visible,
|
||||||
};
|
};
|
||||||
@@ -144,7 +137,7 @@ impl Component for CreateGroupAttributeForm {
|
|||||||
oninput={link.callback(|_| Msg::Update)}>
|
oninput={link.callback(|_| Msg::Update)}>
|
||||||
<option selected=true value="String">{"String"}</option>
|
<option selected=true value="String">{"String"}</option>
|
||||||
<option value="Integer">{"Integer"}</option>
|
<option value="Integer">{"Integer"}</option>
|
||||||
<option value="JpegPhoto">{"Jpeg"}</option>
|
<option value="Jpeg">{"Jpeg"}</option>
|
||||||
<option value="DateTime">{"DateTime"}</option>
|
<option value="DateTime">{"DateTime"}</option>
|
||||||
</Select<CreateGroupAttributeModel>>
|
</Select<CreateGroupAttributeModel>>
|
||||||
<CheckBox<CreateGroupAttributeModel>
|
<CheckBox<CreateGroupAttributeModel>
|
||||||
|
|||||||
@@ -1,23 +1,14 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::{
|
components::{
|
||||||
form::{
|
form::{field::Field, submit::Submit},
|
||||||
attribute_input::{ListAttributeInput, SingleAttributeInput},
|
|
||||||
field::Field,
|
|
||||||
submit::Submit,
|
|
||||||
},
|
|
||||||
router::AppRoute,
|
router::AppRoute,
|
||||||
},
|
},
|
||||||
infra::{
|
infra::{
|
||||||
api::HostService,
|
api::HostService,
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
form_utils::{
|
|
||||||
AttributeValue, EmailIsRequired, GraphQlAttributeSchema, IsAdmin,
|
|
||||||
read_all_form_attributes,
|
|
||||||
},
|
|
||||||
schema::AttributeType,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{Result, ensure};
|
use anyhow::{bail, 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};
|
||||||
@@ -26,31 +17,6 @@ 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",
|
|
||||||
extern_enums("AttributeType")
|
|
||||||
)]
|
|
||||||
pub struct GetUserAttributesSchema;
|
|
||||||
|
|
||||||
use get_user_attributes_schema::ResponseData;
|
|
||||||
|
|
||||||
pub type Attribute = get_user_attributes_schema::GetUserAttributesSchemaSchemaUserSchemaAttributes;
|
|
||||||
|
|
||||||
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",
|
||||||
@@ -63,14 +29,17 @@ 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)"
|
||||||
@@ -90,7 +59,6 @@ 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,
|
||||||
@@ -111,43 +79,21 @@ 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 => {
|
||||||
ensure!(self.form.validate(), "Check the form for errors");
|
if !self.form.validate() {
|
||||||
|
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: None,
|
email: model.email,
|
||||||
displayName: None,
|
displayName: to_option(model.display_name),
|
||||||
firstName: None,
|
firstName: to_option(model.first_name),
|
||||||
lastName: None,
|
lastName: to_option(model.last_name),
|
||||||
avatar: None,
|
avatar: None,
|
||||||
attributes,
|
attributes: None,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
self.common.call_graphql::<CreateUser, _>(
|
self.common.call_graphql::<CreateUser, _>(
|
||||||
@@ -231,20 +177,11 @@ impl Component for CreateUserForm {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = ();
|
type Properties = ();
|
||||||
|
|
||||||
fn create(ctx: &Context<Self>) -> Self {
|
fn create(_: &Context<Self>) -> Self {
|
||||||
let mut component = Self {
|
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 {
|
||||||
@@ -255,22 +192,38 @@ 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"
|
<form class="form py-3" style="max-width: 636px">
|
||||||
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>
|
||||||
self.attributes_schema
|
form={&self.form}
|
||||||
.iter()
|
required=true
|
||||||
.flatten()
|
label="Email"
|
||||||
.filter(|a| !a.is_readonly)
|
field_name="email"
|
||||||
.map(get_custom_attribute_input)
|
input_type="email"
|
||||||
.collect::<Vec<_>>()
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
}
|
<Field<CreateUserModel>
|
||||||
|
form={&self.form}
|
||||||
|
label="Display name"
|
||||||
|
field_name="display_name"
|
||||||
|
autocomplete="name"
|
||||||
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
|
<Field<CreateUserModel>
|
||||||
|
form={&self.form}
|
||||||
|
label="First name"
|
||||||
|
field_name="first_name"
|
||||||
|
autocomplete="given-name"
|
||||||
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
|
<Field<CreateUserModel>
|
||||||
|
form={&self.form}
|
||||||
|
label="Last name"
|
||||||
|
field_name="last_name"
|
||||||
|
autocomplete="family-name"
|
||||||
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
<Field<CreateUserModel>
|
<Field<CreateUserModel>
|
||||||
form={&self.form}
|
form={&self.form}
|
||||||
label="Password"
|
label="Password"
|
||||||
@@ -302,21 +255,3 @@ impl Component for CreateUserForm {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_custom_attribute_input(attribute_schema: &Attribute) -> Html {
|
|
||||||
if attribute_schema.is_list {
|
|
||||||
html! {
|
|
||||||
<ListAttributeInput
|
|
||||||
name={attribute_schema.name.clone()}
|
|
||||||
attribute_type={attribute_schema.attribute_type}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
html! {
|
|
||||||
<SingleAttributeInput
|
|
||||||
name={attribute_schema.name.clone()}
|
|
||||||
attribute_type={attribute_schema.attribute_type}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,15 +3,15 @@ use crate::{
|
|||||||
form::{checkbox::CheckBox, field::Field, select::Select, submit::Submit},
|
form::{checkbox::CheckBox, field::Field, select::Select, submit::Submit},
|
||||||
router::AppRoute,
|
router::AppRoute,
|
||||||
},
|
},
|
||||||
|
convert_attribute_type,
|
||||||
infra::{
|
infra::{
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
schema::{AttributeType, validate_attribute_type},
|
schema::{validate_attribute_type, AttributeType},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{bail, Result};
|
||||||
use gloo_console::log;
|
use gloo_console::log;
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use lldap_validation::attributes::validate_attribute_name;
|
|
||||||
use validator_derive::Validate;
|
use validator_derive::Validate;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew_form_derive::Model;
|
use yew_form_derive::Model;
|
||||||
@@ -22,11 +22,12 @@ use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
|||||||
schema_path = "../schema.graphql",
|
schema_path = "../schema.graphql",
|
||||||
query_path = "queries/create_user_attribute.graphql",
|
query_path = "queries/create_user_attribute.graphql",
|
||||||
response_derives = "Debug",
|
response_derives = "Debug",
|
||||||
custom_scalars_module = "crate::infra::graphql",
|
custom_scalars_module = "crate::infra::graphql"
|
||||||
extern_enums("AttributeType")
|
|
||||||
)]
|
)]
|
||||||
pub struct CreateUserAttribute;
|
pub struct CreateUserAttribute;
|
||||||
|
|
||||||
|
convert_attribute_type!(create_user_attribute::AttributeType);
|
||||||
|
|
||||||
pub struct CreateUserAttributeForm {
|
pub struct CreateUserAttributeForm {
|
||||||
common: CommonComponentParts<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
form: yew_form::Form<CreateUserAttributeModel>,
|
form: yew_form::Form<CreateUserAttributeModel>,
|
||||||
@@ -65,18 +66,10 @@ impl CommonComponent<CreateUserAttributeForm> for CreateUserAttributeForm {
|
|||||||
if model.is_editable && !model.is_visible {
|
if model.is_editable && !model.is_visible {
|
||||||
bail!("Editable attributes must also be visible");
|
bail!("Editable attributes must also be visible");
|
||||||
}
|
}
|
||||||
validate_attribute_name(&model.attribute_name).or_else(|invalid_chars| {
|
let attribute_type = model.attribute_type.parse::<AttributeType>().unwrap();
|
||||||
let invalid = String::from_iter(invalid_chars);
|
|
||||||
bail!(
|
|
||||||
"Attribute name contains one or more invalid characters: {}",
|
|
||||||
invalid
|
|
||||||
);
|
|
||||||
})?;
|
|
||||||
let attribute_type =
|
|
||||||
AttributeType::try_from(model.attribute_type.as_str()).unwrap();
|
|
||||||
let req = create_user_attribute::Variables {
|
let req = create_user_attribute::Variables {
|
||||||
name: model.attribute_name,
|
name: model.attribute_name,
|
||||||
attribute_type,
|
attribute_type: create_user_attribute::AttributeType::from(attribute_type),
|
||||||
is_editable: model.is_editable,
|
is_editable: model.is_editable,
|
||||||
is_list: model.is_list,
|
is_list: model.is_list,
|
||||||
is_visible: model.is_visible,
|
is_visible: model.is_visible,
|
||||||
@@ -146,7 +139,7 @@ impl Component for CreateUserAttributeForm {
|
|||||||
oninput={link.callback(|_| Msg::Update)}>
|
oninput={link.callback(|_| Msg::Update)}>
|
||||||
<option selected=true value="String">{"String"}</option>
|
<option selected=true value="String">{"String"}</option>
|
||||||
<option value="Integer">{"Integer"}</option>
|
<option value="Integer">{"Integer"}</option>
|
||||||
<option value="JpegPhoto">{"Jpeg"}</option>
|
<option value="Jpeg">{"Jpeg"}</option>
|
||||||
<option value="DateTime">{"DateTime"}</option>
|
<option value="DateTime">{"DateTime"}</option>
|
||||||
</Select<CreateUserAttributeModel>>
|
</Select<CreateUserAttributeModel>>
|
||||||
<CheckBox<CreateUserAttributeModel>
|
<CheckBox<CreateUserAttributeModel>
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
use crate::{
|
use yew::{function_component, html, virtual_dom::AttrValue, Callback, InputEvent, Properties, NodeRef};
|
||||||
components::form::{date_input::DateTimeInput, file_input::JpegFileInput},
|
use crate::infra::schema::AttributeType;
|
||||||
infra::{schema::AttributeType, tooltip::Tooltip},
|
|
||||||
};
|
/*
|
||||||
use web_sys::Element;
|
<input
|
||||||
use yew::{
|
ref={&ctx.props().input_ref}
|
||||||
Component, Context, Html, Properties, function_component, html, use_effect_with_deps,
|
type="text"
|
||||||
use_node_ref, virtual_dom::AttrValue,
|
class="input-component"
|
||||||
};
|
placeholder={placeholder}
|
||||||
|
onmouseover={ctx.link().callback(|_| Msg::Hover)}
|
||||||
|
/>
|
||||||
|
*/
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
struct AttributeInputProps {
|
struct AttributeInputProps {
|
||||||
@@ -21,68 +24,27 @@ 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 => {
|
AttributeType::DateTime => "datetime-local",
|
||||||
return html! {
|
AttributeType::Jpeg => "file",
|
||||||
<DateTimeInput name={props.name.clone()} value={props.value.clone()} />
|
};
|
||||||
};
|
let accept = match props.attribute_type {
|
||||||
}
|
AttributeType::Jpeg => Some("image/jpeg"),
|
||||||
AttributeType::JpegPhoto => {
|
_ => None,
|
||||||
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: AttrValue,
|
||||||
pub(crate) attribute_type: AttributeType,
|
pub attribute_type: AttributeType,
|
||||||
#[prop_or(None)]
|
#[prop_or(None)]
|
||||||
pub value: Option<String>,
|
pub value: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -91,100 +53,16 @@ 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">
|
||||||
<AttributeLabel name={props.name.clone()} />
|
<label for={props.name.clone()}
|
||||||
|
class="form-label col-4 col-form-label">
|
||||||
|
{&props.name}{":"}
|
||||||
|
</label>
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<AttributeInput
|
<AttributeInput
|
||||||
attribute_type={props.attribute_type}
|
attribute_type={props.attribute_type}
|
||||||
name={props.name.clone()}
|
name={props.name}
|
||||||
value={props.value.clone()} />
|
value={props.value} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
|
||||||
pub struct ListAttributeInputProps {
|
|
||||||
pub name: String,
|
|
||||||
pub(crate) 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}
|
|
||||||
name={props.name.clone()}
|
|
||||||
value={props.values.get(i).cloned().unwrap_or_default()} />
|
|
||||||
<button
|
|
||||||
class="btn btn-danger"
|
|
||||||
type="button"
|
|
||||||
onclick={link.callback(move |_| ListAttributeInputMsg::Remove(i))}>
|
|
||||||
<i class="bi-x-circle-fill" aria-label="Remove value" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}).collect::<Html>()}
|
|
||||||
<button
|
|
||||||
class="btn btn-secondary"
|
|
||||||
type="button"
|
|
||||||
onclick={link.callback(|_| ListAttributeInputMsg::Append)}>
|
|
||||||
<i class="bi-plus-circle me-2"></i>
|
|
||||||
{"Add value"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use yew::{Callback, Properties, function_component, html, virtual_dom::AttrValue};
|
use yew::{function_component, html, virtual_dom::AttrValue, Callback, Properties};
|
||||||
use yew_form::{Form, Model};
|
use yew_form::{Form, Model};
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
|
||||||
use wasm_bindgen::JsCast;
|
|
||||||
use web_sys::HtmlInputElement;
|
|
||||||
use yew::{Event, Properties, function_component, html, use_state, virtual_dom::AttrValue};
|
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
|
||||||
pub struct DateTimeInputProps {
|
|
||||||
pub name: AttrValue,
|
|
||||||
pub value: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[function_component(DateTimeInput)]
|
|
||||||
pub fn date_time_input(props: &DateTimeInputProps) -> Html {
|
|
||||||
let value = use_state(|| {
|
|
||||||
props
|
|
||||||
.value
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|x| DateTime::<Utc>::from_str(x).ok())
|
|
||||||
});
|
|
||||||
|
|
||||||
html! {
|
|
||||||
<div class="input-group">
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name={props.name.clone()}
|
|
||||||
value={value.as_ref().map(|v: &DateTime<Utc>| v.to_rfc3339())} />
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
step="1"
|
|
||||||
class="form-control"
|
|
||||||
value={value.as_ref().map(|v: &DateTime<Utc>| v.naive_utc().to_string())}
|
|
||||||
onchange={move |e: Event| {
|
|
||||||
let string_val =
|
|
||||||
e.target()
|
|
||||||
.expect("Event should have target")
|
|
||||||
.unchecked_into::<HtmlInputElement>()
|
|
||||||
.value();
|
|
||||||
value.set(
|
|
||||||
NaiveDateTime::from_str(&string_val)
|
|
||||||
.ok()
|
|
||||||
.map(|x| DateTime::from_naive_utc_and_offset(x, Utc))
|
|
||||||
)
|
|
||||||
}} />
|
|
||||||
<span class="input-group-text">{"UTC"}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use yew::{Callback, InputEvent, Properties, function_component, html, virtual_dom::AttrValue};
|
use yew::{function_component, html, virtual_dom::AttrValue, Callback, InputEvent, Properties};
|
||||||
use yew_form::{Form, Model};
|
use yew_form::{Form, Model};
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
use std::{fmt::Display, str::FromStr};
|
|
||||||
|
|
||||||
use anyhow::{Error, Ok, Result, bail};
|
|
||||||
use gloo_file::{
|
|
||||||
File,
|
|
||||||
callbacks::{FileReader, read_as_bytes},
|
|
||||||
};
|
|
||||||
use web_sys::{FileList, HtmlInputElement, InputEvent};
|
|
||||||
use yew::Properties;
|
|
||||||
use yew::{prelude::*, virtual_dom::AttrValue};
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct JsFile {
|
|
||||||
file: Option<File>,
|
|
||||||
contents: Option<Vec<u8>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Display for JsFile {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
write!(
|
|
||||||
f,
|
|
||||||
"{}",
|
|
||||||
self.file.as_ref().map(File::name).unwrap_or_default()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromStr for JsFile {
|
|
||||||
type Err = Error;
|
|
||||||
|
|
||||||
fn from_str(s: &str) -> Result<Self> {
|
|
||||||
if s.is_empty() {
|
|
||||||
Ok(JsFile::default())
|
|
||||||
} else {
|
|
||||||
bail!("Building file from non-empty string")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn to_base64(file: &JsFile) -> Result<String> {
|
|
||||||
match file {
|
|
||||||
JsFile {
|
|
||||||
file: None,
|
|
||||||
contents: None,
|
|
||||||
} => Ok(String::new()),
|
|
||||||
JsFile {
|
|
||||||
file: Some(_),
|
|
||||||
contents: None,
|
|
||||||
} => bail!("Image file hasn't finished loading, try again"),
|
|
||||||
JsFile {
|
|
||||||
file: Some(_),
|
|
||||||
contents: Some(data),
|
|
||||||
} => {
|
|
||||||
if !is_valid_jpeg(data.as_slice()) {
|
|
||||||
bail!("Chosen image is not a valid JPEG");
|
|
||||||
}
|
|
||||||
Ok(base64::encode(data))
|
|
||||||
}
|
|
||||||
JsFile {
|
|
||||||
file: None,
|
|
||||||
contents: Some(data),
|
|
||||||
} => Ok(base64::encode(data)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A [yew::Component] to display the user details, with a form allowing to edit them.
|
|
||||||
pub struct JpegFileInput {
|
|
||||||
// None means that the avatar hasn't changed.
|
|
||||||
avatar: Option<JsFile>,
|
|
||||||
reader: Option<FileReader>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum Msg {
|
|
||||||
Update,
|
|
||||||
/// A new file was selected.
|
|
||||||
FileSelected(File),
|
|
||||||
/// The "Clear" button for the avatar was clicked.
|
|
||||||
ClearClicked,
|
|
||||||
/// A picked file finished loading.
|
|
||||||
FileLoaded(String, Result<Vec<u8>>),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Properties, Clone, PartialEq, Eq)]
|
|
||||||
pub struct Props {
|
|
||||||
pub name: AttrValue,
|
|
||||||
pub value: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Component for JpegFileInput {
|
|
||||||
type Message = Msg;
|
|
||||||
type Properties = Props;
|
|
||||||
|
|
||||||
fn create(ctx: &Context<Self>) -> Self {
|
|
||||||
Self {
|
|
||||||
avatar: Some(JsFile {
|
|
||||||
file: None,
|
|
||||||
contents: ctx
|
|
||||||
.props()
|
|
||||||
.value
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|x| base64::decode(x).ok()),
|
|
||||||
}),
|
|
||||||
reader: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn changed(&mut self, ctx: &Context<Self>) -> bool {
|
|
||||||
self.avatar = Some(JsFile {
|
|
||||||
file: None,
|
|
||||||
contents: ctx
|
|
||||||
.props()
|
|
||||||
.value
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|x| base64::decode(x).ok()),
|
|
||||||
});
|
|
||||||
self.reader = None;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
|
||||||
match msg {
|
|
||||||
Msg::Update => true,
|
|
||||||
Msg::FileSelected(new_avatar) => {
|
|
||||||
if self
|
|
||||||
.avatar
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|f| f.file.as_ref().map(|f| f.name()))
|
|
||||||
!= Some(new_avatar.name())
|
|
||||||
{
|
|
||||||
let file_name = new_avatar.name();
|
|
||||||
let link = ctx.link().clone();
|
|
||||||
self.reader = Some(read_as_bytes(&new_avatar, move |res| {
|
|
||||||
link.send_message(Msg::FileLoaded(
|
|
||||||
file_name,
|
|
||||||
res.map_err(|e| anyhow::anyhow!("{:#}", e)),
|
|
||||||
))
|
|
||||||
}));
|
|
||||||
self.avatar = Some(JsFile {
|
|
||||||
file: Some(new_avatar),
|
|
||||||
contents: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
Msg::ClearClicked => {
|
|
||||||
self.avatar = Some(JsFile::default());
|
|
||||||
true
|
|
||||||
}
|
|
||||||
Msg::FileLoaded(file_name, data) => {
|
|
||||||
if let Some(avatar) = &mut self.avatar {
|
|
||||||
if let Some(file) = &avatar.file {
|
|
||||||
if file.name() == file_name {
|
|
||||||
if let Result::Ok(data) = data {
|
|
||||||
if !is_valid_jpeg(data.as_slice()) {
|
|
||||||
// Clear the selection.
|
|
||||||
self.avatar = Some(JsFile::default());
|
|
||||||
// TODO: bail!("Chosen image is not a valid JPEG");
|
|
||||||
} else {
|
|
||||||
avatar.contents = Some(data);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.reader = None;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
|
||||||
let link = &ctx.link();
|
|
||||||
|
|
||||||
let avatar_string = match &self.avatar {
|
|
||||||
Some(avatar) => {
|
|
||||||
let avatar_base64 = to_base64(avatar);
|
|
||||||
avatar_base64.as_deref().unwrap_or("").to_owned()
|
|
||||||
}
|
|
||||||
None => String::new(),
|
|
||||||
};
|
|
||||||
html! {
|
|
||||||
<div class="row align-items-center">
|
|
||||||
<div class="col-5">
|
|
||||||
<input type="hidden" name={ctx.props().name.clone()} value={avatar_string.clone()} />
|
|
||||||
<input
|
|
||||||
class="form-control"
|
|
||||||
id="avatarInput"
|
|
||||||
type="file"
|
|
||||||
accept="image/jpeg"
|
|
||||||
oninput={link.callback(|e: InputEvent| {
|
|
||||||
let input: HtmlInputElement = e.target_unchecked_into();
|
|
||||||
Self::upload_files(input.files())
|
|
||||||
})} />
|
|
||||||
</div>
|
|
||||||
<div class="col-3">
|
|
||||||
<button
|
|
||||||
class="btn btn-secondary col-auto"
|
|
||||||
id="avatarClear"
|
|
||||||
type="button"
|
|
||||||
onclick={link.callback(|_| {Msg::ClearClicked})}>
|
|
||||||
{"Clear"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
{
|
|
||||||
if !avatar_string.is_empty() {
|
|
||||||
html!{
|
|
||||||
<img
|
|
||||||
id="avatarDisplay"
|
|
||||||
src={format!("data:image/jpeg;base64, {}", avatar_string)}
|
|
||||||
style="max-height:128px;max-width:128px;height:auto;width:auto;"
|
|
||||||
alt="Avatar" />
|
|
||||||
}
|
|
||||||
} else { html! {} }
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl JpegFileInput {
|
|
||||||
fn upload_files(files: Option<FileList>) -> Msg {
|
|
||||||
match files {
|
|
||||||
Some(files) if files.length() > 0 => {
|
|
||||||
Msg::FileSelected(File::from(files.item(0).unwrap()))
|
|
||||||
}
|
|
||||||
Some(_) | None => Msg::Update,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_valid_jpeg(bytes: &[u8]) -> bool {
|
|
||||||
image::io::Reader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg)
|
|
||||||
.decode()
|
|
||||||
.is_ok()
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
pub mod attribute_input;
|
pub mod 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,5 +1,5 @@
|
|||||||
use yew::{
|
use yew::{
|
||||||
Callback, Children, InputEvent, Properties, function_component, html, virtual_dom::AttrValue,
|
function_component, html, virtual_dom::AttrValue, Callback, Children, InputEvent, Properties,
|
||||||
};
|
};
|
||||||
use yew_form::{Form, Model};
|
use yew_form::{Form, Model};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use yew::{Children, Properties, function_component, html, virtual_dom::AttrValue};
|
use yew::{function_component, html, virtual_dom::AttrValue, Children, Properties};
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct Props {
|
pub struct Props {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use web_sys::MouseEvent;
|
use web_sys::MouseEvent;
|
||||||
use yew::{Callback, Children, Properties, function_component, html, virtual_dom::AttrValue};
|
use yew::{function_component, html, virtual_dom::AttrValue, Callback, Children, Properties};
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct Props {
|
pub struct Props {
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
use crate::infra::attributes::AttributeDescription;
|
|
||||||
use lldap_validation::attributes::{ALLOWED_CHARACTERS_DESCRIPTION, validate_attribute_name};
|
|
||||||
use yew::{Html, html};
|
|
||||||
|
|
||||||
fn render_attribute_aliases(attribute_description: &AttributeDescription) -> Html {
|
|
||||||
if attribute_description.aliases.is_empty() {
|
|
||||||
html! {}
|
|
||||||
} else {
|
|
||||||
html! {
|
|
||||||
<>
|
|
||||||
<br/>
|
|
||||||
<small class="text-muted">
|
|
||||||
{"Aliases: "}
|
|
||||||
{attribute_description.aliases.join(", ")}
|
|
||||||
</small>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_attribute_validation_warnings(attribute_name: &str) -> Html {
|
|
||||||
match validate_attribute_name(attribute_name) {
|
|
||||||
Ok(()) => {
|
|
||||||
html! {}
|
|
||||||
}
|
|
||||||
Err(_invalid_chars) => {
|
|
||||||
html! {
|
|
||||||
<>
|
|
||||||
<br/>
|
|
||||||
<small class="text-warning">
|
|
||||||
{"Warning: This attribute uses one or more invalid characters "}
|
|
||||||
{"("}{ALLOWED_CHARACTERS_DESCRIPTION}{"). "}
|
|
||||||
{"Some clients may not support it."}
|
|
||||||
</small>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn render_attribute_name(
|
|
||||||
hardcoded: bool,
|
|
||||||
attribute_description: &AttributeDescription,
|
|
||||||
) -> Html {
|
|
||||||
html! {
|
|
||||||
<>
|
|
||||||
{&attribute_description.attribute_name}
|
|
||||||
{if hardcoded {render_attribute_aliases(attribute_description)} else {html!{}}}
|
|
||||||
{render_attribute_validation_warnings(attribute_description.attribute_name)}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
pub mod attribute_schema;
|
|
||||||
@@ -1,17 +1,12 @@
|
|||||||
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::{
|
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
|
||||||
form_utils::GraphQlAttributeSchema,
|
|
||||||
schema::AttributeType,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
use anyhow::{Error, Result, bail};
|
use anyhow::{bail, Error, Result};
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
|
|
||||||
@@ -20,33 +15,19 @@ use yew::prelude::*;
|
|||||||
schema_path = "../schema.graphql",
|
schema_path = "../schema.graphql",
|
||||||
query_path = "queries/get_group_details.graphql",
|
query_path = "queries/get_group_details.graphql",
|
||||||
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"
|
||||||
extern_enums("AttributeType")
|
|
||||||
)]
|
)]
|
||||||
pub struct GetGroupDetails;
|
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;
|
|
||||||
|
|
||||||
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_and_schema: Option<(Group, Vec<AttributeSchema>)>,
|
group: Option<Group>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// State machine describing the possible transitions of the component state.
|
/// State machine describing the possible transitions of the component state.
|
||||||
@@ -57,13 +38,11 @@ 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 {
|
||||||
@@ -90,16 +69,41 @@ impl GroupDetails {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_details(&self, ctx: &Context<Self>, g: &Group, schema: Vec<AttributeSchema>) -> Html {
|
fn view_details(&self, g: &Group) -> Html {
|
||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
<h3>{g.display_name.to_string()}</h3>
|
<h3>{g.display_name.to_string()}</h3>
|
||||||
<GroupDetailsForm
|
<div class="py-3">
|
||||||
group={g.clone()}
|
<form class="form">
|
||||||
group_attributes_schema={schema}
|
<div class="form-group row mb-3">
|
||||||
is_admin={ctx.props().is_admin}
|
<label for="displayName"
|
||||||
on_display_name_updated={ctx.link().callback(|_| Msg::DisplayNameUpdated)}
|
class="form-label col-4 col-form-label">
|
||||||
/>
|
{"Group: "}
|
||||||
|
</label>
|
||||||
|
<div class="col-8">
|
||||||
|
<span id="groupId" class="form-constrol-static">{g.display_name.to_string()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group row mb-3">
|
||||||
|
<label for="creationDate"
|
||||||
|
class="form-label col-4 col-form-label">
|
||||||
|
{"Creation date: "}
|
||||||
|
</label>
|
||||||
|
<div class="col-8">
|
||||||
|
<span id="creationDate" class="form-constrol-static">{g.creation_date.naive_local().date()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group row mb-3">
|
||||||
|
<label for="uuid"
|
||||||
|
class="form-label col-4 col-form-label">
|
||||||
|
{"UUID: "}
|
||||||
|
</label>
|
||||||
|
<div class="col-8">
|
||||||
|
<span id="uuid" class="form-constrol-static">{g.uuid.to_string()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,38 +182,29 @@ impl GroupDetails {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<GroupDetails> for GroupDetails {
|
impl CommonComponent<GroupDetails> for GroupDetails {
|
||||||
fn handle_msg(
|
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||||
&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) => {
|
Ok(group) => self.group = Some(group.group),
|
||||||
self.group_and_schema =
|
|
||||||
Some((group.group, group.schema.group_schema.attributes))
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
self.group_and_schema = None;
|
self.group = 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_and_schema.as_mut().unwrap().0.users.push(User {
|
self.group.as_mut().unwrap().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_and_schema
|
self.group
|
||||||
.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)
|
||||||
}
|
}
|
||||||
@@ -226,7 +221,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_and_schema: None,
|
group: None,
|
||||||
};
|
};
|
||||||
table.get_group_details(ctx);
|
table.get_group_details(ctx);
|
||||||
table
|
table
|
||||||
@@ -237,15 +232,15 @@ impl Component for GroupDetails {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
match (&self.group_and_schema, &self.common.error) {
|
match (&self.group, &self.common.error) {
|
||||||
(None, None) => html! {{"Loading..."}},
|
(None, None) => html! {{"Loading..."}},
|
||||||
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||||
(Some((group, schema)), error) => {
|
(Some(u), error) => {
|
||||||
html! {
|
html! {
|
||||||
<div>
|
<div>
|
||||||
{self.view_details(ctx, group, schema.clone())}
|
{self.view_details(u)}
|
||||||
{self.view_user_list(ctx, group)}
|
{self.view_user_list(ctx, u)}
|
||||||
{self.view_add_user_button(ctx, group)}
|
{self.view_add_user_button(ctx, u)}
|
||||||
{self.view_messages(error)}
|
{self.view_messages(error)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,271 +0,0 @@
|
|||||||
use crate::{
|
|
||||||
components::{
|
|
||||||
form::{
|
|
||||||
attribute_input::{ListAttributeInput, SingleAttributeInput},
|
|
||||||
static_value::StaticValue,
|
|
||||||
submit::Submit,
|
|
||||||
},
|
|
||||||
group_details::{Attribute, AttributeSchema, Group},
|
|
||||||
},
|
|
||||||
infra::{
|
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
|
||||||
form_utils::{AttributeValue, EmailIsRequired, IsAdmin, read_all_form_attributes},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
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={attribute_schema.attribute_type}
|
|
||||||
values={values}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
html! {
|
|
||||||
<SingleAttributeInput
|
|
||||||
name={attribute_schema.name.clone()}
|
|
||||||
attribute_type={attribute_schema.attribute_type}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::{
|
components::{
|
||||||
delete_group_attribute::DeleteGroupAttribute,
|
delete_group_attribute::DeleteGroupAttribute,
|
||||||
fragments::attribute_schema::render_attribute_name,
|
|
||||||
router::{AppRoute, Link},
|
router::{AppRoute, Link},
|
||||||
},
|
},
|
||||||
|
convert_attribute_type,
|
||||||
infra::{
|
infra::{
|
||||||
attributes::group,
|
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
schema::AttributeType,
|
schema::AttributeType,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{Error, Result, anyhow};
|
use anyhow::{anyhow, Error, Result};
|
||||||
use gloo_console::log;
|
use gloo_console::log;
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
@@ -20,8 +19,7 @@ use yew::prelude::*;
|
|||||||
schema_path = "../schema.graphql",
|
schema_path = "../schema.graphql",
|
||||||
query_path = "queries/get_group_attributes_schema.graphql",
|
query_path = "queries/get_group_attributes_schema.graphql",
|
||||||
response_derives = "Debug,Clone,PartialEq,Eq",
|
response_derives = "Debug,Clone,PartialEq,Eq",
|
||||||
custom_scalars_module = "crate::infra::graphql",
|
custom_scalars_module = "crate::infra::graphql"
|
||||||
extern_enums("AttributeType")
|
|
||||||
)]
|
)]
|
||||||
pub struct GetGroupAttributesSchema;
|
pub struct GetGroupAttributesSchema;
|
||||||
|
|
||||||
@@ -30,6 +28,8 @@ use get_group_attributes_schema::ResponseData;
|
|||||||
pub type Attribute =
|
pub type Attribute =
|
||||||
get_group_attributes_schema::GetGroupAttributesSchemaSchemaGroupSchemaAttributes;
|
get_group_attributes_schema::GetGroupAttributesSchemaSchemaGroupSchemaAttributes;
|
||||||
|
|
||||||
|
convert_attribute_type!(get_group_attributes_schema::AttributeType);
|
||||||
|
|
||||||
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
||||||
pub struct Props {
|
pub struct Props {
|
||||||
pub hardcoded: bool,
|
pub hardcoded: bool,
|
||||||
@@ -55,21 +55,21 @@ impl CommonComponent<GroupSchemaTable> for GroupSchemaTable {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
Msg::OnError(e) => Err(e),
|
Msg::OnError(e) => Err(e),
|
||||||
Msg::OnAttributeDeleted(attribute_name) => match self.attributes {
|
Msg::OnAttributeDeleted(attribute_name) => {
|
||||||
None => {
|
match self.attributes {
|
||||||
log!(format!(
|
None => {
|
||||||
"Attribute {attribute_name} was deleted but component has no attributes"
|
log!(format!("Attribute {attribute_name} was deleted but component has no attributes"));
|
||||||
));
|
Err(anyhow!("invalid state"))
|
||||||
Err(anyhow!("invalid state"))
|
}
|
||||||
|
Some(_) => {
|
||||||
|
self.attributes
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.retain(|a| a.name != attribute_name);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Some(_) => {
|
}
|
||||||
self.attributes
|
|
||||||
.as_mut()
|
|
||||||
.unwrap()
|
|
||||||
.retain(|a| a.name != attribute_name);
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,17 +145,16 @@ impl GroupSchemaTable {
|
|||||||
|
|
||||||
fn view_attribute(&self, ctx: &Context<Self>, attribute: &Attribute) -> Html {
|
fn view_attribute(&self, ctx: &Context<Self>, attribute: &Attribute) -> Html {
|
||||||
let link = ctx.link();
|
let link = ctx.link();
|
||||||
let attribute_type = attribute.attribute_type;
|
let attribute_type = AttributeType::from(attribute.attribute_type.clone());
|
||||||
let checkmark = html! {
|
let checkmark = html! {
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check" viewBox="0 0 16 16">
|
||||||
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z"></path>
|
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
};
|
};
|
||||||
let hardcoded = ctx.props().hardcoded;
|
let hardcoded = ctx.props().hardcoded;
|
||||||
let desc = group::resolve_group_attribute_description_or_default(&attribute.name);
|
|
||||||
html! {
|
html! {
|
||||||
<tr key={attribute.name.clone()}>
|
<tr key={attribute.name.clone()}>
|
||||||
<td>{render_attribute_name(hardcoded, &desc)}</td>
|
<td>{&attribute.name}</td>
|
||||||
<td>{if attribute.is_list { format!("List<{attribute_type}>")} else {attribute_type.to_string()}}</td>
|
<td>{if attribute.is_list { format!("List<{attribute_type}>")} else {attribute_type.to_string()}}</td>
|
||||||
<td>{if attribute.is_visible {checkmark.clone()} else {html!{}}}</td>
|
<td>{if attribute.is_visible {checkmark.clone()} else {html!{}}}</td>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use crate::{
|
|||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{Result, anyhow, bail};
|
use anyhow::{anyhow, bail, Result};
|
||||||
use gloo_console::error;
|
use gloo_console::error;
|
||||||
use lldap_auth::*;
|
use lldap_auth::*;
|
||||||
use validator_derive::Validate;
|
use validator_derive::Validate;
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ pub mod delete_group_attribute;
|
|||||||
pub mod delete_user;
|
pub mod delete_user;
|
||||||
pub mod delete_user_attribute;
|
pub mod delete_user_attribute;
|
||||||
pub mod form;
|
pub mod form;
|
||||||
pub mod fragments;
|
|
||||||
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;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use crate::{
|
|||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{bail, Result};
|
||||||
use validator_derive::Validate;
|
use validator_derive::Validate;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew_form::Form;
|
use yew_form::Form;
|
||||||
@@ -104,11 +104,7 @@ impl Component for ResetPasswordStep1Form {
|
|||||||
</div>
|
</div>
|
||||||
{ if self.just_succeeded {
|
{ if self.just_succeeded {
|
||||||
html! {
|
html! {
|
||||||
{"If a user with this username or email exists, a password reset email will \
|
{"A reset token has been sent to your email."}
|
||||||
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! {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use crate::{
|
|||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{bail, Result};
|
||||||
use lldap_auth::{
|
use lldap_auth::{
|
||||||
opaque::client::registration as opaque_registration,
|
opaque::client::registration as opaque_registration,
|
||||||
password_reset::ServerPasswordResetResponse, registration,
|
password_reset::ServerPasswordResetResponse, registration,
|
||||||
@@ -148,7 +148,7 @@ impl Component for ResetPasswordStep2Form {
|
|||||||
(None, None) => {
|
(None, None) => {
|
||||||
return html! {
|
return html! {
|
||||||
{"Validating token"}
|
{"Validating token"}
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
(None, Some(e)) => {
|
(None, Some(e)) => {
|
||||||
return html! {
|
return html! {
|
||||||
@@ -163,7 +163,7 @@ impl Component for ResetPasswordStep2Form {
|
|||||||
{"Back"}
|
{"Back"}
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
_ => (),
|
_ => (),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,13 +5,9 @@ use crate::{
|
|||||||
router::{AppRoute, Link},
|
router::{AppRoute, Link},
|
||||||
user_details_form::UserDetailsForm,
|
user_details_form::UserDetailsForm,
|
||||||
},
|
},
|
||||||
infra::{
|
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
|
||||||
form_utils::GraphQlAttributeSchema,
|
|
||||||
schema::AttributeType,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
use anyhow::{Error, Result, bail};
|
use anyhow::{bail, Error, Result};
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
|
|
||||||
@@ -20,38 +16,18 @@ use yew::prelude::*;
|
|||||||
schema_path = "../schema.graphql",
|
schema_path = "../schema.graphql",
|
||||||
query_path = "queries/get_user_details.graphql",
|
query_path = "queries/get_user_details.graphql",
|
||||||
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"
|
||||||
extern_enums("AttributeType")
|
|
||||||
)]
|
)]
|
||||||
pub struct GetUserDetails;
|
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 AttributeSchema = get_user_details::GetUserDetailsSchemaUserSchemaAttributes;
|
|
||||||
|
|
||||||
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_and_schema: Option<(User, Vec<AttributeSchema>)>,
|
user: Option<User>,
|
||||||
}
|
|
||||||
|
|
||||||
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.
|
||||||
@@ -74,20 +50,22 @@ 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) => {
|
Ok(user) => self.user = Some(user.user),
|
||||||
self.user_and_schema = Some((user.user, user.schema.user_schema.attributes))
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
self.user_and_schema = None;
|
self.user = 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.mut_groups().push(group);
|
self.user.as_mut().unwrap().groups.push(group);
|
||||||
}
|
}
|
||||||
Msg::OnUserRemovedFromGroup((_, group_id)) => {
|
Msg::OnUserRemovedFromGroup((_, group_id)) => {
|
||||||
self.mut_groups().retain(|g| g.id != group_id);
|
self.user
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.groups
|
||||||
|
.retain(|g| g.id != group_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
@@ -200,7 +178,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_and_schema: None,
|
user: None,
|
||||||
};
|
};
|
||||||
table.get_user_details(ctx);
|
table.get_user_details(ctx);
|
||||||
table
|
table
|
||||||
@@ -211,8 +189,10 @@ impl Component for UserDetails {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
match (&self.user_and_schema, &self.common.error) {
|
match (&self.user, &self.common.error) {
|
||||||
(Some((u, schema)), error) => {
|
(None, None) => html! {{"Loading..."}},
|
||||||
|
(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>
|
||||||
@@ -227,20 +207,13 @@ 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
|
<UserDetailsForm user={u.clone()} />
|
||||||
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,22 +1,57 @@
|
|||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
components::{
|
components::{
|
||||||
form::{
|
form::{field::Field, static_value::StaticValue, submit::Submit},
|
||||||
attribute_input::{ListAttributeInput, SingleAttributeInput},
|
user_details::User,
|
||||||
static_value::StaticValue,
|
|
||||||
submit::Submit,
|
|
||||||
},
|
|
||||||
user_details::{Attribute, AttributeSchema, User},
|
|
||||||
},
|
|
||||||
infra::{
|
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
|
||||||
form_utils::{AttributeValue, EmailIsRequired, IsAdmin, read_all_form_attributes},
|
|
||||||
schema::AttributeType,
|
|
||||||
},
|
},
|
||||||
|
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
};
|
||||||
|
use anyhow::{bail, Error, Result};
|
||||||
|
use gloo_file::{
|
||||||
|
callbacks::{read_as_bytes, FileReader},
|
||||||
|
File,
|
||||||
};
|
};
|
||||||
use anyhow::{Ok, Result};
|
|
||||||
use gloo_console::console;
|
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
|
use validator_derive::Validate;
|
||||||
|
use web_sys::{FileList, HtmlInputElement, InputEvent, SubmitEvent};
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
|
use yew_form_derive::Model;
|
||||||
|
use gloo_console::log;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct JsFile {
|
||||||
|
file: Option<File>,
|
||||||
|
contents: Option<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToString for JsFile {
|
||||||
|
fn to_string(&self) -> String {
|
||||||
|
self.file.as_ref().map(File::name).unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for JsFile {
|
||||||
|
type Err = Error;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self> {
|
||||||
|
if s.is_empty() {
|
||||||
|
Ok(JsFile::default())
|
||||||
|
} else {
|
||||||
|
bail!("Building file from non-empty string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fields of the form, with the editable details and the constraints.
|
||||||
|
#[derive(Model, Validate, PartialEq, Eq, Clone)]
|
||||||
|
pub struct UserModel {
|
||||||
|
#[validate(email)]
|
||||||
|
email: String,
|
||||||
|
display_name: String,
|
||||||
|
first_name: String,
|
||||||
|
last_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// The GraphQL query sent to the server to update the user details.
|
/// The GraphQL query sent to the server to update the user details.
|
||||||
#[derive(GraphQLQuery)]
|
#[derive(GraphQLQuery)]
|
||||||
@@ -32,28 +67,36 @@ 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,
|
||||||
form_ref: NodeRef,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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>),
|
||||||
|
/// The "Submit" button was clicked.
|
||||||
|
OnSubmit(SubmitEvent),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
||||||
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 {
|
||||||
@@ -64,10 +107,55 @@ 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::UserUpdated(Err(e)) => Err(e),
|
Msg::ClearAvatarClicked => {
|
||||||
Msg::UserUpdated(Result::Ok(_)) => {
|
self.avatar = Some(JsFile::default());
|
||||||
self.just_updated = 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) => {
|
||||||
|
log!(format!("{:#?}", e));
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,11 +171,19 @@ 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(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,45 +195,97 @@ 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 can_edit =
|
let avatar_string = match &self.avatar {
|
||||||
|a: &AttributeSchema| (ctx.props().is_admin || a.is_editable) && !a.is_readonly;
|
Some(avatar) => {
|
||||||
let display_field = |a: &AttributeSchema| {
|
let avatar_base64 = to_base64(avatar);
|
||||||
if can_edit(a) {
|
avatar_base64.as_deref().unwrap_or("").to_owned()
|
||||||
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
|
<form class="form" onsubmit={link.callback(|e: SubmitEvent| {e.prevent_default(); Msg::OnSubmit(e)})}>
|
||||||
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">
|
||||||
ctx
|
{&self.user.creation_date.naive_local().date()}
|
||||||
.props()
|
</StaticValue>
|
||||||
.user_attributes_schema
|
<StaticValue label="UUID" id="uuid">
|
||||||
.iter()
|
{&self.user.uuid}
|
||||||
.filter(|a| a.is_hardcoded && a.name != "user_id")
|
</StaticValue>
|
||||||
.map(display_field)
|
<Field<UserModel>
|
||||||
.collect::<Vec<_>>()
|
form={&self.form}
|
||||||
}
|
required=true
|
||||||
{
|
label="Email"
|
||||||
ctx
|
field_name="email"
|
||||||
.props()
|
input_type="email"
|
||||||
.user_attributes_schema
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
.iter()
|
<Field<UserModel>
|
||||||
.filter(|a| !a.is_hardcoded)
|
form={&self.form}
|
||||||
.map(display_field)
|
label="Display name"
|
||||||
.collect::<Vec<_>>()
|
field_name="display_name"
|
||||||
}
|
autocomplete="name"
|
||||||
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
|
<Field<UserModel>
|
||||||
|
form={&self.form}
|
||||||
|
label="First name"
|
||||||
|
field_name="first_name"
|
||||||
|
autocomplete="given-name"
|
||||||
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
|
<Field<UserModel>
|
||||||
|
form={&self.form}
|
||||||
|
label="Last name"
|
||||||
|
field_name="last_name"
|
||||||
|
autocomplete="family-name"
|
||||||
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
|
<div class="form-group row align-items-center mb-3">
|
||||||
|
<label for="avatar"
|
||||||
|
class="form-label col-4 col-form-label">
|
||||||
|
{"Avatar: "}
|
||||||
|
</label>
|
||||||
|
<div class="col-8">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-5">
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
id="avatarInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg"
|
||||||
|
oninput={link.callback(|e: InputEvent| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
Self::upload_files(input.files())
|
||||||
|
})} />
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary col-auto"
|
||||||
|
id="avatarClear"
|
||||||
|
disabled={self.common.is_task_running()}
|
||||||
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::ClearAvatarClicked})}>
|
||||||
|
{"Clear"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
{
|
||||||
|
if !avatar_string.is_empty() {
|
||||||
|
html!{
|
||||||
|
<img
|
||||||
|
id="avatarDisplay"
|
||||||
|
src={format!("data:image/jpeg;base64, {}", avatar_string)}
|
||||||
|
style="max-height:128px;max-width:128px;height:auto;width:auto;"
|
||||||
|
alt="Avatar" />
|
||||||
|
}
|
||||||
|
} else { html! {} }
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Submit
|
<Submit
|
||||||
text="Save changes"
|
text="Save changes"
|
||||||
disabled={self.common.is_task_running()}
|
disabled={self.common.is_task_running()}
|
||||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})} />
|
onclick={link.callback(|e: MouseEvent| {Msg::SubmitClicked})} />
|
||||||
</form>
|
</form>
|
||||||
{
|
{
|
||||||
if let Some(e) = &self.common.error {
|
if let Some(e) = &self.common.error {
|
||||||
@@ -156,107 +304,19 @@ impl Component for UserDetailsForm {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_custom_attribute_input(
|
|
||||||
attribute_schema: &AttributeSchema,
|
|
||||||
user_attributes: &[Attribute],
|
|
||||||
) -> Html {
|
|
||||||
let values = user_attributes
|
|
||||||
.iter()
|
|
||||||
.find(|a| a.name == attribute_schema.name)
|
|
||||||
.map(|attribute| attribute.value.clone())
|
|
||||||
.unwrap_or_default();
|
|
||||||
if attribute_schema.is_list {
|
|
||||||
html! {
|
|
||||||
<ListAttributeInput
|
|
||||||
name={attribute_schema.name.clone()}
|
|
||||||
attribute_type={attribute_schema.attribute_type}
|
|
||||||
values={values}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
html! {
|
|
||||||
<SingleAttributeInput
|
|
||||||
name={attribute_schema.name.clone()}
|
|
||||||
attribute_type={attribute_schema.attribute_type}
|
|
||||||
value={values.first().cloned().unwrap_or_default()}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_custom_attribute_static(
|
|
||||||
attribute_schema: &AttributeSchema,
|
|
||||||
user_attributes: &[Attribute],
|
|
||||||
) -> Html {
|
|
||||||
let values = user_attributes
|
|
||||||
.iter()
|
|
||||||
.find(|a| a.name == attribute_schema.name)
|
|
||||||
.map(|attribute| attribute.value.clone())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let value_to_str = match attribute_schema.attribute_type {
|
|
||||||
AttributeType::String | AttributeType::Integer => |v: String| v,
|
|
||||||
AttributeType::DateTime => |v: String| {
|
|
||||||
console!(format!("Parsing date: {}", &v));
|
|
||||||
chrono::DateTime::parse_from_rfc3339(&v)
|
|
||||||
.map(|dt| dt.naive_utc().to_string())
|
|
||||||
.unwrap_or_else(|_| "Invalid date".to_string())
|
|
||||||
},
|
|
||||||
AttributeType::JpegPhoto => |_: String| "Unimplemented JPEG display".to_string(),
|
|
||||||
};
|
|
||||||
html! {
|
|
||||||
<StaticValue label={attribute_schema.name.clone()} id={attribute_schema.name.clone()}>
|
|
||||||
{values.into_iter().map(|x| html!{<div>{value_to_str(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> {
|
||||||
// TODO: Handle unloaded files.
|
if !self.form.validate() {
|
||||||
// if let Some(JsFile {
|
bail!("Invalid inputs");
|
||||||
// file: Some(_),
|
}
|
||||||
// contents: None,
|
if let Some(JsFile {
|
||||||
// }) = &self.avatar
|
file: Some(_),
|
||||||
// {
|
contents: None,
|
||||||
// bail!("Image file hasn't finished loading, try again");
|
}) = &self.avatar
|
||||||
// }
|
{
|
||||||
let mut all_values = read_all_form_attributes(
|
bail!("Image file hasn't finished loading, try again");
|
||||||
ctx.props().user_attributes_schema.iter(),
|
}
|
||||||
&self.form_ref,
|
let base_user = &self.user;
|
||||||
IsAdmin(ctx.props().is_admin),
|
|
||||||
EmailIsRequired(!ctx.props().is_edited_user_admin),
|
|
||||||
)?;
|
|
||||||
let base_attributes = &self.user.attributes;
|
|
||||||
all_values.retain(|a| {
|
|
||||||
let base_val = base_attributes
|
|
||||||
.iter()
|
|
||||||
.find(|base_val| base_val.name == a.name);
|
|
||||||
base_val
|
|
||||||
.map(|v| v.value != a.values)
|
|
||||||
.unwrap_or(!a.values.is_empty())
|
|
||||||
});
|
|
||||||
let remove_attributes: Option<Vec<String>> = if all_values.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(all_values.iter().map(|a| a.name.clone()).collect())
|
|
||||||
};
|
|
||||||
let insert_attributes: Option<Vec<update_user::AttributeValueInput>> =
|
|
||||||
if remove_attributes.is_none() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(
|
|
||||||
all_values
|
|
||||||
.into_iter()
|
|
||||||
.filter(|a| !a.values.is_empty())
|
|
||||||
.map(
|
|
||||||
|AttributeValue { name, values }| update_user::AttributeValueInput {
|
|
||||||
name,
|
|
||||||
value: values,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.collect(),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
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,
|
||||||
@@ -268,8 +328,23 @@ impl UserDetailsForm {
|
|||||||
insertAttributes: None,
|
insertAttributes: None,
|
||||||
};
|
};
|
||||||
let default_user_input = user_input.clone();
|
let default_user_input = user_input.clone();
|
||||||
user_input.removeAttributes = remove_attributes;
|
let model = self.form.model();
|
||||||
user_input.insertAttributes = insert_attributes;
|
let email = model.email;
|
||||||
|
if base_user.email != email {
|
||||||
|
user_input.email = Some(email);
|
||||||
|
}
|
||||||
|
if base_user.display_name != model.display_name {
|
||||||
|
user_input.displayName = Some(model.display_name);
|
||||||
|
}
|
||||||
|
if base_user.first_name != model.first_name {
|
||||||
|
user_input.firstName = Some(model.first_name);
|
||||||
|
}
|
||||||
|
if base_user.last_name != model.last_name {
|
||||||
|
user_input.lastName = Some(model.last_name);
|
||||||
|
}
|
||||||
|
if let Some(avatar) = &self.avatar {
|
||||||
|
user_input.avatar = Some(to_base64(avatar)?);
|
||||||
|
}
|
||||||
// Nothing changed.
|
// Nothing changed.
|
||||||
if user_input == default_user_input {
|
if user_input == default_user_input {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
@@ -283,4 +358,58 @@ 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,16 +1,15 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::{
|
components::{
|
||||||
delete_user_attribute::DeleteUserAttribute,
|
delete_user_attribute::DeleteUserAttribute,
|
||||||
fragments::attribute_schema::render_attribute_name,
|
|
||||||
router::{AppRoute, Link},
|
router::{AppRoute, Link},
|
||||||
},
|
},
|
||||||
|
convert_attribute_type,
|
||||||
infra::{
|
infra::{
|
||||||
attributes::user,
|
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
schema::AttributeType,
|
schema::AttributeType,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{Error, Result, anyhow};
|
use anyhow::{anyhow, Error, Result};
|
||||||
use gloo_console::log;
|
use gloo_console::log;
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
@@ -20,8 +19,7 @@ use yew::prelude::*;
|
|||||||
schema_path = "../schema.graphql",
|
schema_path = "../schema.graphql",
|
||||||
query_path = "queries/get_user_attributes_schema.graphql",
|
query_path = "queries/get_user_attributes_schema.graphql",
|
||||||
response_derives = "Debug,Clone,PartialEq,Eq",
|
response_derives = "Debug,Clone,PartialEq,Eq",
|
||||||
custom_scalars_module = "crate::infra::graphql",
|
custom_scalars_module = "crate::infra::graphql"
|
||||||
extern_enums("AttributeType")
|
|
||||||
)]
|
)]
|
||||||
pub struct GetUserAttributesSchema;
|
pub struct GetUserAttributesSchema;
|
||||||
|
|
||||||
@@ -29,6 +27,8 @@ use get_user_attributes_schema::ResponseData;
|
|||||||
|
|
||||||
pub type Attribute = get_user_attributes_schema::GetUserAttributesSchemaSchemaUserSchemaAttributes;
|
pub type Attribute = get_user_attributes_schema::GetUserAttributesSchemaSchemaUserSchemaAttributes;
|
||||||
|
|
||||||
|
convert_attribute_type!(get_user_attributes_schema::AttributeType);
|
||||||
|
|
||||||
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
||||||
pub struct Props {
|
pub struct Props {
|
||||||
pub hardcoded: bool,
|
pub hardcoded: bool,
|
||||||
@@ -53,21 +53,21 @@ impl CommonComponent<UserSchemaTable> for UserSchemaTable {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
Msg::OnError(e) => Err(e),
|
Msg::OnError(e) => Err(e),
|
||||||
Msg::OnAttributeDeleted(attribute_name) => match self.attributes {
|
Msg::OnAttributeDeleted(attribute_name) => {
|
||||||
None => {
|
match self.attributes {
|
||||||
log!(format!(
|
None => {
|
||||||
"Attribute {attribute_name} was deleted but component has no attributes"
|
log!(format!("Attribute {attribute_name} was deleted but component has no attributes"));
|
||||||
));
|
Err(anyhow!("invalid state"))
|
||||||
Err(anyhow!("invalid state"))
|
}
|
||||||
|
Some(_) => {
|
||||||
|
self.attributes
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.retain(|a| a.name != attribute_name);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Some(_) => {
|
}
|
||||||
self.attributes
|
|
||||||
.as_mut()
|
|
||||||
.unwrap()
|
|
||||||
.retain(|a| a.name != attribute_name);
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,17 +144,16 @@ impl UserSchemaTable {
|
|||||||
|
|
||||||
fn view_attribute(&self, ctx: &Context<Self>, attribute: &Attribute) -> Html {
|
fn view_attribute(&self, ctx: &Context<Self>, attribute: &Attribute) -> Html {
|
||||||
let link = ctx.link();
|
let link = ctx.link();
|
||||||
let attribute_type = attribute.attribute_type;
|
let attribute_type = AttributeType::from(attribute.attribute_type.clone());
|
||||||
let checkmark = html! {
|
let checkmark = html! {
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check" viewBox="0 0 16 16">
|
||||||
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z"></path>
|
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
};
|
};
|
||||||
let hardcoded = ctx.props().hardcoded;
|
let hardcoded = ctx.props().hardcoded;
|
||||||
let desc = user::resolve_user_attribute_description_or_default(&attribute.name);
|
|
||||||
html! {
|
html! {
|
||||||
<tr key={attribute.name.clone()}>
|
<tr key={attribute.name.clone()}>
|
||||||
<td>{render_attribute_name(hardcoded, &desc)}</td>
|
<td>{&attribute.name}</td>
|
||||||
<td>{if attribute.is_list { format!("List<{attribute_type}>")} else {attribute_type.to_string()}}</td>
|
<td>{if attribute.is_list { format!("List<{attribute_type}>")} else {attribute_type.to_string()}}</td>
|
||||||
<td>{if attribute.is_editable {checkmark.clone()} else {html!{}}}</td>
|
<td>{if attribute.is_editable {checkmark.clone()} else {html!{}}}</td>
|
||||||
<td>{if attribute.is_visible {checkmark.clone()} else {html!{}}}</td>
|
<td>{if attribute.is_visible {checkmark.clone()} else {html!{}}}</td>
|
||||||
|
|||||||
+35
-41
@@ -1,11 +1,10 @@
|
|||||||
use super::cookies::set_cookie;
|
use super::cookies::set_cookie;
|
||||||
use anyhow::{Context, Result, anyhow};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use gloo_net::http::{Method, RequestBuilder};
|
use gloo_net::http::{Method, Request};
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use lldap_auth::{JWTClaims, login, registration};
|
use lldap_auth::{login, registration, JWTClaims};
|
||||||
|
|
||||||
use lldap_frontend_options::Options;
|
use serde::{de::DeserializeOwned, Serialize};
|
||||||
use serde::{Serialize, de::DeserializeOwned};
|
|
||||||
use web_sys::RequestCredentials;
|
use web_sys::RequestCredentials;
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@@ -17,32 +16,25 @@ fn get_claims_from_jwt(jwt: &str) -> Result<JWTClaims> {
|
|||||||
Ok(token.claims().clone())
|
Ok(token.claims().clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RequestType<Body: Serialize> {
|
const NO_BODY: Option<()> = None;
|
||||||
Get,
|
|
||||||
Post(Body),
|
|
||||||
}
|
|
||||||
|
|
||||||
const GET_REQUEST: RequestType<()> = RequestType::Get;
|
|
||||||
|
|
||||||
fn base_url() -> String {
|
fn base_url() -> String {
|
||||||
yew_router::utils::base_url().unwrap_or_default()
|
yew_router::utils::base_url().unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn call_server<Body: Serialize>(
|
async fn call_server(
|
||||||
url: &str,
|
url: &str,
|
||||||
body: RequestType<Body>,
|
body: Option<impl Serialize>,
|
||||||
error_message: &'static str,
|
error_message: &'static str,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let request_builder = RequestBuilder::new(url)
|
let mut request = Request::new(url)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.credentials(RequestCredentials::SameOrigin);
|
.credentials(RequestCredentials::SameOrigin);
|
||||||
let request = if let RequestType::Post(b) = body {
|
if let Some(b) = body {
|
||||||
request_builder
|
request = request
|
||||||
.method(Method::POST)
|
.body(serde_json::to_string(&b)?)
|
||||||
.body(serde_json::to_string(&b)?)?
|
.method(Method::POST);
|
||||||
} 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?)
|
||||||
@@ -59,7 +51,7 @@ async fn call_server<Body: Serialize>(
|
|||||||
|
|
||||||
async fn call_server_json_with_error_message<CallbackResult, Body: Serialize>(
|
async fn call_server_json_with_error_message<CallbackResult, Body: Serialize>(
|
||||||
url: &str,
|
url: &str,
|
||||||
request: RequestType<Body>,
|
request: Option<Body>,
|
||||||
error_message: &'static str,
|
error_message: &'static str,
|
||||||
) -> Result<CallbackResult>
|
) -> Result<CallbackResult>
|
||||||
where
|
where
|
||||||
@@ -71,7 +63,7 @@ where
|
|||||||
|
|
||||||
async fn call_server_empty_response_with_error_message<Body: Serialize>(
|
async fn call_server_empty_response_with_error_message<Body: Serialize>(
|
||||||
url: &str,
|
url: &str,
|
||||||
request: RequestType<Body>,
|
request: Option<Body>,
|
||||||
error_message: &'static str,
|
error_message: &'static str,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
call_server(url, request, error_message).await.map(|_| ())
|
call_server(url, request, error_message).await.map(|_| ())
|
||||||
@@ -110,7 +102,7 @@ impl HostService {
|
|||||||
let request_body = QueryType::build_query(variables);
|
let request_body = QueryType::build_query(variables);
|
||||||
call_server_json_with_error_message::<graphql_client::Response<_>, _>(
|
call_server_json_with_error_message::<graphql_client::Response<_>, _>(
|
||||||
&(base_url() + "/api/graphql"),
|
&(base_url() + "/api/graphql"),
|
||||||
RequestType::Post(request_body),
|
Some(request_body),
|
||||||
error_message,
|
error_message,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -122,7 +114,7 @@ impl HostService {
|
|||||||
) -> Result<Box<login::ServerLoginStartResponse>> {
|
) -> Result<Box<login::ServerLoginStartResponse>> {
|
||||||
call_server_json_with_error_message(
|
call_server_json_with_error_message(
|
||||||
&(base_url() + "/auth/opaque/login/start"),
|
&(base_url() + "/auth/opaque/login/start"),
|
||||||
RequestType::Post(request),
|
Some(request),
|
||||||
"Could not start authentication: ",
|
"Could not start authentication: ",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -131,28 +123,19 @@ impl HostService {
|
|||||||
pub async fn login_finish(request: login::ClientLoginFinishRequest) -> Result<(String, bool)> {
|
pub async fn login_finish(request: login::ClientLoginFinishRequest) -> Result<(String, bool)> {
|
||||||
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
|
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
|
||||||
&(base_url() + "/auth/opaque/login/finish"),
|
&(base_url() + "/auth/opaque/login/finish"),
|
||||||
RequestType::Post(request),
|
Some(request),
|
||||||
"Could not finish authentication",
|
"Could not finish authentication",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.and_then(set_cookies_from_jwt)
|
.and_then(set_cookies_from_jwt)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_settings() -> Result<Options> {
|
|
||||||
call_server_json_with_error_message::<Options, _>(
|
|
||||||
&(base_url() + "/settings"),
|
|
||||||
GET_REQUEST,
|
|
||||||
"Could not fetch settings: ",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn register_start(
|
pub async fn register_start(
|
||||||
request: registration::ClientRegistrationStartRequest,
|
request: registration::ClientRegistrationStartRequest,
|
||||||
) -> Result<Box<registration::ServerRegistrationStartResponse>> {
|
) -> Result<Box<registration::ServerRegistrationStartResponse>> {
|
||||||
call_server_json_with_error_message(
|
call_server_json_with_error_message(
|
||||||
&(base_url() + "/auth/opaque/register/start"),
|
&(base_url() + "/auth/opaque/register/start"),
|
||||||
RequestType::Post(request),
|
Some(request),
|
||||||
"Could not start registration: ",
|
"Could not start registration: ",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -163,7 +146,7 @@ impl HostService {
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
call_server_empty_response_with_error_message(
|
call_server_empty_response_with_error_message(
|
||||||
&(base_url() + "/auth/opaque/register/finish"),
|
&(base_url() + "/auth/opaque/register/finish"),
|
||||||
RequestType::Post(request),
|
Some(request),
|
||||||
"Could not finish registration",
|
"Could not finish registration",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -172,7 +155,7 @@ impl HostService {
|
|||||||
pub async fn refresh() -> Result<(String, bool)> {
|
pub async fn refresh() -> Result<(String, bool)> {
|
||||||
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
|
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
|
||||||
&(base_url() + "/auth/refresh"),
|
&(base_url() + "/auth/refresh"),
|
||||||
GET_REQUEST,
|
NO_BODY,
|
||||||
"Could not start authentication: ",
|
"Could not start authentication: ",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -183,7 +166,7 @@ impl HostService {
|
|||||||
pub async fn logout() -> Result<()> {
|
pub async fn logout() -> Result<()> {
|
||||||
call_server_empty_response_with_error_message(
|
call_server_empty_response_with_error_message(
|
||||||
&(base_url() + "/auth/logout"),
|
&(base_url() + "/auth/logout"),
|
||||||
GET_REQUEST,
|
NO_BODY,
|
||||||
"Could not logout",
|
"Could not logout",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -196,7 +179,7 @@ impl HostService {
|
|||||||
base_url(),
|
base_url(),
|
||||||
url_escape::encode_query(&username)
|
url_escape::encode_query(&username)
|
||||||
),
|
),
|
||||||
RequestType::Post(""),
|
NO_BODY,
|
||||||
"Could not initiate password reset",
|
"Could not initiate password reset",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -207,9 +190,20 @@ impl HostService {
|
|||||||
) -> Result<lldap_auth::password_reset::ServerPasswordResetResponse> {
|
) -> Result<lldap_auth::password_reset::ServerPasswordResetResponse> {
|
||||||
call_server_json_with_error_message(
|
call_server_json_with_error_message(
|
||||||
&format!("{}/auth/reset/step2/{}", base_url(), token),
|
&format!("{}/auth/reset/step2/{}", base_url(), token),
|
||||||
GET_REQUEST,
|
NO_BODY,
|
||||||
"Could not validate token",
|
"Could not validate token",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn probe_password_reset() -> Result<bool> {
|
||||||
|
Ok(gloo_net::http::Request::get(
|
||||||
|
&(base_url() + "/auth/reset/step1/lldap_unlikely_very_long_user_name"),
|
||||||
|
)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.status()
|
||||||
|
!= http::StatusCode::NOT_FOUND)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
pub struct AttributeDescription<'a> {
|
|
||||||
pub attribute_identifier: &'a str,
|
|
||||||
pub attribute_name: &'a str,
|
|
||||||
pub aliases: Vec<&'a str>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub mod group {
|
|
||||||
|
|
||||||
use super::AttributeDescription;
|
|
||||||
|
|
||||||
pub fn resolve_group_attribute_description(name: &str) -> Option<AttributeDescription> {
|
|
||||||
match name {
|
|
||||||
"creation_date" => Some(AttributeDescription {
|
|
||||||
attribute_identifier: name,
|
|
||||||
attribute_name: "creationdate",
|
|
||||||
aliases: vec![name, "createtimestamp"],
|
|
||||||
}),
|
|
||||||
"modified_date" => Some(AttributeDescription {
|
|
||||||
attribute_identifier: name,
|
|
||||||
attribute_name: "modifydate",
|
|
||||||
aliases: vec![name, "modifytimestamp"],
|
|
||||||
}),
|
|
||||||
"display_name" => Some(AttributeDescription {
|
|
||||||
attribute_identifier: name,
|
|
||||||
attribute_name: "displayname",
|
|
||||||
aliases: vec![name, "cn", "uid", "id"],
|
|
||||||
}),
|
|
||||||
"group_id" => Some(AttributeDescription {
|
|
||||||
attribute_identifier: name,
|
|
||||||
attribute_name: "groupid",
|
|
||||||
aliases: vec![name],
|
|
||||||
}),
|
|
||||||
"uuid" => Some(AttributeDescription {
|
|
||||||
attribute_identifier: name,
|
|
||||||
attribute_name: name,
|
|
||||||
aliases: vec!["entryuuid"],
|
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn resolve_group_attribute_description_or_default(name: &str) -> AttributeDescription {
|
|
||||||
match resolve_group_attribute_description(name) {
|
|
||||||
Some(d) => d,
|
|
||||||
None => AttributeDescription {
|
|
||||||
attribute_identifier: name,
|
|
||||||
attribute_name: name,
|
|
||||||
aliases: vec![],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub mod user {
|
|
||||||
|
|
||||||
use super::AttributeDescription;
|
|
||||||
|
|
||||||
pub fn resolve_user_attribute_description(name: &str) -> Option<AttributeDescription> {
|
|
||||||
match name {
|
|
||||||
"avatar" => Some(AttributeDescription {
|
|
||||||
attribute_identifier: name,
|
|
||||||
attribute_name: name,
|
|
||||||
aliases: vec!["jpegphoto"],
|
|
||||||
}),
|
|
||||||
"creation_date" => Some(AttributeDescription {
|
|
||||||
attribute_identifier: name,
|
|
||||||
attribute_name: "creationdate",
|
|
||||||
aliases: vec![name, "createtimestamp"],
|
|
||||||
}),
|
|
||||||
"modified_date" => Some(AttributeDescription {
|
|
||||||
attribute_identifier: name,
|
|
||||||
attribute_name: "modifydate",
|
|
||||||
aliases: vec![name, "modifytimestamp"],
|
|
||||||
}),
|
|
||||||
"password_modified_date" => Some(AttributeDescription {
|
|
||||||
attribute_identifier: name,
|
|
||||||
attribute_name: "passwordmodifydate",
|
|
||||||
aliases: vec![name, "pwdchangedtime"],
|
|
||||||
}),
|
|
||||||
"display_name" => Some(AttributeDescription {
|
|
||||||
attribute_identifier: name,
|
|
||||||
attribute_name: "displayname",
|
|
||||||
aliases: vec![name, "cn"],
|
|
||||||
}),
|
|
||||||
"first_name" => Some(AttributeDescription {
|
|
||||||
attribute_identifier: name,
|
|
||||||
attribute_name: "firstname",
|
|
||||||
aliases: vec![name, "givenname"],
|
|
||||||
}),
|
|
||||||
"last_name" => Some(AttributeDescription {
|
|
||||||
attribute_identifier: name,
|
|
||||||
attribute_name: "lastname",
|
|
||||||
aliases: vec![name, "sn"],
|
|
||||||
}),
|
|
||||||
"mail" => Some(AttributeDescription {
|
|
||||||
attribute_identifier: name,
|
|
||||||
attribute_name: name,
|
|
||||||
aliases: vec!["email"],
|
|
||||||
}),
|
|
||||||
"user_id" => Some(AttributeDescription {
|
|
||||||
attribute_identifier: name,
|
|
||||||
attribute_name: "uid",
|
|
||||||
aliases: vec![name, "id"],
|
|
||||||
}),
|
|
||||||
"uuid" => Some(AttributeDescription {
|
|
||||||
attribute_identifier: name,
|
|
||||||
attribute_name: name,
|
|
||||||
aliases: vec!["entryuuid"],
|
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn resolve_user_attribute_description_or_default(name: &str) -> AttributeDescription {
|
|
||||||
match resolve_user_attribute_description(name) {
|
|
||||||
Some(d) => d,
|
|
||||||
None => AttributeDescription {
|
|
||||||
attribute_identifier: name,
|
|
||||||
attribute_name: name,
|
|
||||||
aliases: vec![],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use anyhow::{Result, anyhow};
|
use anyhow::{anyhow, Result};
|
||||||
use chrono::prelude::*;
|
use chrono::prelude::*;
|
||||||
use wasm_bindgen::JsCast;
|
use wasm_bindgen::JsCast;
|
||||||
use web_sys::HtmlDocument;
|
use web_sys::HtmlDocument;
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
use anyhow::{Result, anyhow, ensure};
|
|
||||||
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_email_attributes(all_values: &[AttributeValue]) -> Result<()> {
|
|
||||||
let maybe_email_values = all_values.iter().find(|a| a.name == "mail");
|
|
||||||
let email_values = &maybe_email_values
|
|
||||||
.ok_or_else(|| anyhow!("Email is required"))?
|
|
||||||
.values;
|
|
||||||
ensure!(!email_values.is_empty(), "Email is required");
|
|
||||||
ensure!(email_values.len() == 1, "Multiple emails are not supported");
|
|
||||||
ensure!(validate_email(&email_values[0]), "Email is not valid");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
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<_>>>()?;
|
|
||||||
if email_is_required.0 {
|
|
||||||
validate_email_attributes(&all_values)?;
|
|
||||||
}
|
|
||||||
Ok(all_values)
|
|
||||||
}
|
|
||||||
+10
-31
@@ -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::{UseStateHandle, use_effect_with_deps, use_state_eq};
|
use yew::{use_effect, use_state, UseStateHandle};
|
||||||
|
|
||||||
// Enum to represent a result that is fetched asynchronously.
|
// Enum to represent a result that is fetched asynchronously.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -13,47 +13,26 @@ pub enum LoadableResult<T> {
|
|||||||
Loaded(Result<T>),
|
Loaded(Result<T>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: PartialEq> PartialEq for LoadableResult<T> {
|
|
||||||
fn eq(&self, other: &Self) -> bool {
|
|
||||||
match (self, other) {
|
|
||||||
(LoadableResult::Loading, LoadableResult::Loading) => true,
|
|
||||||
(LoadableResult::Loaded(Ok(d1)), LoadableResult::Loaded(Ok(d2))) => d1.eq(d2),
|
|
||||||
(LoadableResult::Loaded(Err(e1)), LoadableResult::Loaded(Err(e2))) => {
|
|
||||||
e1.to_string().eq(&e2.to_string())
|
|
||||||
}
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn use_graphql_call<QueryType>(
|
pub fn use_graphql_call<QueryType>(
|
||||||
variables: QueryType::Variables,
|
variables: QueryType::Variables,
|
||||||
) -> 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,
|
|
||||||
{
|
{
|
||||||
let loadable_result: UseStateHandle<LoadableResult<QueryType::ResponseData>> =
|
let loadable_result: UseStateHandle<LoadableResult<QueryType::ResponseData>> =
|
||||||
use_state_eq(|| LoadableResult::Loading);
|
use_state(|| LoadableResult::Loading);
|
||||||
{
|
{
|
||||||
let loadable_result = loadable_result.clone();
|
let loadable_result = loadable_result.clone();
|
||||||
use_effect_with_deps(
|
use_effect(move || {
|
||||||
move |variables| {
|
let task = HostService::graphql_query::<QueryType>(variables, "Failed graphql query");
|
||||||
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,10 +1,7 @@
|
|||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod attributes;
|
|
||||||
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,5 +1,3 @@
|
|||||||
#![allow(clippy::empty_docs)]
|
|
||||||
|
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
|
|||||||
+87
-33
@@ -1,42 +1,96 @@
|
|||||||
use derive_more::Display;
|
use anyhow::Result;
|
||||||
use serde::{Deserialize, Serialize};
|
use std::{fmt::Display, str::FromStr};
|
||||||
use strum::EnumString;
|
|
||||||
use validator::ValidationError;
|
use validator::ValidationError;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq, Hash, EnumString, Display)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
pub enum AttributeType {
|
||||||
#[strum(ascii_case_insensitive)]
|
|
||||||
pub(crate) enum AttributeType {
|
|
||||||
String,
|
String,
|
||||||
Integer,
|
Integer,
|
||||||
#[strum(serialize = "DATE_TIME", serialize = "DATETIME")]
|
|
||||||
DateTime,
|
DateTime,
|
||||||
#[strum(serialize = "JPEG_PHOTO", serialize = "JPEGPHOTO")]
|
Jpeg,
|
||||||
JpegPhoto,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn validate_attribute_type(attribute_type: &str) -> Result<(), ValidationError> {
|
impl Display for AttributeType {
|
||||||
AttributeType::try_from(attribute_type)
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
.map_err(|_| ValidationError::new("Invalid attribute type"))?;
|
write!(f, "{:?}", self)
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_deserialize_attribute_type() {
|
|
||||||
let attr_type: AttributeType = "STRING".try_into().unwrap();
|
|
||||||
assert_eq!(attr_type, AttributeType::String);
|
|
||||||
|
|
||||||
let attr_type: AttributeType = "Integer".try_into().unwrap();
|
|
||||||
assert_eq!(attr_type, AttributeType::Integer);
|
|
||||||
|
|
||||||
let attr_type: AttributeType = "DATE_TIME".try_into().unwrap();
|
|
||||||
assert_eq!(attr_type, AttributeType::DateTime);
|
|
||||||
|
|
||||||
let attr_type: AttributeType = "JpegPhoto".try_into().unwrap();
|
|
||||||
assert_eq!(attr_type, AttributeType::JpegPhoto);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl FromStr for AttributeType {
|
||||||
|
type Err = ();
|
||||||
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
|
match value {
|
||||||
|
"String" => Ok(AttributeType::String),
|
||||||
|
"Integer" => Ok(AttributeType::Integer),
|
||||||
|
"DateTime" => Ok(AttributeType::DateTime),
|
||||||
|
"Jpeg" => Ok(AttributeType::Jpeg),
|
||||||
|
_ => Err(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Macro to generate traits for converting between AttributeType and the
|
||||||
|
// graphql generated equivalents.
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! convert_attribute_type {
|
||||||
|
($source_type:ty) => {
|
||||||
|
impl From<$source_type> for AttributeType {
|
||||||
|
fn from(value: $source_type) -> Self {
|
||||||
|
match value {
|
||||||
|
<$source_type>::STRING => AttributeType::String,
|
||||||
|
<$source_type>::INTEGER => AttributeType::Integer,
|
||||||
|
<$source_type>::DATE_TIME => AttributeType::DateTime,
|
||||||
|
<$source_type>::JPEG_PHOTO => AttributeType::Jpeg,
|
||||||
|
_ => panic!("Unknown attribute type"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AttributeType> for $source_type {
|
||||||
|
fn from(value: AttributeType) -> Self {
|
||||||
|
match value {
|
||||||
|
AttributeType::String => <$source_type>::STRING,
|
||||||
|
AttributeType::Integer => <$source_type>::INTEGER,
|
||||||
|
AttributeType::DateTime => <$source_type>::DATE_TIME,
|
||||||
|
AttributeType::Jpeg => <$source_type>::JPEG_PHOTO,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
|
#[derive(Clone, PartialEq, Eq)]
|
||||||
|
pub struct Attribute {
|
||||||
|
pub name: String,
|
||||||
|
pub value: Vec<String>,
|
||||||
|
pub attribute_type: AttributeType,
|
||||||
|
pub is_list: bool,
|
||||||
|
pub is_editable: bool,
|
||||||
|
pub is_hardcoded: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Macro to generate traits for converting between AttributeType and the
|
||||||
|
// graphql generated equivalents.
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! combine_schema_and_values {
|
||||||
|
($schema_list:ident, $value_list:ident, $output_list:ident) => {
|
||||||
|
let set_attributes = value_list.clone();
|
||||||
|
let mut attribute_schema = schema_list.clone();
|
||||||
|
attribute_schema.retain(|schema| !schema.is_hardcoded);
|
||||||
|
let $output_list = attribute_schema.into_iter().map(|schema| {
|
||||||
|
Attribute {
|
||||||
|
name: schema.name.clone(),
|
||||||
|
value: set_attributes.iter().find(|attribute_value| attribute_value.name == schema.name).unwrap().value.clone(),
|
||||||
|
attribute_type: AttributeType::from(schema.attribute_type),
|
||||||
|
is_list: schema.is_list,
|
||||||
|
}
|
||||||
|
}).collect();
|
||||||
|
};
|
||||||
|
=======
|
||||||
|
pub fn validate_attribute_type(attribute_type: &str) -> Result<(), ValidationError> {
|
||||||
|
AttributeType::from_str(attribute_type)
|
||||||
|
.map_err(|_| ValidationError::new("Invalid attribute type"))?;
|
||||||
|
Ok(())
|
||||||
|
>>>>>>> 8f2391a (app: create group attribute schema page (#825))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
#![allow(clippy::empty_docs)]
|
|
||||||
|
|
||||||
use wasm_bindgen::prelude::*;
|
|
||||||
|
|
||||||
#[wasm_bindgen]
|
|
||||||
extern "C" {
|
|
||||||
#[wasm_bindgen(js_namespace = bootstrap)]
|
|
||||||
pub type Tooltip;
|
|
||||||
|
|
||||||
#[wasm_bindgen(constructor, js_namespace = bootstrap)]
|
|
||||||
pub fn new(e: web_sys::Element) -> Tooltip;
|
|
||||||
}
|
|
||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
pub mod components;
|
pub mod components;
|
||||||
pub mod infra;
|
pub mod infra;
|
||||||
|
|
||||||
use wasm_bindgen::prelude::{JsValue, wasm_bindgen};
|
use wasm_bindgen::prelude::{wasm_bindgen, JsValue};
|
||||||
|
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub fn run_app() -> Result<(), JsValue> {
|
pub fn run_app() -> Result<(), JsValue> {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lldap_auth"
|
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
|
||||||
version = "0.6.0"
|
|
||||||
description = "Authentication protocol for LLDAP"
|
description = "Authentication protocol for LLDAP"
|
||||||
edition.workspace = true
|
edition = "2021"
|
||||||
authors.workspace = true
|
homepage = "https://github.com/lldap/lldap"
|
||||||
homepage.workspace = true
|
license = "GPL-3.0-only"
|
||||||
license.workspace = true
|
name = "lldap_auth"
|
||||||
repository.workspace = true
|
repository = "https://github.com/lldap/lldap"
|
||||||
|
version = "0.4.0"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["opaque_server", "opaque_client"]
|
default = ["opaque_server", "opaque_client"]
|
||||||
@@ -14,37 +14,30 @@ opaque_server = []
|
|||||||
opaque_client = []
|
opaque_client = []
|
||||||
js = []
|
js = []
|
||||||
sea_orm = ["dep:sea-orm"]
|
sea_orm = ["dep:sea-orm"]
|
||||||
test = []
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
rust-argon2 = "2"
|
rust-argon2 = "0.8"
|
||||||
curve25519-dalek = "3"
|
curve25519-dalek = "3"
|
||||||
digest = "0.9"
|
digest = "0.9"
|
||||||
generic-array = "0.14"
|
generic-array = "0.14"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
|
serde = "*"
|
||||||
sha2 = "0.9"
|
sha2 = "0.9"
|
||||||
thiserror = "2"
|
thiserror = "*"
|
||||||
|
|
||||||
[dependencies.derive_more]
|
|
||||||
features = ["debug", "display"]
|
|
||||||
default-features = false
|
|
||||||
version = "1"
|
|
||||||
|
|
||||||
[dependencies.opaque-ke]
|
[dependencies.opaque-ke]
|
||||||
version = "0.7"
|
version = "0.6"
|
||||||
|
|
||||||
[dependencies.chrono]
|
[dependencies.chrono]
|
||||||
version = "*"
|
version = "*"
|
||||||
features = ["serde"]
|
features = [ "serde" ]
|
||||||
|
|
||||||
[dependencies.sea-orm]
|
[dependencies.sea-orm]
|
||||||
workspace = true
|
version= "0.12"
|
||||||
|
default-features = false
|
||||||
features = ["macros"]
|
features = ["macros"]
|
||||||
optional = true
|
optional = true
|
||||||
|
|
||||||
[dependencies.serde]
|
|
||||||
workspace = true
|
|
||||||
|
|
||||||
# For WASM targets, use the JS getrandom.
|
# For WASM targets, use the JS getrandom.
|
||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.getrandom]
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.getrandom]
|
||||||
version = "0.2"
|
version = "0.2"
|
||||||
@@ -5,7 +5,6 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
pub mod access_control;
|
|
||||||
pub mod opaque;
|
pub mod opaque;
|
||||||
|
|
||||||
/// The messages for the 3-step OPAQUE and simple login process.
|
/// The messages for the 3-step OPAQUE and simple login process.
|
||||||
@@ -109,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, TryFromU64, Value};
|
use sea_orm::{DbErr, DeriveValueType, QueryResult, TryFromU64, Value};
|
||||||
|
|
||||||
#[derive(
|
#[derive(
|
||||||
PartialEq, Eq, PartialOrd, Ord, Clone, Debug, Default, Hash, Serialize, Deserialize,
|
PartialEq, Eq, PartialOrd, Ord, Clone, Debug, Default, Hash, Serialize, Deserialize,
|
||||||
@@ -152,22 +151,10 @@ pub mod types {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(
|
#[derive(
|
||||||
PartialEq,
|
PartialEq, Eq, PartialOrd, Ord, Clone, Debug, Default, Hash, Serialize, Deserialize,
|
||||||
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 {
|
||||||
@@ -189,6 +176,11 @@ 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 {
|
||||||
@@ -32,6 +32,7 @@ impl ArgonHasher {
|
|||||||
lanes: 1,
|
lanes: 1,
|
||||||
mem_cost: 50 * 1024, // 50 MB, in KB
|
mem_cost: 50 * 1024, // 50 MB, in KB
|
||||||
secret: &[],
|
secret: &[],
|
||||||
|
thread_mode: argon2::ThreadMode::Sequential,
|
||||||
time_cost: 1,
|
time_cost: 1,
|
||||||
variant: argon2::Variant::Argon2id,
|
variant: argon2::Variant::Argon2id,
|
||||||
version: argon2::Version::Version13,
|
version: argon2::Version::Version13,
|
||||||
@@ -132,12 +133,6 @@ pub mod server {
|
|||||||
pub use super::*;
|
pub use super::*;
|
||||||
pub type ServerRegistration = opaque_ke::ServerRegistration<DefaultSuite>;
|
pub type ServerRegistration = opaque_ke::ServerRegistration<DefaultSuite>;
|
||||||
pub type ServerSetup = opaque_ke::ServerSetup<DefaultSuite>;
|
pub type ServerSetup = opaque_ke::ServerSetup<DefaultSuite>;
|
||||||
|
|
||||||
pub fn generate_random_private_key() -> ServerSetup {
|
|
||||||
let mut rng = rand::rngs::OsRng;
|
|
||||||
ServerSetup::new(&mut rng)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Methods to register a new user, from the server side.
|
/// Methods to register a new user, from the server side.
|
||||||
pub mod registration {
|
pub mod registration {
|
||||||
pub use super::*;
|
pub use super::*;
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "lldap_access_control"
|
|
||||||
version = "0.1.0"
|
|
||||||
description = "Access control wrappers for LLDAP"
|
|
||||||
authors.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
homepage.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
repository.workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
tracing = "*"
|
|
||||||
async-trait = "0.1"
|
|
||||||
|
|
||||||
[dependencies.lldap_auth]
|
|
||||||
path = "../auth"
|
|
||||||
features = ["opaque_server", "opaque_client", "sea_orm"]
|
|
||||||
|
|
||||||
[dependencies.lldap_domain]
|
|
||||||
path = "../domain"
|
|
||||||
|
|
||||||
[dependencies.lldap_domain_handlers]
|
|
||||||
path = "../domain-handlers"
|
|
||||||
|
|
||||||
[dependencies.lldap_domain_model]
|
|
||||||
path = "../domain-model"
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
use crate::types::UserId;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
|
|
||||||
pub enum Permission {
|
|
||||||
Admin,
|
|
||||||
PasswordManager,
|
|
||||||
Readonly,
|
|
||||||
Regular,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct ValidationResults {
|
|
||||||
pub user: UserId,
|
|
||||||
pub permission: Permission,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ValidationResults {
|
|
||||||
#[cfg(feature = "test")]
|
|
||||||
pub fn admin() -> Self {
|
|
||||||
Self {
|
|
||||||
user: UserId::new("admin"),
|
|
||||||
permission: Permission::Admin,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn is_admin(&self) -> bool {
|
|
||||||
self.permission == Permission::Admin
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn can_read_all(&self) -> bool {
|
|
||||||
self.permission == Permission::Admin
|
|
||||||
|| self.permission == Permission::Readonly
|
|
||||||
|| self.permission == Permission::PasswordManager
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn can_read(&self, user: &UserId) -> bool {
|
|
||||||
self.permission == Permission::Admin
|
|
||||||
|| self.permission == Permission::PasswordManager
|
|
||||||
|| self.permission == Permission::Readonly
|
|
||||||
|| &self.user == user
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn can_change_password(&self, user: &UserId, user_is_admin: bool) -> bool {
|
|
||||||
self.permission == Permission::Admin
|
|
||||||
|| (self.permission == Permission::PasswordManager && !user_is_admin)
|
|
||||||
|| &self.user == user
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn can_write(&self, user: &UserId) -> bool {
|
|
||||||
self.permission == Permission::Admin || &self.user == user
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "lldap_domain_handlers"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition.workspace = true
|
|
||||||
authors.workspace = true
|
|
||||||
homepage.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
repository.workspace = true
|
|
||||||
|
|
||||||
[features]
|
|
||||||
test = []
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
async-trait = "0.1"
|
|
||||||
base64 = "0.21"
|
|
||||||
ldap3_proto = "0.6.0"
|
|
||||||
serde_bytes = "0.11"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
pretty_assertions = "1"
|
|
||||||
|
|
||||||
[dependencies.chrono]
|
|
||||||
features = ["serde"]
|
|
||||||
version = "0.4"
|
|
||||||
|
|
||||||
[dependencies.derive_more]
|
|
||||||
features = ["debug", "display", "from", "from_str"]
|
|
||||||
default-features = false
|
|
||||||
version = "1"
|
|
||||||
|
|
||||||
[dependencies.lldap_auth]
|
|
||||||
path = "../auth"
|
|
||||||
features = ["opaque_server", "opaque_client", "sea_orm"]
|
|
||||||
|
|
||||||
[dependencies.lldap_domain]
|
|
||||||
path = "../domain"
|
|
||||||
|
|
||||||
[dependencies.lldap_domain_model]
|
|
||||||
path = "../domain-model"
|
|
||||||
|
|
||||||
[dependencies.serde]
|
|
||||||
workspace = true
|
|
||||||
|
|
||||||
[dependencies.uuid]
|
|
||||||
features = ["v1", "v3"]
|
|
||||||
version = "1"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
pub mod handler;
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "lldap_domain_model"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition.workspace = true
|
|
||||||
authors.workspace = true
|
|
||||||
homepage.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
repository.workspace = true
|
|
||||||
|
|
||||||
[features]
|
|
||||||
test = []
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
base64 = "0.21"
|
|
||||||
bincode = "1.3"
|
|
||||||
orion = "0.17"
|
|
||||||
serde_bytes = "0.11"
|
|
||||||
thiserror = "2"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
pretty_assertions = "1"
|
|
||||||
|
|
||||||
[dependencies.chrono]
|
|
||||||
features = ["serde"]
|
|
||||||
version = "0.4"
|
|
||||||
|
|
||||||
[dependencies.derive_more]
|
|
||||||
features = ["debug", "display", "from", "from_str"]
|
|
||||||
default-features = false
|
|
||||||
version = "1"
|
|
||||||
|
|
||||||
[dependencies.lldap_auth]
|
|
||||||
path = "../auth"
|
|
||||||
features = ["opaque_server", "opaque_client", "sea_orm"]
|
|
||||||
|
|
||||||
[dependencies.lldap_domain]
|
|
||||||
path = "../domain"
|
|
||||||
|
|
||||||
[dependencies.sea-orm]
|
|
||||||
workspace = true
|
|
||||||
features = ["macros"]
|
|
||||||
|
|
||||||
[dependencies.serde]
|
|
||||||
workspace = true
|
|
||||||
|
|
||||||
[dependencies.uuid]
|
|
||||||
features = ["v1", "v3"]
|
|
||||||
version = "1"
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
pub mod error;
|
|
||||||
pub mod model;
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
use crate::error::DomainError;
|
|
||||||
use lldap_domain::{
|
|
||||||
schema::AttributeList,
|
|
||||||
types::{Attribute, AttributeName, AttributeType, AttributeValue, Cardinality, Serialized},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Value must be a serialized attribute value of the type denoted by typ,
|
|
||||||
// and either a singleton or unbounded list, depending on is_list.
|
|
||||||
pub fn deserialize_attribute_value(
|
|
||||||
value: &Serialized,
|
|
||||||
typ: AttributeType,
|
|
||||||
is_list: bool,
|
|
||||||
) -> AttributeValue {
|
|
||||||
match (typ, is_list) {
|
|
||||||
(AttributeType::String, false) => {
|
|
||||||
AttributeValue::String(Cardinality::Singleton(value.unwrap()))
|
|
||||||
}
|
|
||||||
(AttributeType::String, true) => {
|
|
||||||
AttributeValue::String(Cardinality::Unbounded(value.unwrap()))
|
|
||||||
}
|
|
||||||
(AttributeType::Integer, false) => {
|
|
||||||
AttributeValue::Integer(Cardinality::Singleton(value.unwrap::<i64>()))
|
|
||||||
}
|
|
||||||
(AttributeType::Integer, true) => {
|
|
||||||
AttributeValue::Integer(Cardinality::Unbounded(value.unwrap()))
|
|
||||||
}
|
|
||||||
(AttributeType::DateTime, false) => {
|
|
||||||
AttributeValue::DateTime(Cardinality::Singleton(value.unwrap()))
|
|
||||||
}
|
|
||||||
(AttributeType::DateTime, true) => {
|
|
||||||
AttributeValue::DateTime(Cardinality::Unbounded(value.unwrap()))
|
|
||||||
}
|
|
||||||
(AttributeType::JpegPhoto, false) => {
|
|
||||||
AttributeValue::JpegPhoto(Cardinality::Singleton(value.unwrap()))
|
|
||||||
}
|
|
||||||
(AttributeType::JpegPhoto, true) => {
|
|
||||||
AttributeValue::JpegPhoto(Cardinality::Unbounded(value.unwrap()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn deserialize_attribute(
|
|
||||||
name: AttributeName,
|
|
||||||
value: &Serialized,
|
|
||||||
schema: &AttributeList,
|
|
||||||
) -> Result<Attribute, DomainError> {
|
|
||||||
match schema.get_attribute_type(&name) {
|
|
||||||
Some((typ, is_list)) => Ok(Attribute {
|
|
||||||
name,
|
|
||||||
value: deserialize_attribute_value(value, typ, is_list),
|
|
||||||
}),
|
|
||||||
None => Err(DomainError::InternalError(format!(
|
|
||||||
"Unable to find schema for attribute named '{}'",
|
|
||||||
name.into_string()
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
use sea_orm::entity::prelude::*;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use lldap_domain::types::LdapObjectClass;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
|
||||||
#[sea_orm(table_name = "group_object_classes")]
|
|
||||||
pub struct Model {
|
|
||||||
#[sea_orm(primary_key, auto_increment = false)]
|
|
||||||
pub lower_object_class: String,
|
|
||||||
pub object_class: LdapObjectClass,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
|
||||||
pub enum Relation {}
|
|
||||||
|
|
||||||
impl ActiveModelBehavior for ActiveModel {}
|
|
||||||
|
|
||||||
impl From<Model> for LdapObjectClass {
|
|
||||||
fn from(value: Model) -> Self {
|
|
||||||
value.object_class
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
use sea_orm::entity::prelude::*;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use lldap_domain::types::LdapObjectClass;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
|
||||||
#[sea_orm(table_name = "user_object_classes")]
|
|
||||||
pub struct Model {
|
|
||||||
#[sea_orm(primary_key, auto_increment = false)]
|
|
||||||
pub lower_object_class: String,
|
|
||||||
pub object_class: LdapObjectClass,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
|
||||||
pub enum Relation {}
|
|
||||||
|
|
||||||
impl ActiveModelBehavior for ActiveModel {}
|
|
||||||
|
|
||||||
impl From<Model> for LdapObjectClass {
|
|
||||||
fn from(value: Model) -> Self {
|
|
||||||
value.object_class
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "lldap_domain"
|
|
||||||
version = "0.1.0"
|
|
||||||
authors = [
|
|
||||||
"Valentin Tolmer <valentin@tolmer.fr>",
|
|
||||||
"Simon Broeng Jensen <sbj@cwconsult.dk>",
|
|
||||||
]
|
|
||||||
edition.workspace = true
|
|
||||||
homepage.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
repository.workspace = true
|
|
||||||
|
|
||||||
[features]
|
|
||||||
test = []
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
anyhow = "*"
|
|
||||||
base64 = "0.21"
|
|
||||||
bincode = "1.3"
|
|
||||||
itertools = "0.10"
|
|
||||||
juniper = "0.15"
|
|
||||||
serde_bytes = "0.11"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
pretty_assertions = "1"
|
|
||||||
|
|
||||||
[dependencies.chrono]
|
|
||||||
features = ["serde"]
|
|
||||||
version = "*"
|
|
||||||
|
|
||||||
[dependencies.derive_more]
|
|
||||||
features = ["debug", "display", "from", "from_str"]
|
|
||||||
default-features = false
|
|
||||||
version = "1"
|
|
||||||
|
|
||||||
[dependencies.image]
|
|
||||||
features = ["jpeg"]
|
|
||||||
default-features = false
|
|
||||||
version = "0.24"
|
|
||||||
|
|
||||||
[dependencies.lldap_auth]
|
|
||||||
path = "../auth"
|
|
||||||
features = ["opaque_server", "opaque_client", "sea_orm"]
|
|
||||||
|
|
||||||
[dependencies.sea-orm]
|
|
||||||
workspace = true
|
|
||||||
features = [
|
|
||||||
"macros",
|
|
||||||
"with-chrono",
|
|
||||||
"with-uuid",
|
|
||||||
"sqlx-all",
|
|
||||||
"runtime-actix-rustls",
|
|
||||||
]
|
|
||||||
|
|
||||||
[dependencies.serde]
|
|
||||||
workspace = true
|
|
||||||
|
|
||||||
[dependencies.strum]
|
|
||||||
features = ["derive"]
|
|
||||||
version = "0.25"
|
|
||||||
|
|
||||||
[dependencies.uuid]
|
|
||||||
features = ["v1", "v3"]
|
|
||||||
version = "1"
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
use crate::types::{AttributeType, AttributeValue, JpegPhoto};
|
|
||||||
use anyhow::{Context as AnyhowContext, Result, bail};
|
|
||||||
|
|
||||||
pub fn deserialize_attribute_value(
|
|
||||||
value: &[String],
|
|
||||||
typ: AttributeType,
|
|
||||||
is_list: bool,
|
|
||||||
) -> Result<AttributeValue> {
|
|
||||||
if !is_list && value.len() != 1 {
|
|
||||||
bail!("Attribute is not a list, but multiple values were provided",);
|
|
||||||
}
|
|
||||||
let parse_int = |value: &String| -> Result<i64> {
|
|
||||||
value
|
|
||||||
.parse::<i64>()
|
|
||||||
.with_context(|| format!("Invalid integer value {value}"))
|
|
||||||
};
|
|
||||||
let parse_date = |value: &String| -> Result<chrono::NaiveDateTime> {
|
|
||||||
Ok(chrono::DateTime::parse_from_rfc3339(value)
|
|
||||||
.with_context(|| format!("Invalid date value {value}"))?
|
|
||||||
.naive_utc())
|
|
||||||
};
|
|
||||||
let parse_photo = |value: &String| -> Result<JpegPhoto> {
|
|
||||||
JpegPhoto::try_from(value.as_str()).context("Provided image is not a valid JPEG")
|
|
||||||
};
|
|
||||||
Ok(match (typ, is_list) {
|
|
||||||
(AttributeType::String, false) => value[0].clone().into(),
|
|
||||||
(AttributeType::String, true) => value.to_vec().into(),
|
|
||||||
(AttributeType::Integer, false) => (parse_int(&value[0])?).into(),
|
|
||||||
(AttributeType::Integer, true) => {
|
|
||||||
(value.iter().map(parse_int).collect::<Result<Vec<_>>>()?).into()
|
|
||||||
}
|
|
||||||
(AttributeType::DateTime, false) => (parse_date(&value[0])?).into(),
|
|
||||||
(AttributeType::DateTime, true) => {
|
|
||||||
(value.iter().map(parse_date).collect::<Result<Vec<_>>>()?).into()
|
|
||||||
}
|
|
||||||
(AttributeType::JpegPhoto, false) => (parse_photo(&value[0])?).into(),
|
|
||||||
(AttributeType::JpegPhoto, true) => {
|
|
||||||
(value.iter().map(parse_photo).collect::<Result<Vec<_>>>()?).into()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
pub mod deserialize;
|
|
||||||
pub mod public_schema;
|
|
||||||
pub mod requests;
|
|
||||||
pub mod schema;
|
|
||||||
pub mod types;
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::types::{Attribute, AttributeName, AttributeType, Email, GroupId, GroupName, UserId};
|
|
||||||
|
|
||||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Default)]
|
|
||||||
pub struct CreateUserRequest {
|
|
||||||
// Same fields as User, but no creation_date, and with password.
|
|
||||||
pub user_id: UserId,
|
|
||||||
pub email: Email,
|
|
||||||
pub display_name: Option<String>,
|
|
||||||
pub attributes: Vec<Attribute>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Default)]
|
|
||||||
pub struct UpdateUserRequest {
|
|
||||||
// Same fields as CreateUserRequest, but no with an extra layer of Option.
|
|
||||||
pub user_id: UserId,
|
|
||||||
pub email: Option<Email>,
|
|
||||||
pub display_name: Option<String>,
|
|
||||||
pub delete_attributes: Vec<AttributeName>,
|
|
||||||
pub insert_attributes: Vec<Attribute>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Default)]
|
|
||||||
pub struct CreateGroupRequest {
|
|
||||||
pub display_name: GroupName,
|
|
||||||
pub attributes: Vec<Attribute>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
|
||||||
pub struct UpdateGroupRequest {
|
|
||||||
pub group_id: GroupId,
|
|
||||||
pub display_name: Option<GroupName>,
|
|
||||||
pub delete_attributes: Vec<AttributeName>,
|
|
||||||
pub insert_attributes: Vec<Attribute>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
|
||||||
pub struct CreateAttributeRequest {
|
|
||||||
pub name: AttributeName,
|
|
||||||
pub attribute_type: AttributeType,
|
|
||||||
pub is_list: bool,
|
|
||||||
pub is_visible: bool,
|
|
||||||
pub is_editable: bool,
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::types::{AttributeName, AttributeType, LdapObjectClass};
|
|
||||||
|
|
||||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
|
||||||
pub struct Schema {
|
|
||||||
pub user_attributes: AttributeList,
|
|
||||||
pub group_attributes: AttributeList,
|
|
||||||
pub extra_user_object_classes: Vec<LdapObjectClass>,
|
|
||||||
pub extra_group_object_classes: Vec<LdapObjectClass>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
|
||||||
pub struct AttributeSchema {
|
|
||||||
pub name: AttributeName,
|
|
||||||
//TODO: pub aliases: Vec<String>,
|
|
||||||
pub attribute_type: AttributeType,
|
|
||||||
pub is_list: bool,
|
|
||||||
pub is_visible: bool,
|
|
||||||
pub is_editable: bool,
|
|
||||||
pub is_hardcoded: bool,
|
|
||||||
pub is_readonly: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
|
||||||
pub struct AttributeList {
|
|
||||||
pub attributes: Vec<AttributeSchema>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AttributeList {
|
|
||||||
pub fn get_attribute_schema(&self, name: &AttributeName) -> Option<&AttributeSchema> {
|
|
||||||
self.attributes.iter().find(|a| a.name == *name)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_attribute_type(&self, name: &AttributeName) -> Option<(AttributeType, bool)> {
|
|
||||||
self.get_attribute_schema(name)
|
|
||||||
.map(|a| (a.attribute_type, a.is_list))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn format_for_ldap_schema_description(&self) -> String {
|
|
||||||
self.attributes
|
|
||||||
.iter()
|
|
||||||
.map(|a| a.name.as_str())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(" $ ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "lldap_frontend_options"
|
|
||||||
version = "0.1.0"
|
|
||||||
description = "Frontend options for LLDAP"
|
|
||||||
authors.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
homepage.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
repository.workspace = true
|
|
||||||
|
|
||||||
[dependencies.serde]
|
|
||||||
workspace = true
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
|
||||||
pub struct Options {
|
|
||||||
pub password_reset_enabled: bool,
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "lldap_graphql_server"
|
|
||||||
version = "0.1.0"
|
|
||||||
description = "GraphQL server for LLDAP"
|
|
||||||
edition.workspace = true
|
|
||||||
authors.workspace = true
|
|
||||||
homepage.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
repository.workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
anyhow = "*"
|
|
||||||
juniper = "0.15"
|
|
||||||
serde_json = "1"
|
|
||||||
tracing = "*"
|
|
||||||
urlencoding = "2"
|
|
||||||
|
|
||||||
[dependencies.chrono]
|
|
||||||
features = ["serde"]
|
|
||||||
version = "*"
|
|
||||||
|
|
||||||
[dependencies.lldap_access_control]
|
|
||||||
path = "../access-control"
|
|
||||||
|
|
||||||
[dependencies.lldap_auth]
|
|
||||||
path = "../auth"
|
|
||||||
features = ["opaque_server", "opaque_client", "sea_orm"]
|
|
||||||
|
|
||||||
[dependencies.lldap_domain]
|
|
||||||
path = "../domain"
|
|
||||||
|
|
||||||
[dependencies.lldap_domain_model]
|
|
||||||
path = "../domain-model"
|
|
||||||
|
|
||||||
[dependencies.lldap_domain_handlers]
|
|
||||||
path = "../domain-handlers"
|
|
||||||
|
|
||||||
[dependencies.lldap_ldap]
|
|
||||||
path = "../ldap"
|
|
||||||
|
|
||||||
[dependencies.lldap_sql_backend_handler]
|
|
||||||
path = "../sql-backend-handler"
|
|
||||||
|
|
||||||
[dependencies.lldap_validation]
|
|
||||||
path = "../validation"
|
|
||||||
|
|
||||||
[dependencies.serde]
|
|
||||||
workspace = true
|
|
||||||
|
|
||||||
[dependencies.uuid]
|
|
||||||
features = ["v1", "v3"]
|
|
||||||
version = "1"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
mockall = "0.11.4"
|
|
||||||
pretty_assertions = "1"
|
|
||||||
|
|
||||||
#[dev-dependencies.lldap_auth]
|
|
||||||
#path = "../auth"
|
|
||||||
#features = ["test"]
|
|
||||||
#
|
|
||||||
#[dev-dependencies.lldap_opaque_handler]
|
|
||||||
#path = "../opaque-handler"
|
|
||||||
#features = ["test"]
|
|
||||||
|
|
||||||
[dev-dependencies.lldap_test_utils]
|
|
||||||
path = "../test-utils"
|
|
||||||
#
|
|
||||||
#[dev-dependencies.lldap_sql_backend_handler]
|
|
||||||
#path = "../sql-backend-handler"
|
|
||||||
#features = ["test"]
|
|
||||||
|
|
||||||
[dev-dependencies.tokio]
|
|
||||||
features = ["full"]
|
|
||||||
version = "1.25"
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
use crate::{mutation::Mutation, query::Query};
|
|
||||||
use juniper::{EmptySubscription, FieldError, RootNode};
|
|
||||||
use lldap_access_control::{
|
|
||||||
AccessControlledBackendHandler, AdminBackendHandler, ReadonlyBackendHandler,
|
|
||||||
UserReadableBackendHandler, UserWriteableBackendHandler,
|
|
||||||
};
|
|
||||||
use lldap_auth::{access_control::ValidationResults, types::UserId};
|
|
||||||
use lldap_domain_handlers::handler::BackendHandler;
|
|
||||||
use tracing::debug;
|
|
||||||
|
|
||||||
pub struct Context<Handler: BackendHandler> {
|
|
||||||
pub handler: AccessControlledBackendHandler<Handler>,
|
|
||||||
pub validation_result: ValidationResults,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn field_error_callback<'a>(
|
|
||||||
span: &'a tracing::Span,
|
|
||||||
error_message: &'a str,
|
|
||||||
) -> impl 'a + FnOnce() -> FieldError {
|
|
||||||
move || {
|
|
||||||
span.in_scope(|| debug!("Unauthorized"));
|
|
||||||
FieldError::from(error_message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<Handler: BackendHandler> Context<Handler> {
|
|
||||||
#[cfg(test)]
|
|
||||||
pub fn new_for_tests(handler: Handler, validation_result: ValidationResults) -> Self {
|
|
||||||
Self {
|
|
||||||
handler: AccessControlledBackendHandler::new(handler),
|
|
||||||
validation_result,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_admin_handler(&self) -> Option<&(impl AdminBackendHandler + use<Handler>)> {
|
|
||||||
self.handler.get_admin_handler(&self.validation_result)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_readonly_handler(&self) -> Option<&(impl ReadonlyBackendHandler + use<Handler>)> {
|
|
||||||
self.handler.get_readonly_handler(&self.validation_result)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_writeable_handler(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
) -> Option<&(impl UserWriteableBackendHandler + use<Handler>)> {
|
|
||||||
self.handler
|
|
||||||
.get_writeable_handler(&self.validation_result, user_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_readable_handler(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
) -> Option<&(impl UserReadableBackendHandler + use<Handler>)> {
|
|
||||||
self.handler
|
|
||||||
.get_readable_handler(&self.validation_result, user_id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<Handler: BackendHandler> juniper::Context for Context<Handler> {}
|
|
||||||
|
|
||||||
type Schema<Handler> =
|
|
||||||
RootNode<'static, Query<Handler>, Mutation<Handler>, EmptySubscription<Context<Handler>>>;
|
|
||||||
|
|
||||||
pub fn schema<Handler: BackendHandler>() -> Schema<Handler> {
|
|
||||||
Schema::new(
|
|
||||||
Query::<Handler>::new(),
|
|
||||||
Mutation::<Handler>::new(),
|
|
||||||
EmptySubscription::<Context<Handler>>::new(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn export_schema(output_file: Option<String>) -> anyhow::Result<()> {
|
|
||||||
use anyhow::Context;
|
|
||||||
use lldap_sql_backend_handler::SqlBackendHandler;
|
|
||||||
let output = schema::<SqlBackendHandler>().as_schema_language();
|
|
||||||
match output_file {
|
|
||||||
None => println!("{output}"),
|
|
||||||
Some(path) => {
|
|
||||||
use std::fs::File;
|
|
||||||
use std::io::prelude::*;
|
|
||||||
use std::path::Path;
|
|
||||||
let path = Path::new(&path);
|
|
||||||
let mut file =
|
|
||||||
File::create(path).context(format!("unable to open '{}'", path.display()))?;
|
|
||||||
file.write_all(output.as_bytes())
|
|
||||||
.context(format!("unable to write in '{}'", path.display()))?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,66 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "lldap_ldap"
|
|
||||||
version = "0.1.0"
|
|
||||||
description = "LDAP operations support"
|
|
||||||
authors.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
homepage.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
repository.workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
anyhow = "*"
|
|
||||||
ldap3_proto = "0.6.0"
|
|
||||||
tracing = "*"
|
|
||||||
itertools = "0.10"
|
|
||||||
|
|
||||||
[dependencies.derive_more]
|
|
||||||
features = ["from"]
|
|
||||||
default-features = false
|
|
||||||
version = "1"
|
|
||||||
|
|
||||||
[dependencies.chrono]
|
|
||||||
features = ["serde"]
|
|
||||||
version = "*"
|
|
||||||
|
|
||||||
[dependencies.rand]
|
|
||||||
features = ["small_rng", "getrandom"]
|
|
||||||
version = "0.8"
|
|
||||||
|
|
||||||
[dependencies.uuid]
|
|
||||||
version = "1"
|
|
||||||
features = ["v1", "v3"]
|
|
||||||
|
|
||||||
[dependencies.lldap_access_control]
|
|
||||||
path = "../access-control"
|
|
||||||
|
|
||||||
[dependencies.lldap_auth]
|
|
||||||
path = "../auth"
|
|
||||||
features = ["opaque_server", "opaque_client", "sea_orm"]
|
|
||||||
|
|
||||||
[dependencies.lldap_domain]
|
|
||||||
path = "../domain"
|
|
||||||
|
|
||||||
[dependencies.lldap_domain_handlers]
|
|
||||||
path = "../domain-handlers"
|
|
||||||
|
|
||||||
[dependencies.lldap_domain_model]
|
|
||||||
path = "../domain-model"
|
|
||||||
|
|
||||||
[dependencies.lldap_opaque_handler]
|
|
||||||
path = "../opaque-handler"
|
|
||||||
|
|
||||||
[dev-dependencies.lldap_test_utils]
|
|
||||||
path = "../test-utils"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
mockall = "0.11.4"
|
|
||||||
pretty_assertions = "1"
|
|
||||||
|
|
||||||
[dev-dependencies.tokio]
|
|
||||||
features = ["full"]
|
|
||||||
version = "1.25"
|
|
||||||
|
|
||||||
[dev-dependencies.lldap_domain]
|
|
||||||
path = "../domain"
|
|
||||||
features = ["test"]
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
use crate::core::error::{LdapError, LdapResult};
|
|
||||||
use ldap3_proto::proto::{LdapCompareRequest, LdapOp, LdapResult as LdapResultOp, LdapResultCode};
|
|
||||||
use lldap_domain::types::AttributeName;
|
|
||||||
|
|
||||||
pub fn compare(
|
|
||||||
request: LdapCompareRequest,
|
|
||||||
search_results: Vec<LdapOp>,
|
|
||||||
base_dn_str: &str,
|
|
||||||
) -> LdapResult<Vec<LdapOp>> {
|
|
||||||
if search_results.len() > 2 {
|
|
||||||
// SearchResultEntry + SearchResultDone
|
|
||||||
return Err(LdapError {
|
|
||||||
code: LdapResultCode::OperationsError,
|
|
||||||
message: "Too many search results".to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let requested_attribute = AttributeName::from(&request.atype);
|
|
||||||
match search_results.first() {
|
|
||||||
Some(LdapOp::SearchResultEntry(entry)) => {
|
|
||||||
let available = entry.attributes.iter().any(|attr| {
|
|
||||||
AttributeName::from(&attr.atype) == requested_attribute
|
|
||||||
&& attr.vals.contains(&request.val)
|
|
||||||
});
|
|
||||||
Ok(vec![LdapOp::CompareResult(LdapResultOp {
|
|
||||||
code: if available {
|
|
||||||
LdapResultCode::CompareTrue
|
|
||||||
} else {
|
|
||||||
LdapResultCode::CompareFalse
|
|
||||||
},
|
|
||||||
matcheddn: request.dn,
|
|
||||||
message: "".to_string(),
|
|
||||||
referral: vec![],
|
|
||||||
})])
|
|
||||||
}
|
|
||||||
Some(LdapOp::SearchResultDone(_)) => Ok(vec![LdapOp::CompareResult(LdapResultOp {
|
|
||||||
code: LdapResultCode::NoSuchObject,
|
|
||||||
matcheddn: base_dn_str.to_string(),
|
|
||||||
message: "".to_string(),
|
|
||||||
referral: vec![],
|
|
||||||
})]),
|
|
||||||
None => Err(LdapError {
|
|
||||||
code: LdapResultCode::OperationsError,
|
|
||||||
message: "Search request returned nothing".to_string(),
|
|
||||||
}),
|
|
||||||
_ => Err(LdapError {
|
|
||||||
code: LdapResultCode::OperationsError,
|
|
||||||
message: "Unexpected results from search".to_string(),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::handler::tests::setup_bound_admin_handler;
|
|
||||||
use chrono::TimeZone;
|
|
||||||
use lldap_domain::{
|
|
||||||
types::{Group, GroupId, User, UserAndGroups, UserId},
|
|
||||||
uuid,
|
|
||||||
};
|
|
||||||
use lldap_domain_handlers::handler::{GroupRequestFilter, UserRequestFilter};
|
|
||||||
use lldap_test_utils::MockTestBackendHandler;
|
|
||||||
use pretty_assertions::assert_eq;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_compare_user() {
|
|
||||||
let mut mock = MockTestBackendHandler::new();
|
|
||||||
mock.expect_list_users().returning(|f, g| {
|
|
||||||
assert_eq!(f, Some(UserRequestFilter::UserId(UserId::new("bob"))));
|
|
||||||
assert!(!g);
|
|
||||||
Ok(vec![UserAndGroups {
|
|
||||||
user: User {
|
|
||||||
user_id: UserId::new("bob"),
|
|
||||||
email: "bob@bobmail.bob".into(),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
groups: None,
|
|
||||||
}])
|
|
||||||
});
|
|
||||||
mock.expect_list_groups().returning(|_| Ok(vec![]));
|
|
||||||
let ldap_handler = setup_bound_admin_handler(mock).await;
|
|
||||||
let dn = "uid=bob,ou=people,dc=example,dc=com";
|
|
||||||
let request = LdapCompareRequest {
|
|
||||||
dn: dn.to_string(),
|
|
||||||
atype: "uid".to_owned(),
|
|
||||||
val: b"bob".to_vec(),
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.do_compare(request).await,
|
|
||||||
Ok(vec![LdapOp::CompareResult(LdapResultOp {
|
|
||||||
code: LdapResultCode::CompareTrue,
|
|
||||||
matcheddn: dn.to_string(),
|
|
||||||
message: "".to_string(),
|
|
||||||
referral: vec![],
|
|
||||||
})])
|
|
||||||
);
|
|
||||||
// Non-canonical attribute.
|
|
||||||
let request = LdapCompareRequest {
|
|
||||||
dn: dn.to_string(),
|
|
||||||
atype: "eMail".to_owned(),
|
|
||||||
val: b"bob@bobmail.bob".to_vec(),
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.do_compare(request).await,
|
|
||||||
Ok(vec![LdapOp::CompareResult(LdapResultOp {
|
|
||||||
code: LdapResultCode::CompareTrue,
|
|
||||||
matcheddn: dn.to_string(),
|
|
||||||
message: "".to_string(),
|
|
||||||
referral: vec![],
|
|
||||||
})])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_compare_group() {
|
|
||||||
let mut mock = MockTestBackendHandler::new();
|
|
||||||
mock.expect_list_users().returning(|_, _| Ok(vec![]));
|
|
||||||
mock.expect_list_groups().returning(|f| {
|
|
||||||
assert_eq!(f, Some(GroupRequestFilter::DisplayName("group".into())));
|
|
||||||
Ok(vec![Group {
|
|
||||||
id: GroupId(1),
|
|
||||||
display_name: "group".into(),
|
|
||||||
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
|
||||||
users: vec![UserId::new("bob")],
|
|
||||||
uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"),
|
|
||||||
attributes: Vec::new(),
|
|
||||||
modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
|
||||||
}])
|
|
||||||
});
|
|
||||||
let ldap_handler = setup_bound_admin_handler(mock).await;
|
|
||||||
let dn = "uid=group,ou=groups,dc=example,dc=com";
|
|
||||||
let request = LdapCompareRequest {
|
|
||||||
dn: dn.to_string(),
|
|
||||||
atype: "uid".to_owned(),
|
|
||||||
val: b"group".to_vec(),
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.do_compare(request).await,
|
|
||||||
Ok(vec![LdapOp::CompareResult(LdapResultOp {
|
|
||||||
code: LdapResultCode::CompareTrue,
|
|
||||||
matcheddn: dn.to_string(),
|
|
||||||
message: "".to_string(),
|
|
||||||
referral: vec![],
|
|
||||||
})])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_compare_not_found() {
|
|
||||||
let mut mock = MockTestBackendHandler::new();
|
|
||||||
mock.expect_list_users().returning(|f, g| {
|
|
||||||
assert_eq!(f, Some(UserRequestFilter::UserId(UserId::new("bob"))));
|
|
||||||
assert!(!g);
|
|
||||||
Ok(vec![])
|
|
||||||
});
|
|
||||||
mock.expect_list_groups().returning(|_| Ok(vec![]));
|
|
||||||
let ldap_handler = setup_bound_admin_handler(mock).await;
|
|
||||||
let dn = "uid=bob,ou=people,dc=example,dc=com";
|
|
||||||
let request = LdapCompareRequest {
|
|
||||||
dn: dn.to_string(),
|
|
||||||
atype: "uid".to_owned(),
|
|
||||||
val: b"bob".to_vec(),
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.do_compare(request).await,
|
|
||||||
Ok(vec![LdapOp::CompareResult(LdapResultOp {
|
|
||||||
code: LdapResultCode::NoSuchObject,
|
|
||||||
matcheddn: "dc=example,dc=com".to_owned(),
|
|
||||||
message: "".to_string(),
|
|
||||||
referral: vec![],
|
|
||||||
})])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_compare_no_match() {
|
|
||||||
let mut mock = MockTestBackendHandler::new();
|
|
||||||
mock.expect_list_users().returning(|f, g| {
|
|
||||||
assert_eq!(f, Some(UserRequestFilter::UserId(UserId::new("bob"))));
|
|
||||||
assert!(!g);
|
|
||||||
Ok(vec![UserAndGroups {
|
|
||||||
user: User {
|
|
||||||
user_id: UserId::new("bob"),
|
|
||||||
email: "bob@bobmail.bob".into(),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
groups: None,
|
|
||||||
}])
|
|
||||||
});
|
|
||||||
mock.expect_list_groups().returning(|_| Ok(vec![]));
|
|
||||||
let ldap_handler = setup_bound_admin_handler(mock).await;
|
|
||||||
let dn = "uid=bob,ou=people,dc=example,dc=com";
|
|
||||||
let request = LdapCompareRequest {
|
|
||||||
dn: dn.to_string(),
|
|
||||||
atype: "mail".to_owned(),
|
|
||||||
val: b"bob@bob".to_vec(),
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.do_compare(request).await,
|
|
||||||
Ok(vec![LdapOp::CompareResult(LdapResultOp {
|
|
||||||
code: LdapResultCode::CompareFalse,
|
|
||||||
matcheddn: dn.to_string(),
|
|
||||||
message: "".to_string(),
|
|
||||||
referral: vec![],
|
|
||||||
})])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_compare_group_member() {
|
|
||||||
let mut mock = MockTestBackendHandler::new();
|
|
||||||
mock.expect_list_users().returning(|_, _| Ok(vec![]));
|
|
||||||
mock.expect_list_groups().returning(|f| {
|
|
||||||
assert_eq!(f, Some(GroupRequestFilter::DisplayName("group".into())));
|
|
||||||
Ok(vec![Group {
|
|
||||||
id: GroupId(1),
|
|
||||||
display_name: "group".into(),
|
|
||||||
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
|
||||||
users: vec![UserId::new("bob")],
|
|
||||||
uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"),
|
|
||||||
attributes: Vec::new(),
|
|
||||||
modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
|
||||||
}])
|
|
||||||
});
|
|
||||||
let ldap_handler = setup_bound_admin_handler(mock).await;
|
|
||||||
let dn = "uid=group,ou=groups,dc=example,dc=com";
|
|
||||||
let request = LdapCompareRequest {
|
|
||||||
dn: dn.to_string(),
|
|
||||||
atype: "uniqueMember".to_owned(),
|
|
||||||
val: b"uid=bob,ou=people,dc=example,dc=com".to_vec(),
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.do_compare(request).await,
|
|
||||||
Ok(vec![LdapOp::CompareResult(LdapResultOp {
|
|
||||||
code: LdapResultCode::CompareTrue,
|
|
||||||
matcheddn: dn.to_owned(),
|
|
||||||
message: "".to_string(),
|
|
||||||
referral: vec![],
|
|
||||||
})])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,524 +0,0 @@
|
|||||||
use crate::core::{
|
|
||||||
error::{LdapError, LdapResult},
|
|
||||||
group::{REQUIRED_GROUP_ATTRIBUTES, get_default_group_object_classes},
|
|
||||||
user::{REQUIRED_USER_ATTRIBUTES, get_default_user_object_classes},
|
|
||||||
};
|
|
||||||
use chrono::TimeZone;
|
|
||||||
use itertools::join;
|
|
||||||
use ldap3_proto::LdapResultCode;
|
|
||||||
use lldap_domain::{
|
|
||||||
public_schema::PublicSchema,
|
|
||||||
schema::{AttributeList, Schema},
|
|
||||||
types::{
|
|
||||||
Attribute, AttributeName, AttributeType, AttributeValue, Cardinality, GroupName,
|
|
||||||
LdapObjectClass, UserId,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
use lldap_domain_model::model::UserColumn;
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
use tracing::{debug, instrument, warn};
|
|
||||||
|
|
||||||
fn make_dn_pair<I>(mut iter: I) -> LdapResult<(String, String)>
|
|
||||||
where
|
|
||||||
I: Iterator<Item = String>,
|
|
||||||
{
|
|
||||||
(|| {
|
|
||||||
let pair = (
|
|
||||||
iter.next().ok_or_else(|| "Empty DN element".to_string())?,
|
|
||||||
iter.next().ok_or_else(|| "Missing DN value".to_string())?,
|
|
||||||
);
|
|
||||||
if let Some(e) = iter.next() {
|
|
||||||
Err(format!(
|
|
||||||
r#"Too many elements in distinguished name: "{}", "{}", "{}""#,
|
|
||||||
pair.0, pair.1, e
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
Ok(pair)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
.map_err(|e| LdapError {
|
|
||||||
code: LdapResultCode::InvalidDNSyntax,
|
|
||||||
message: e,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_distinguished_name(dn: &str) -> LdapResult<Vec<(String, String)>> {
|
|
||||||
assert!(dn == dn.to_ascii_lowercase());
|
|
||||||
dn.split(',')
|
|
||||||
.map(|s| make_dn_pair(s.split('=').map(str::trim).map(String::from)))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum UserOrGroupName {
|
|
||||||
User(UserId),
|
|
||||||
Group(GroupName),
|
|
||||||
BadSubStree,
|
|
||||||
UnexpectedFormat,
|
|
||||||
InvalidSyntax(LdapError),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UserOrGroupName {
|
|
||||||
pub fn into_ldap_error(self, input: &str, expected_format: String) -> LdapError {
|
|
||||||
LdapError {
|
|
||||||
code: LdapResultCode::InvalidDNSyntax,
|
|
||||||
message: match self {
|
|
||||||
UserOrGroupName::BadSubStree => "Not a subtree of the base tree".to_string(),
|
|
||||||
UserOrGroupName::InvalidSyntax(err) => return err,
|
|
||||||
UserOrGroupName::UnexpectedFormat
|
|
||||||
| UserOrGroupName::User(_)
|
|
||||||
| UserOrGroupName::Group(_) => {
|
|
||||||
format!(r#"Unexpected DN format. Got "{input}", expected: {expected_format}"#)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_user_or_group_id_from_distinguished_name(
|
|
||||||
dn: &str,
|
|
||||||
base_tree: &[(String, String)],
|
|
||||||
) -> UserOrGroupName {
|
|
||||||
let parts = match parse_distinguished_name(dn) {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(e) => return UserOrGroupName::InvalidSyntax(e),
|
|
||||||
};
|
|
||||||
if !is_subtree(&parts, base_tree) {
|
|
||||||
return UserOrGroupName::BadSubStree;
|
|
||||||
} else if parts.len() == base_tree.len() + 2
|
|
||||||
&& parts[1].0 == "ou"
|
|
||||||
&& (parts[0].0 == "cn" || parts[0].0 == "uid")
|
|
||||||
{
|
|
||||||
if parts[1].1 == "groups" {
|
|
||||||
return UserOrGroupName::Group(GroupName::from(parts[0].1.clone()));
|
|
||||||
} else if parts[1].1 == "people" {
|
|
||||||
return UserOrGroupName::User(UserId::from(parts[0].1.clone()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
UserOrGroupName::UnexpectedFormat
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_user_id_from_distinguished_name(
|
|
||||||
dn: &str,
|
|
||||||
base_tree: &[(String, String)],
|
|
||||||
base_dn_str: &str,
|
|
||||||
) -> LdapResult<UserId> {
|
|
||||||
match get_user_or_group_id_from_distinguished_name(dn, base_tree) {
|
|
||||||
UserOrGroupName::User(user_id) => Ok(user_id),
|
|
||||||
err => Err(err.into_ldap_error(dn, format!(r#""uid=id,ou=people,{base_dn_str}""#))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_group_id_from_distinguished_name(
|
|
||||||
dn: &str,
|
|
||||||
base_tree: &[(String, String)],
|
|
||||||
base_dn_str: &str,
|
|
||||||
) -> LdapResult<GroupName> {
|
|
||||||
match get_user_or_group_id_from_distinguished_name(dn, base_tree) {
|
|
||||||
UserOrGroupName::Group(group_name) => Ok(group_name),
|
|
||||||
err => Err(err.into_ldap_error(dn, format!(r#""uid=id,ou=groups,{base_dn_str}""#))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn looks_like_distinguished_name(dn: &str) -> bool {
|
|
||||||
dn.contains('=') || dn.contains(',')
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_user_id_from_distinguished_name_or_plain_name(
|
|
||||||
dn: &str,
|
|
||||||
base_tree: &[(String, String)],
|
|
||||||
base_dn_str: &str,
|
|
||||||
) -> LdapResult<UserId> {
|
|
||||||
if !looks_like_distinguished_name(dn) {
|
|
||||||
Ok(UserId::from(dn))
|
|
||||||
} else {
|
|
||||||
get_user_id_from_distinguished_name(dn, base_tree, base_dn_str)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_group_id_from_distinguished_name_or_plain_name(
|
|
||||||
dn: &str,
|
|
||||||
base_tree: &[(String, String)],
|
|
||||||
base_dn_str: &str,
|
|
||||||
) -> LdapResult<GroupName> {
|
|
||||||
if !looks_like_distinguished_name(dn) {
|
|
||||||
Ok(GroupName::from(dn))
|
|
||||||
} else {
|
|
||||||
get_group_id_from_distinguished_name(dn, base_tree, base_dn_str)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct ExpandedAttributes {
|
|
||||||
// Lowercase name to original name.
|
|
||||||
pub attribute_keys: BTreeMap<AttributeName, String>,
|
|
||||||
pub include_custom_attributes: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(all_attribute_keys), level = "debug")]
|
|
||||||
pub fn expand_attribute_wildcards(
|
|
||||||
ldap_attributes: &[String],
|
|
||||||
all_attribute_keys: &[&'static str],
|
|
||||||
) -> ExpandedAttributes {
|
|
||||||
let mut include_custom_attributes = false;
|
|
||||||
let mut attributes_out: BTreeMap<_, _> = ldap_attributes
|
|
||||||
.iter()
|
|
||||||
.filter(|&s| s != "*" && s != "+" && s != "1.1")
|
|
||||||
.map(|s| (AttributeName::from(s), s.to_string()))
|
|
||||||
.collect();
|
|
||||||
attributes_out.extend(
|
|
||||||
if ldap_attributes.iter().any(|x| x == "*") || ldap_attributes.is_empty() {
|
|
||||||
include_custom_attributes = true;
|
|
||||||
all_attribute_keys
|
|
||||||
} else {
|
|
||||||
&[]
|
|
||||||
}
|
|
||||||
.iter()
|
|
||||||
.map(|&s| (AttributeName::from(s), s.to_string())),
|
|
||||||
);
|
|
||||||
debug!(?attributes_out);
|
|
||||||
ExpandedAttributes {
|
|
||||||
attribute_keys: attributes_out,
|
|
||||||
include_custom_attributes,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_subtree(subtree: &[(String, String)], base_tree: &[(String, String)]) -> bool {
|
|
||||||
for (k, v) in subtree {
|
|
||||||
assert!(k == &k.to_ascii_lowercase());
|
|
||||||
assert!(v == &v.to_ascii_lowercase());
|
|
||||||
}
|
|
||||||
for (k, v) in base_tree {
|
|
||||||
assert!(k == &k.to_ascii_lowercase());
|
|
||||||
assert!(v == &v.to_ascii_lowercase());
|
|
||||||
}
|
|
||||||
if subtree.len() < base_tree.len() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let size_diff = subtree.len() - base_tree.len();
|
|
||||||
for i in 0..base_tree.len() {
|
|
||||||
if subtree[size_diff + i] != base_tree[i] {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum UserFieldType {
|
|
||||||
NoMatch,
|
|
||||||
ObjectClass,
|
|
||||||
MemberOf,
|
|
||||||
Dn,
|
|
||||||
EntryDn,
|
|
||||||
PrimaryField(UserColumn),
|
|
||||||
Attribute(AttributeName, AttributeType, bool),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn map_user_field(field: &AttributeName, schema: &PublicSchema) -> UserFieldType {
|
|
||||||
match field.as_str() {
|
|
||||||
"memberof" | "ismemberof" => UserFieldType::MemberOf,
|
|
||||||
"objectclass" => UserFieldType::ObjectClass,
|
|
||||||
"dn" | "distinguishedname" => UserFieldType::Dn,
|
|
||||||
"entrydn" => UserFieldType::EntryDn,
|
|
||||||
"uid" | "user_id" | "id" => UserFieldType::PrimaryField(UserColumn::UserId),
|
|
||||||
"mail" | "email" => UserFieldType::PrimaryField(UserColumn::Email),
|
|
||||||
"cn" | "displayname" | "display_name" => {
|
|
||||||
UserFieldType::PrimaryField(UserColumn::DisplayName)
|
|
||||||
}
|
|
||||||
"givenname" | "first_name" | "firstname" => UserFieldType::Attribute(
|
|
||||||
AttributeName::from("first_name"),
|
|
||||||
AttributeType::String,
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
"sn" | "last_name" | "lastname" => UserFieldType::Attribute(
|
|
||||||
AttributeName::from("last_name"),
|
|
||||||
AttributeType::String,
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
"avatar" | "jpegphoto" => UserFieldType::Attribute(
|
|
||||||
AttributeName::from("avatar"),
|
|
||||||
AttributeType::JpegPhoto,
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
"creationdate" | "createtimestamp" | "creation_date" => {
|
|
||||||
UserFieldType::PrimaryField(UserColumn::CreationDate)
|
|
||||||
}
|
|
||||||
"modifytimestamp" | "modifydate" | "modified_date" => {
|
|
||||||
UserFieldType::PrimaryField(UserColumn::ModifiedDate)
|
|
||||||
}
|
|
||||||
"pwdchangedtime" | "passwordmodifydate" | "password_modified_date" => {
|
|
||||||
UserFieldType::PrimaryField(UserColumn::PasswordModifiedDate)
|
|
||||||
}
|
|
||||||
"entryuuid" | "uuid" => UserFieldType::PrimaryField(UserColumn::Uuid),
|
|
||||||
_ => schema
|
|
||||||
.get_schema()
|
|
||||||
.user_attributes
|
|
||||||
.get_attribute_type(field)
|
|
||||||
.map(|(t, is_list)| UserFieldType::Attribute(field.clone(), t, is_list))
|
|
||||||
.unwrap_or(UserFieldType::NoMatch),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum GroupFieldType {
|
|
||||||
NoMatch,
|
|
||||||
GroupId,
|
|
||||||
DisplayName,
|
|
||||||
CreationDate,
|
|
||||||
ModifiedDate,
|
|
||||||
ObjectClass,
|
|
||||||
Dn,
|
|
||||||
// Like Dn, but returned as part of the attributes.
|
|
||||||
EntryDn,
|
|
||||||
Member,
|
|
||||||
Uuid,
|
|
||||||
Attribute(AttributeName, AttributeType, bool),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn map_group_field(field: &AttributeName, schema: &PublicSchema) -> GroupFieldType {
|
|
||||||
match field.as_str() {
|
|
||||||
"dn" | "distinguishedname" => GroupFieldType::Dn,
|
|
||||||
"entrydn" => GroupFieldType::EntryDn,
|
|
||||||
"objectclass" => GroupFieldType::ObjectClass,
|
|
||||||
"cn" | "displayname" | "uid" | "display_name" | "id" => GroupFieldType::DisplayName,
|
|
||||||
"creationdate" | "createtimestamp" | "creation_date" => GroupFieldType::CreationDate,
|
|
||||||
"modifytimestamp" | "modifydate" | "modified_date" => GroupFieldType::ModifiedDate,
|
|
||||||
"member" | "uniquemember" => GroupFieldType::Member,
|
|
||||||
"entryuuid" | "uuid" => GroupFieldType::Uuid,
|
|
||||||
"group_id" | "groupid" => GroupFieldType::GroupId,
|
|
||||||
_ => schema
|
|
||||||
.get_schema()
|
|
||||||
.group_attributes
|
|
||||||
.get_attribute_type(field)
|
|
||||||
.map(|(t, is_list)| GroupFieldType::Attribute(field.clone(), t, is_list))
|
|
||||||
.unwrap_or(GroupFieldType::NoMatch),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct LdapInfo {
|
|
||||||
pub base_dn: Vec<(String, String)>,
|
|
||||||
pub base_dn_str: String,
|
|
||||||
pub ignored_user_attributes: Vec<AttributeName>,
|
|
||||||
pub ignored_group_attributes: Vec<AttributeName>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_custom_attribute(
|
|
||||||
attributes: &[Attribute],
|
|
||||||
attribute_name: &AttributeName,
|
|
||||||
) -> Option<Vec<Vec<u8>>> {
|
|
||||||
let convert_date = |date| {
|
|
||||||
chrono::Utc
|
|
||||||
.from_utc_datetime(date)
|
|
||||||
.to_rfc3339()
|
|
||||||
.into_bytes()
|
|
||||||
};
|
|
||||||
attributes
|
|
||||||
.iter()
|
|
||||||
.find(|a| &a.name == attribute_name)
|
|
||||||
.map(|attribute| match &attribute.value {
|
|
||||||
AttributeValue::String(Cardinality::Singleton(s)) => {
|
|
||||||
vec![s.clone().into_bytes()]
|
|
||||||
}
|
|
||||||
AttributeValue::String(Cardinality::Unbounded(l)) => {
|
|
||||||
l.iter().map(|s| s.clone().into_bytes()).collect()
|
|
||||||
}
|
|
||||||
AttributeValue::Integer(Cardinality::Singleton(i)) => {
|
|
||||||
// LDAP integers are encoded as strings.
|
|
||||||
vec![i.to_string().into_bytes()]
|
|
||||||
}
|
|
||||||
AttributeValue::Integer(Cardinality::Unbounded(l)) => l
|
|
||||||
.iter()
|
|
||||||
// LDAP integers are encoded as strings.
|
|
||||||
.map(|i| i.to_string().into_bytes())
|
|
||||||
.collect(),
|
|
||||||
AttributeValue::JpegPhoto(Cardinality::Singleton(p)) => {
|
|
||||||
vec![p.clone().into_bytes()]
|
|
||||||
}
|
|
||||||
AttributeValue::JpegPhoto(Cardinality::Unbounded(l)) => {
|
|
||||||
l.iter().map(|p| p.clone().into_bytes()).collect()
|
|
||||||
}
|
|
||||||
AttributeValue::DateTime(Cardinality::Singleton(dt)) => vec![convert_date(dt)],
|
|
||||||
AttributeValue::DateTime(Cardinality::Unbounded(l)) => {
|
|
||||||
l.iter().map(convert_date).collect()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(derive_more::From)]
|
|
||||||
pub struct ObjectClassList(Vec<LdapObjectClass>);
|
|
||||||
|
|
||||||
// See RFC4512 section 4.2.1 "objectClasses"
|
|
||||||
impl ObjectClassList {
|
|
||||||
pub fn format_for_ldap_schema_description(&self) -> String {
|
|
||||||
join(self.0.iter().map(|c| format!("'{c}'")), " ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// See RFC4512 section 4.2 "Subschema Subentries"
|
|
||||||
// This struct holds all information on what attributes and objectclasses are present on the server.
|
|
||||||
// It can be used to 'index' a server using a LDAP subschema call.
|
|
||||||
pub struct LdapSchemaDescription {
|
|
||||||
base: PublicSchema,
|
|
||||||
user_object_classes: ObjectClassList,
|
|
||||||
group_object_classes: ObjectClassList,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LdapSchemaDescription {
|
|
||||||
pub fn from(schema: PublicSchema) -> Self {
|
|
||||||
let mut user_object_classes = get_default_user_object_classes();
|
|
||||||
user_object_classes.extend(schema.get_schema().extra_user_object_classes.clone());
|
|
||||||
let mut group_object_classes = get_default_group_object_classes();
|
|
||||||
group_object_classes.extend(schema.get_schema().extra_group_object_classes.clone());
|
|
||||||
|
|
||||||
Self {
|
|
||||||
base: schema,
|
|
||||||
user_object_classes: ObjectClassList(user_object_classes),
|
|
||||||
group_object_classes: ObjectClassList(group_object_classes),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn schema(&self) -> &Schema {
|
|
||||||
self.base.get_schema()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn user_object_classes(&self) -> &ObjectClassList {
|
|
||||||
&self.user_object_classes
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn group_object_classes(&self) -> &ObjectClassList {
|
|
||||||
&self.group_object_classes
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn required_user_attributes(&self) -> AttributeList {
|
|
||||||
let attributes = self
|
|
||||||
.schema()
|
|
||||||
.user_attributes
|
|
||||||
.attributes
|
|
||||||
.iter()
|
|
||||||
.filter(|a| REQUIRED_USER_ATTRIBUTES.contains(&a.name.as_str()))
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
AttributeList { attributes }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn optional_user_attributes(&self) -> AttributeList {
|
|
||||||
let attributes = self
|
|
||||||
.schema()
|
|
||||||
.user_attributes
|
|
||||||
.attributes
|
|
||||||
.iter()
|
|
||||||
.filter(|a| !REQUIRED_USER_ATTRIBUTES.contains(&a.name.as_str()))
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
AttributeList { attributes }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn required_group_attributes(&self) -> AttributeList {
|
|
||||||
let attributes = self
|
|
||||||
.schema()
|
|
||||||
.group_attributes
|
|
||||||
.attributes
|
|
||||||
.iter()
|
|
||||||
.filter(|a| REQUIRED_GROUP_ATTRIBUTES.contains(&a.name.as_str()))
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
AttributeList { attributes }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn optional_group_attributes(&self) -> AttributeList {
|
|
||||||
let attributes = self
|
|
||||||
.schema()
|
|
||||||
.group_attributes
|
|
||||||
.attributes
|
|
||||||
.iter()
|
|
||||||
.filter(|a| !REQUIRED_GROUP_ATTRIBUTES.contains(&a.name.as_str()))
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
AttributeList { attributes }
|
|
||||||
}
|
|
||||||
|
|
||||||
// See RFC4512 section 4.2.2 "attributeTypes"
|
|
||||||
// Parameter 'index_offset' is an offset for the enumeration of this list of attributes,
|
|
||||||
// it has been preceeded by the list of hardcoded attributes.
|
|
||||||
pub fn formatted_attribute_list(
|
|
||||||
&self,
|
|
||||||
index_offset: usize,
|
|
||||||
exclude_attributes: Vec<&str>,
|
|
||||||
) -> Vec<Vec<u8>> {
|
|
||||||
let mut formatted_list: Vec<Vec<u8>> = Vec::new();
|
|
||||||
|
|
||||||
for (index, attribute) in self
|
|
||||||
.all_attributes()
|
|
||||||
.attributes
|
|
||||||
.into_iter()
|
|
||||||
.filter(|attr| !exclude_attributes.contains(&attr.name.as_str()))
|
|
||||||
.enumerate()
|
|
||||||
{
|
|
||||||
formatted_list.push(
|
|
||||||
format!(
|
|
||||||
"( 10.{} NAME '{}' DESC 'LLDAP: {}' SUP {:?} )",
|
|
||||||
(index + index_offset),
|
|
||||||
attribute.name,
|
|
||||||
if attribute.is_hardcoded {
|
|
||||||
"builtin attribute"
|
|
||||||
} else {
|
|
||||||
"custom attribute"
|
|
||||||
},
|
|
||||||
attribute.attribute_type
|
|
||||||
)
|
|
||||||
.into_bytes()
|
|
||||||
.to_vec(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
formatted_list
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn all_attributes(&self) -> AttributeList {
|
|
||||||
let mut combined_attributes = self.schema().user_attributes.attributes.clone();
|
|
||||||
combined_attributes.extend_from_slice(&self.schema().group_attributes.attributes);
|
|
||||||
AttributeList {
|
|
||||||
attributes: combined_attributes,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_is_subtree() {
|
|
||||||
let subtree1 = &[
|
|
||||||
("ou".to_string(), "people".to_string()),
|
|
||||||
("dc".to_string(), "example".to_string()),
|
|
||||||
("dc".to_string(), "com".to_string()),
|
|
||||||
];
|
|
||||||
let root = &[
|
|
||||||
("dc".to_string(), "example".to_string()),
|
|
||||||
("dc".to_string(), "com".to_string()),
|
|
||||||
];
|
|
||||||
assert!(is_subtree(subtree1, root));
|
|
||||||
assert!(!is_subtree(&[], root));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_distinguished_name() {
|
|
||||||
let parsed_dn = &[
|
|
||||||
("ou".to_string(), "people".to_string()),
|
|
||||||
("dc".to_string(), "example".to_string()),
|
|
||||||
("dc".to_string(), "com".to_string()),
|
|
||||||
];
|
|
||||||
assert_eq!(
|
|
||||||
parse_distinguished_name("ou=people,dc=example,dc=com").expect("parsing failed"),
|
|
||||||
parsed_dn
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parse_distinguished_name(" ou = people , dc = example , dc = com ")
|
|
||||||
.expect("parsing failed"),
|
|
||||||
parsed_dn
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
use crate::{
|
|
||||||
core::{
|
|
||||||
error::{LdapError, LdapResult},
|
|
||||||
utils::{LdapInfo, UserOrGroupName, get_user_or_group_id_from_distinguished_name},
|
|
||||||
},
|
|
||||||
handler::make_add_response,
|
|
||||||
};
|
|
||||||
use ldap3_proto::proto::{
|
|
||||||
LdapAddRequest, LdapAttribute, LdapOp, LdapPartialAttribute, LdapResultCode,
|
|
||||||
};
|
|
||||||
use lldap_access_control::AdminBackendHandler;
|
|
||||||
use lldap_domain::{
|
|
||||||
deserialize,
|
|
||||||
requests::{CreateGroupRequest, CreateUserRequest},
|
|
||||||
types::{Attribute, AttributeName, AttributeType, Email, GroupName, UserId},
|
|
||||||
};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use tracing::instrument;
|
|
||||||
|
|
||||||
#[instrument(skip_all, level = "debug")]
|
|
||||||
pub(crate) async fn create_user_or_group(
|
|
||||||
backend_handler: &impl AdminBackendHandler,
|
|
||||||
ldap_info: &LdapInfo,
|
|
||||||
request: LdapAddRequest,
|
|
||||||
) -> LdapResult<Vec<LdapOp>> {
|
|
||||||
let base_dn_str = &ldap_info.base_dn_str;
|
|
||||||
match get_user_or_group_id_from_distinguished_name(&request.dn, &ldap_info.base_dn) {
|
|
||||||
UserOrGroupName::User(user_id) => {
|
|
||||||
create_user(backend_handler, user_id, request.attributes).await
|
|
||||||
}
|
|
||||||
UserOrGroupName::Group(group_name) => {
|
|
||||||
create_group(backend_handler, group_name, request.attributes).await
|
|
||||||
}
|
|
||||||
err => Err(err.into_ldap_error(
|
|
||||||
&request.dn,
|
|
||||||
format!(r#""uid=id,ou=people,{base_dn_str}" or "uid=id,ou=groups,{base_dn_str}""#),
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip_all, level = "debug")]
|
|
||||||
async fn create_user(
|
|
||||||
backend_handler: &impl AdminBackendHandler,
|
|
||||||
user_id: UserId,
|
|
||||||
attributes: Vec<LdapAttribute>,
|
|
||||||
) -> LdapResult<Vec<LdapOp>> {
|
|
||||||
fn parse_attribute(mut attr: LdapPartialAttribute) -> LdapResult<(String, Vec<u8>)> {
|
|
||||||
if attr.vals.len() > 1 {
|
|
||||||
Err(LdapError {
|
|
||||||
code: LdapResultCode::ConstraintViolation,
|
|
||||||
message: format!("Expected a single value for attribute {}", attr.atype),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
attr.atype.make_ascii_lowercase();
|
|
||||||
match attr.vals.pop() {
|
|
||||||
Some(val) => Ok((attr.atype, val)),
|
|
||||||
None => Err(LdapError {
|
|
||||||
code: LdapResultCode::ConstraintViolation,
|
|
||||||
message: format!("Missing value for attribute {}", attr.atype),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let attributes: HashMap<String, Vec<u8>> = attributes
|
|
||||||
.into_iter()
|
|
||||||
.filter(|a| !a.atype.eq_ignore_ascii_case("objectclass"))
|
|
||||||
.map(parse_attribute)
|
|
||||||
.collect::<LdapResult<_>>()?;
|
|
||||||
fn decode_attribute_value(val: &[u8]) -> LdapResult<String> {
|
|
||||||
std::str::from_utf8(val)
|
|
||||||
.map_err(|e| LdapError {
|
|
||||||
code: LdapResultCode::ConstraintViolation,
|
|
||||||
message: format!("Attribute value is invalid UTF-8: {e:#?} (value {val:?})"),
|
|
||||||
})
|
|
||||||
.map(str::to_owned)
|
|
||||||
}
|
|
||||||
let get_attribute = |name| {
|
|
||||||
attributes
|
|
||||||
.get(name)
|
|
||||||
.map(Vec::as_slice)
|
|
||||||
.map(decode_attribute_value)
|
|
||||||
};
|
|
||||||
let make_encoded_attribute = |name: &str, typ: AttributeType, value: String| {
|
|
||||||
Ok(Attribute {
|
|
||||||
name: AttributeName::from(name),
|
|
||||||
value: deserialize::deserialize_attribute_value(&[value], typ, false).map_err(|e| {
|
|
||||||
LdapError {
|
|
||||||
code: LdapResultCode::ConstraintViolation,
|
|
||||||
message: format!("Invalid attribute value: {e}"),
|
|
||||||
}
|
|
||||||
})?,
|
|
||||||
})
|
|
||||||
};
|
|
||||||
let mut new_user_attributes: Vec<Attribute> = Vec::new();
|
|
||||||
if let Some(first_name) = get_attribute("givenname").transpose()? {
|
|
||||||
new_user_attributes.push(make_encoded_attribute(
|
|
||||||
"first_name",
|
|
||||||
AttributeType::String,
|
|
||||||
first_name,
|
|
||||||
)?);
|
|
||||||
}
|
|
||||||
if let Some(last_name) = get_attribute("sn").transpose()? {
|
|
||||||
new_user_attributes.push(make_encoded_attribute(
|
|
||||||
"last_name",
|
|
||||||
AttributeType::String,
|
|
||||||
last_name,
|
|
||||||
)?);
|
|
||||||
}
|
|
||||||
if let Some(avatar) = get_attribute("avatar").transpose()? {
|
|
||||||
new_user_attributes.push(make_encoded_attribute(
|
|
||||||
"avatar",
|
|
||||||
AttributeType::JpegPhoto,
|
|
||||||
avatar,
|
|
||||||
)?);
|
|
||||||
}
|
|
||||||
backend_handler
|
|
||||||
.create_user(CreateUserRequest {
|
|
||||||
user_id,
|
|
||||||
email: Email::from(
|
|
||||||
get_attribute("mail")
|
|
||||||
.or_else(|| get_attribute("email"))
|
|
||||||
.transpose()?
|
|
||||||
.unwrap_or_default(),
|
|
||||||
),
|
|
||||||
display_name: get_attribute("cn").transpose()?,
|
|
||||||
attributes: new_user_attributes,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| LdapError {
|
|
||||||
code: LdapResultCode::OperationsError,
|
|
||||||
message: format!("Could not create user: {e:#?}"),
|
|
||||||
})?;
|
|
||||||
Ok(vec![make_add_response(
|
|
||||||
LdapResultCode::Success,
|
|
||||||
String::new(),
|
|
||||||
)])
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip_all, level = "debug")]
|
|
||||||
async fn create_group(
|
|
||||||
backend_handler: &impl AdminBackendHandler,
|
|
||||||
group_name: GroupName,
|
|
||||||
_attributes: Vec<LdapAttribute>,
|
|
||||||
) -> LdapResult<Vec<LdapOp>> {
|
|
||||||
backend_handler
|
|
||||||
.create_group(CreateGroupRequest {
|
|
||||||
display_name: group_name,
|
|
||||||
attributes: Vec::new(),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| LdapError {
|
|
||||||
code: LdapResultCode::OperationsError,
|
|
||||||
message: format!("Could not create group: {e:#?}"),
|
|
||||||
})?;
|
|
||||||
Ok(vec![make_add_response(
|
|
||||||
LdapResultCode::Success,
|
|
||||||
String::new(),
|
|
||||||
)])
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::handler::tests::setup_bound_admin_handler;
|
|
||||||
use lldap_domain::types::*;
|
|
||||||
use lldap_test_utils::MockTestBackendHandler;
|
|
||||||
use mockall::predicate::eq;
|
|
||||||
use pretty_assertions::assert_eq;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_create_user() {
|
|
||||||
let mut mock = MockTestBackendHandler::new();
|
|
||||||
mock.expect_create_user()
|
|
||||||
.with(eq(CreateUserRequest {
|
|
||||||
user_id: UserId::new("bob"),
|
|
||||||
email: "".into(),
|
|
||||||
display_name: Some("Bob".to_string()),
|
|
||||||
..Default::default()
|
|
||||||
}))
|
|
||||||
.times(1)
|
|
||||||
.return_once(|_| Ok(()));
|
|
||||||
let ldap_handler = setup_bound_admin_handler(mock).await;
|
|
||||||
let request = LdapAddRequest {
|
|
||||||
dn: "uid=bob,ou=people,dc=example,dc=com".to_owned(),
|
|
||||||
attributes: vec![LdapPartialAttribute {
|
|
||||||
atype: "cn".to_owned(),
|
|
||||||
vals: vec![b"Bob".to_vec()],
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.create_user_or_group(request).await,
|
|
||||||
Ok(vec![make_add_response(
|
|
||||||
LdapResultCode::Success,
|
|
||||||
String::new()
|
|
||||||
)])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_create_group() {
|
|
||||||
let mut mock = MockTestBackendHandler::new();
|
|
||||||
mock.expect_create_group()
|
|
||||||
.with(eq(CreateGroupRequest {
|
|
||||||
display_name: GroupName::new("bob"),
|
|
||||||
..Default::default()
|
|
||||||
}))
|
|
||||||
.times(1)
|
|
||||||
.return_once(|_| Ok(GroupId(5)));
|
|
||||||
let ldap_handler = setup_bound_admin_handler(mock).await;
|
|
||||||
let request = LdapAddRequest {
|
|
||||||
dn: "uid=bob,ou=groups,dc=example,dc=com".to_owned(),
|
|
||||||
attributes: vec![LdapPartialAttribute {
|
|
||||||
atype: "cn".to_owned(),
|
|
||||||
vals: vec![b"Bobby".to_vec()],
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.create_user_or_group(request).await,
|
|
||||||
Ok(vec![make_add_response(
|
|
||||||
LdapResultCode::Success,
|
|
||||||
String::new()
|
|
||||||
)])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_create_user_multiple_object_class() {
|
|
||||||
let mut mock = MockTestBackendHandler::new();
|
|
||||||
mock.expect_create_user()
|
|
||||||
.with(eq(CreateUserRequest {
|
|
||||||
user_id: UserId::new("bob"),
|
|
||||||
email: "".into(),
|
|
||||||
display_name: Some("Bob".to_string()),
|
|
||||||
..Default::default()
|
|
||||||
}))
|
|
||||||
.times(1)
|
|
||||||
.return_once(|_| Ok(()));
|
|
||||||
let ldap_handler = setup_bound_admin_handler(mock).await;
|
|
||||||
let request = LdapAddRequest {
|
|
||||||
dn: "uid=bob,ou=people,dc=example,dc=com".to_owned(),
|
|
||||||
attributes: vec![
|
|
||||||
LdapPartialAttribute {
|
|
||||||
atype: "cn".to_owned(),
|
|
||||||
vals: vec![b"Bob".to_vec()],
|
|
||||||
},
|
|
||||||
LdapPartialAttribute {
|
|
||||||
atype: "objectClass".to_owned(),
|
|
||||||
vals: vec![
|
|
||||||
b"top".to_vec(),
|
|
||||||
b"person".to_vec(),
|
|
||||||
b"inetOrgPerson".to_vec(),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.create_user_or_group(request).await,
|
|
||||||
Ok(vec![make_add_response(
|
|
||||||
LdapResultCode::Success,
|
|
||||||
String::new()
|
|
||||||
)])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,305 +0,0 @@
|
|||||||
use crate::core::{
|
|
||||||
error::{LdapError, LdapResult},
|
|
||||||
utils::{LdapInfo, UserOrGroupName, get_user_or_group_id_from_distinguished_name},
|
|
||||||
};
|
|
||||||
use ldap3_proto::proto::{LdapOp, LdapResult as LdapResultOp, LdapResultCode};
|
|
||||||
use lldap_access_control::AdminBackendHandler;
|
|
||||||
use lldap_domain::types::{GroupName, UserId};
|
|
||||||
use lldap_domain_handlers::handler::GroupRequestFilter;
|
|
||||||
use lldap_domain_model::error::DomainError;
|
|
||||||
use tracing::instrument;
|
|
||||||
|
|
||||||
pub(crate) fn make_del_response(code: LdapResultCode, message: String) -> LdapOp {
|
|
||||||
LdapOp::DelResponse(LdapResultOp {
|
|
||||||
code,
|
|
||||||
matcheddn: "".to_string(),
|
|
||||||
message,
|
|
||||||
referral: vec![],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip_all, level = "debug")]
|
|
||||||
pub(crate) async fn delete_user_or_group(
|
|
||||||
backend_handler: &impl AdminBackendHandler,
|
|
||||||
ldap_info: &LdapInfo,
|
|
||||||
request: String,
|
|
||||||
) -> LdapResult<Vec<LdapOp>> {
|
|
||||||
let base_dn_str = &ldap_info.base_dn_str;
|
|
||||||
match get_user_or_group_id_from_distinguished_name(&request, &ldap_info.base_dn) {
|
|
||||||
UserOrGroupName::User(user_id) => delete_user(backend_handler, user_id).await,
|
|
||||||
UserOrGroupName::Group(group_name) => delete_group(backend_handler, group_name).await,
|
|
||||||
err => Err(err.into_ldap_error(
|
|
||||||
&request,
|
|
||||||
format!(r#""uid=id,ou=people,{base_dn_str}" or "uid=id,ou=groups,{base_dn_str}""#),
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip_all, level = "debug")]
|
|
||||||
async fn delete_user(
|
|
||||||
backend_handler: &impl AdminBackendHandler,
|
|
||||||
user_id: UserId,
|
|
||||||
) -> LdapResult<Vec<LdapOp>> {
|
|
||||||
backend_handler
|
|
||||||
.get_user_details(&user_id)
|
|
||||||
.await
|
|
||||||
.map_err(|err| match err {
|
|
||||||
DomainError::EntityNotFound(_) => LdapError {
|
|
||||||
code: LdapResultCode::NoSuchObject,
|
|
||||||
message: "Could not find user".to_string(),
|
|
||||||
},
|
|
||||||
e => LdapError {
|
|
||||||
code: LdapResultCode::OperationsError,
|
|
||||||
message: format!("Error while finding user: {e:?}"),
|
|
||||||
},
|
|
||||||
})?;
|
|
||||||
backend_handler
|
|
||||||
.delete_user(&user_id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| LdapError {
|
|
||||||
code: LdapResultCode::OperationsError,
|
|
||||||
message: format!("Error while deleting user: {e:?}"),
|
|
||||||
})?;
|
|
||||||
Ok(vec![make_del_response(
|
|
||||||
LdapResultCode::Success,
|
|
||||||
String::new(),
|
|
||||||
)])
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip_all, level = "debug")]
|
|
||||||
async fn delete_group(
|
|
||||||
backend_handler: &impl AdminBackendHandler,
|
|
||||||
group_name: GroupName,
|
|
||||||
) -> LdapResult<Vec<LdapOp>> {
|
|
||||||
let groups = backend_handler
|
|
||||||
.list_groups(Some(GroupRequestFilter::DisplayName(group_name.clone())))
|
|
||||||
.await
|
|
||||||
.map_err(|e| LdapError {
|
|
||||||
code: LdapResultCode::OperationsError,
|
|
||||||
message: format!("Error while finding group: {e:?}"),
|
|
||||||
})?;
|
|
||||||
let group_id = groups
|
|
||||||
.iter()
|
|
||||||
.find(|g| g.display_name == group_name)
|
|
||||||
.map(|g| g.id)
|
|
||||||
.ok_or_else(|| LdapError {
|
|
||||||
code: LdapResultCode::NoSuchObject,
|
|
||||||
message: "Could not find group".to_string(),
|
|
||||||
})?;
|
|
||||||
backend_handler
|
|
||||||
.delete_group(group_id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| LdapError {
|
|
||||||
code: LdapResultCode::OperationsError,
|
|
||||||
message: format!("Error while deleting group: {e:?}"),
|
|
||||||
})?;
|
|
||||||
Ok(vec![make_del_response(
|
|
||||||
LdapResultCode::Success,
|
|
||||||
String::new(),
|
|
||||||
)])
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::handler::tests::setup_bound_admin_handler;
|
|
||||||
use chrono::TimeZone;
|
|
||||||
use lldap_domain::{
|
|
||||||
types::{Group, GroupId, User},
|
|
||||||
uuid,
|
|
||||||
};
|
|
||||||
use lldap_domain_model::error::DomainError;
|
|
||||||
use lldap_test_utils::MockTestBackendHandler;
|
|
||||||
use mockall::predicate::eq;
|
|
||||||
use pretty_assertions::assert_eq;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_delete_user() {
|
|
||||||
let mut mock = MockTestBackendHandler::new();
|
|
||||||
mock.expect_get_user_details()
|
|
||||||
.with(eq(UserId::new("bob")))
|
|
||||||
.return_once(|_| {
|
|
||||||
Ok(User {
|
|
||||||
user_id: UserId::new("bob"),
|
|
||||||
..Default::default()
|
|
||||||
})
|
|
||||||
});
|
|
||||||
mock.expect_delete_user()
|
|
||||||
.with(eq(UserId::new("bob")))
|
|
||||||
.times(1)
|
|
||||||
.return_once(|_| Ok(()));
|
|
||||||
let mut ldap_handler = setup_bound_admin_handler(mock).await;
|
|
||||||
let request = LdapOp::DelRequest("uid=bob,ou=people,dc=example,dc=com".to_owned());
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.handle_ldap_message(request).await,
|
|
||||||
Some(vec![make_del_response(
|
|
||||||
LdapResultCode::Success,
|
|
||||||
String::new()
|
|
||||||
)])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_delete_group() {
|
|
||||||
let mut mock = MockTestBackendHandler::new();
|
|
||||||
mock.expect_list_groups()
|
|
||||||
.with(eq(Some(GroupRequestFilter::DisplayName(GroupName::from(
|
|
||||||
"bob",
|
|
||||||
)))))
|
|
||||||
.return_once(|_| {
|
|
||||||
Ok(vec![Group {
|
|
||||||
id: GroupId(34),
|
|
||||||
display_name: GroupName::from("bob"),
|
|
||||||
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
|
||||||
uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
|
|
||||||
users: Vec::new(),
|
|
||||||
attributes: Vec::new(),
|
|
||||||
modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
|
||||||
}])
|
|
||||||
});
|
|
||||||
mock.expect_delete_group()
|
|
||||||
.with(eq(GroupId(34)))
|
|
||||||
.times(1)
|
|
||||||
.return_once(|_| Ok(()));
|
|
||||||
let mut ldap_handler = setup_bound_admin_handler(mock).await;
|
|
||||||
let request = LdapOp::DelRequest("uid=bob,ou=groups,dc=example,dc=com".to_owned());
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.handle_ldap_message(request).await,
|
|
||||||
Some(vec![make_del_response(
|
|
||||||
LdapResultCode::Success,
|
|
||||||
String::new()
|
|
||||||
)])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_delete_user_not_found() {
|
|
||||||
let mut mock = MockTestBackendHandler::new();
|
|
||||||
mock.expect_get_user_details()
|
|
||||||
.with(eq(UserId::new("bob")))
|
|
||||||
.return_once(|_| Err(DomainError::EntityNotFound("No such user".to_string())));
|
|
||||||
let mut ldap_handler = setup_bound_admin_handler(mock).await;
|
|
||||||
let request = LdapOp::DelRequest("uid=bob,ou=people,dc=example,dc=com".to_owned());
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.handle_ldap_message(request).await,
|
|
||||||
Some(vec![make_del_response(
|
|
||||||
LdapResultCode::NoSuchObject,
|
|
||||||
"Could not find user".to_string()
|
|
||||||
)])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_delete_user_lookup_error() {
|
|
||||||
let mut mock = MockTestBackendHandler::new();
|
|
||||||
mock.expect_get_user_details()
|
|
||||||
.with(eq(UserId::new("bob")))
|
|
||||||
.return_once(|_| Err(DomainError::InternalError("WTF?".to_string())));
|
|
||||||
let mut ldap_handler = setup_bound_admin_handler(mock).await;
|
|
||||||
let request = LdapOp::DelRequest("uid=bob,ou=people,dc=example,dc=com".to_owned());
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.handle_ldap_message(request).await,
|
|
||||||
Some(vec![make_del_response(
|
|
||||||
LdapResultCode::OperationsError,
|
|
||||||
r#"Error while finding user: InternalError("WTF?")"#.to_string()
|
|
||||||
)])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_delete_user_deletion_error() {
|
|
||||||
let mut mock = MockTestBackendHandler::new();
|
|
||||||
mock.expect_get_user_details()
|
|
||||||
.with(eq(UserId::new("bob")))
|
|
||||||
.return_once(|_| {
|
|
||||||
Ok(User {
|
|
||||||
user_id: UserId::new("bob"),
|
|
||||||
..Default::default()
|
|
||||||
})
|
|
||||||
});
|
|
||||||
mock.expect_delete_user()
|
|
||||||
.with(eq(UserId::new("bob")))
|
|
||||||
.times(1)
|
|
||||||
.return_once(|_| Err(DomainError::InternalError("WTF?".to_string())));
|
|
||||||
let mut ldap_handler = setup_bound_admin_handler(mock).await;
|
|
||||||
let request = LdapOp::DelRequest("uid=bob,ou=people,dc=example,dc=com".to_owned());
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.handle_ldap_message(request).await,
|
|
||||||
Some(vec![make_del_response(
|
|
||||||
LdapResultCode::OperationsError,
|
|
||||||
r#"Error while deleting user: InternalError("WTF?")"#.to_string()
|
|
||||||
)])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_delete_group_not_found() {
|
|
||||||
let mut mock = MockTestBackendHandler::new();
|
|
||||||
mock.expect_list_groups()
|
|
||||||
.with(eq(Some(GroupRequestFilter::DisplayName(GroupName::from(
|
|
||||||
"bob",
|
|
||||||
)))))
|
|
||||||
.return_once(|_| Ok(vec![]));
|
|
||||||
let mut ldap_handler = setup_bound_admin_handler(mock).await;
|
|
||||||
let request = LdapOp::DelRequest("uid=bob,ou=groups,dc=example,dc=com".to_owned());
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.handle_ldap_message(request).await,
|
|
||||||
Some(vec![make_del_response(
|
|
||||||
LdapResultCode::NoSuchObject,
|
|
||||||
"Could not find group".to_string()
|
|
||||||
)])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_delete_group_lookup_error() {
|
|
||||||
let mut mock = MockTestBackendHandler::new();
|
|
||||||
mock.expect_list_groups()
|
|
||||||
.with(eq(Some(GroupRequestFilter::DisplayName(GroupName::from(
|
|
||||||
"bob",
|
|
||||||
)))))
|
|
||||||
.return_once(|_| Err(DomainError::InternalError("WTF?".to_string())));
|
|
||||||
let mut ldap_handler = setup_bound_admin_handler(mock).await;
|
|
||||||
let request = LdapOp::DelRequest("uid=bob,ou=groups,dc=example,dc=com".to_owned());
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.handle_ldap_message(request).await,
|
|
||||||
Some(vec![make_del_response(
|
|
||||||
LdapResultCode::OperationsError,
|
|
||||||
r#"Error while finding group: InternalError("WTF?")"#.to_string()
|
|
||||||
)])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_delete_group_deletion_error() {
|
|
||||||
let mut mock = MockTestBackendHandler::new();
|
|
||||||
mock.expect_list_groups()
|
|
||||||
.with(eq(Some(GroupRequestFilter::DisplayName(GroupName::from(
|
|
||||||
"bob",
|
|
||||||
)))))
|
|
||||||
.return_once(|_| {
|
|
||||||
Ok(vec![Group {
|
|
||||||
id: GroupId(34),
|
|
||||||
display_name: GroupName::from("bob"),
|
|
||||||
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
|
||||||
uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
|
|
||||||
users: Vec::new(),
|
|
||||||
attributes: Vec::new(),
|
|
||||||
modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
|
||||||
}])
|
|
||||||
});
|
|
||||||
mock.expect_delete_group()
|
|
||||||
.with(eq(GroupId(34)))
|
|
||||||
.times(1)
|
|
||||||
.return_once(|_| Err(DomainError::InternalError("WTF?".to_string())));
|
|
||||||
let mut ldap_handler = setup_bound_admin_handler(mock).await;
|
|
||||||
let request = LdapOp::DelRequest("uid=bob,ou=groups,dc=example,dc=com".to_owned());
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.handle_ldap_message(request).await,
|
|
||||||
Some(vec![make_del_response(
|
|
||||||
LdapResultCode::OperationsError,
|
|
||||||
r#"Error while deleting group: InternalError("WTF?")"#.to_string()
|
|
||||||
)])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,460 +0,0 @@
|
|||||||
use crate::{
|
|
||||||
compare,
|
|
||||||
core::{
|
|
||||||
error::{LdapError, LdapResult},
|
|
||||||
utils::{LdapInfo, parse_distinguished_name},
|
|
||||||
},
|
|
||||||
create, delete, modify,
|
|
||||||
password::{self, do_password_modification},
|
|
||||||
search::{
|
|
||||||
self, is_root_dse_request, is_subschema_entry_request, make_ldap_subschema_entry,
|
|
||||||
make_search_error, make_search_request, make_search_success, root_dse_response,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
use ldap3_proto::proto::{
|
|
||||||
LdapAddRequest, LdapBindRequest, LdapBindResponse, LdapCompareRequest, LdapExtendedRequest,
|
|
||||||
LdapExtendedResponse, LdapFilter, LdapModifyRequest, LdapOp, LdapPasswordModifyRequest,
|
|
||||||
LdapResult as LdapResultOp, LdapResultCode, LdapSearchRequest, OID_PASSWORD_MODIFY, OID_WHOAMI,
|
|
||||||
};
|
|
||||||
use lldap_access_control::AccessControlledBackendHandler;
|
|
||||||
use lldap_auth::access_control::ValidationResults;
|
|
||||||
use lldap_domain::{public_schema::PublicSchema, types::AttributeName};
|
|
||||||
use lldap_domain_handlers::handler::{BackendHandler, LoginHandler, ReadSchemaBackendHandler};
|
|
||||||
use lldap_opaque_handler::OpaqueHandler;
|
|
||||||
use tracing::{debug, instrument};
|
|
||||||
|
|
||||||
use super::delete::make_del_response;
|
|
||||||
|
|
||||||
pub(crate) fn make_add_response(code: LdapResultCode, message: String) -> LdapOp {
|
|
||||||
LdapOp::AddResponse(LdapResultOp {
|
|
||||||
code,
|
|
||||||
matcheddn: "".to_string(),
|
|
||||||
message,
|
|
||||||
referral: vec![],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn make_extended_response(code: LdapResultCode, message: String) -> LdapOp {
|
|
||||||
LdapOp::ExtendedResponse(LdapExtendedResponse {
|
|
||||||
res: LdapResultOp {
|
|
||||||
code,
|
|
||||||
matcheddn: "".to_string(),
|
|
||||||
message,
|
|
||||||
referral: vec![],
|
|
||||||
},
|
|
||||||
name: None,
|
|
||||||
value: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn make_modify_response(code: LdapResultCode, message: String) -> LdapOp {
|
|
||||||
LdapOp::ModifyResponse(LdapResultOp {
|
|
||||||
code,
|
|
||||||
matcheddn: "".to_string(),
|
|
||||||
message,
|
|
||||||
referral: vec![],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct LdapHandler<Backend> {
|
|
||||||
user_info: Option<ValidationResults>,
|
|
||||||
backend_handler: AccessControlledBackendHandler<Backend>,
|
|
||||||
ldap_info: LdapInfo,
|
|
||||||
session_uuid: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<Backend> LdapHandler<Backend> {
|
|
||||||
pub fn session_uuid(&self) -> &uuid::Uuid {
|
|
||||||
&self.session_uuid
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<Backend: LoginHandler> LdapHandler<Backend> {
|
|
||||||
pub fn get_login_handler(&self) -> &(impl LoginHandler + use<Backend>) {
|
|
||||||
self.backend_handler.unsafe_get_handler()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<Backend: OpaqueHandler> LdapHandler<Backend> {
|
|
||||||
pub fn get_opaque_handler(&self) -> &(impl OpaqueHandler + use<Backend>) {
|
|
||||||
self.backend_handler.unsafe_get_handler()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Credentials<'s> {
|
|
||||||
Bound(&'s ValidationResults),
|
|
||||||
Unbound(Vec<LdapOp>),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend> {
|
|
||||||
pub fn new(
|
|
||||||
backend_handler: AccessControlledBackendHandler<Backend>,
|
|
||||||
mut ldap_base_dn: String,
|
|
||||||
ignored_user_attributes: Vec<AttributeName>,
|
|
||||||
ignored_group_attributes: Vec<AttributeName>,
|
|
||||||
session_uuid: uuid::Uuid,
|
|
||||||
) -> Self {
|
|
||||||
ldap_base_dn.make_ascii_lowercase();
|
|
||||||
Self {
|
|
||||||
user_info: None,
|
|
||||||
backend_handler,
|
|
||||||
ldap_info: LdapInfo {
|
|
||||||
base_dn: parse_distinguished_name(&ldap_base_dn).unwrap_or_else(|_| {
|
|
||||||
panic!("Invalid value for ldap_base_dn in configuration: {ldap_base_dn}")
|
|
||||||
}),
|
|
||||||
base_dn_str: ldap_base_dn,
|
|
||||||
ignored_user_attributes,
|
|
||||||
ignored_group_attributes,
|
|
||||||
},
|
|
||||||
session_uuid,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub fn new_for_tests(backend_handler: Backend, ldap_base_dn: &str) -> Self {
|
|
||||||
Self::new(
|
|
||||||
AccessControlledBackendHandler::new(backend_handler),
|
|
||||||
ldap_base_dn.to_string(),
|
|
||||||
vec![],
|
|
||||||
vec![],
|
|
||||||
uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_credentials(&self) -> Credentials<'_> {
|
|
||||||
match self.user_info.as_ref() {
|
|
||||||
Some(user_info) => Credentials::Bound(user_info),
|
|
||||||
None => Credentials::Unbound(vec![make_extended_response(
|
|
||||||
LdapResultCode::InsufficentAccessRights,
|
|
||||||
"No user currently bound".to_string(),
|
|
||||||
)]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn do_search_or_dse(&self, request: &LdapSearchRequest) -> LdapResult<Vec<LdapOp>> {
|
|
||||||
if is_root_dse_request(request) {
|
|
||||||
debug!("rootDSE request");
|
|
||||||
return Ok(vec![
|
|
||||||
root_dse_response(&self.ldap_info.base_dn_str),
|
|
||||||
make_search_success(),
|
|
||||||
]);
|
|
||||||
} else if is_subschema_entry_request(request) {
|
|
||||||
// See RFC4512 section 4.4 "Subschema discovery"
|
|
||||||
debug!("Schema request");
|
|
||||||
let backend_handler = self
|
|
||||||
.user_info
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|u| self.backend_handler.get_schema_only_handler(u))
|
|
||||||
.ok_or_else(|| LdapError {
|
|
||||||
code: LdapResultCode::InsufficentAccessRights,
|
|
||||||
message: "No user currently bound".to_string(),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let schema = backend_handler.get_schema().await.map_err(|e| LdapError {
|
|
||||||
code: LdapResultCode::OperationsError,
|
|
||||||
message: format!("Unable to get schema: {e:#}"),
|
|
||||||
})?;
|
|
||||||
return Ok(vec![
|
|
||||||
make_ldap_subschema_entry(PublicSchema::from(schema)),
|
|
||||||
make_search_success(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
self.do_search(request).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip_all, level = "debug")]
|
|
||||||
async fn do_search(&self, request: &LdapSearchRequest) -> LdapResult<Vec<LdapOp>> {
|
|
||||||
let user_info = self.user_info.as_ref().ok_or_else(|| LdapError {
|
|
||||||
code: LdapResultCode::InsufficentAccessRights,
|
|
||||||
message: "No user currently bound".to_string(),
|
|
||||||
})?;
|
|
||||||
let backend_handler = self
|
|
||||||
.backend_handler
|
|
||||||
.get_user_restricted_lister_handler(user_info);
|
|
||||||
search::do_search(&backend_handler, &self.ldap_info, request).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip_all, level = "debug", fields(dn = %request.dn))]
|
|
||||||
pub async fn do_bind(&mut self, request: &LdapBindRequest) -> Vec<LdapOp> {
|
|
||||||
let (code, message) =
|
|
||||||
match password::do_bind(&self.ldap_info, request, self.get_login_handler()).await {
|
|
||||||
Ok(user_id) => {
|
|
||||||
self.user_info = self
|
|
||||||
.backend_handler
|
|
||||||
.get_permissions_for_user(user_id)
|
|
||||||
.await
|
|
||||||
.ok();
|
|
||||||
debug!("Success!");
|
|
||||||
(LdapResultCode::Success, "".to_string())
|
|
||||||
}
|
|
||||||
Err(err) => (err.code, err.message),
|
|
||||||
};
|
|
||||||
vec![LdapOp::BindResponse(LdapBindResponse {
|
|
||||||
res: LdapResultOp {
|
|
||||||
code,
|
|
||||||
matcheddn: "".to_string(),
|
|
||||||
message,
|
|
||||||
referral: vec![],
|
|
||||||
},
|
|
||||||
saslcreds: None,
|
|
||||||
})]
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip_all, level = "debug")]
|
|
||||||
async fn do_extended_request(&self, request: &LdapExtendedRequest) -> Vec<LdapOp> {
|
|
||||||
match request.name.as_str() {
|
|
||||||
OID_PASSWORD_MODIFY => match LdapPasswordModifyRequest::try_from(request) {
|
|
||||||
Ok(password_request) => {
|
|
||||||
let credentials = match self.get_credentials() {
|
|
||||||
Credentials::Bound(cred) => cred,
|
|
||||||
Credentials::Unbound(err) => return err,
|
|
||||||
};
|
|
||||||
do_password_modification(
|
|
||||||
credentials,
|
|
||||||
&self.ldap_info,
|
|
||||||
&self.backend_handler,
|
|
||||||
self.get_opaque_handler(),
|
|
||||||
&password_request,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap_or_else(|e: LdapError| vec![make_extended_response(e.code, e.message)])
|
|
||||||
}
|
|
||||||
Err(e) => vec![make_extended_response(
|
|
||||||
LdapResultCode::ProtocolError,
|
|
||||||
format!("Error while parsing password modify request: {e:#?}"),
|
|
||||||
)],
|
|
||||||
},
|
|
||||||
OID_WHOAMI => {
|
|
||||||
let authz_id = self
|
|
||||||
.user_info
|
|
||||||
.as_ref()
|
|
||||||
.map(|user_info| {
|
|
||||||
format!(
|
|
||||||
"dn:uid={},ou=people,{}",
|
|
||||||
user_info.user.as_str(),
|
|
||||||
self.ldap_info.base_dn_str
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
vec![make_extended_response(LdapResultCode::Success, authz_id)]
|
|
||||||
}
|
|
||||||
_ => vec![make_extended_response(
|
|
||||||
LdapResultCode::UnwillingToPerform,
|
|
||||||
format!("Unsupported extended operation: {}", &request.name),
|
|
||||||
)],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip_all, level = "debug", fields(dn = %request.dn))]
|
|
||||||
pub async fn do_modify_request(&self, request: &LdapModifyRequest) -> Vec<LdapOp> {
|
|
||||||
let credentials = match self.get_credentials() {
|
|
||||||
Credentials::Bound(cred) => cred,
|
|
||||||
Credentials::Unbound(err) => return err,
|
|
||||||
};
|
|
||||||
modify::handle_modify_request(
|
|
||||||
self.get_opaque_handler(),
|
|
||||||
|credentials, user_id| {
|
|
||||||
self.backend_handler
|
|
||||||
.get_readable_handler(credentials, &user_id)
|
|
||||||
},
|
|
||||||
&self.ldap_info,
|
|
||||||
credentials,
|
|
||||||
request,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap_or_else(|e: LdapError| vec![make_modify_response(e.code, e.message)])
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip_all, level = "debug")]
|
|
||||||
pub async fn create_user_or_group(&self, request: LdapAddRequest) -> LdapResult<Vec<LdapOp>> {
|
|
||||||
let backend_handler = self
|
|
||||||
.user_info
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|u| self.backend_handler.get_admin_handler(u))
|
|
||||||
.ok_or_else(|| LdapError {
|
|
||||||
code: LdapResultCode::InsufficentAccessRights,
|
|
||||||
message: "Unauthorized write".to_string(),
|
|
||||||
})?;
|
|
||||||
create::create_user_or_group(backend_handler, &self.ldap_info, request).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip_all, level = "debug")]
|
|
||||||
pub async fn delete_user_or_group(&self, request: String) -> LdapResult<Vec<LdapOp>> {
|
|
||||||
let backend_handler = self
|
|
||||||
.user_info
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|u| self.backend_handler.get_admin_handler(u))
|
|
||||||
.ok_or_else(|| LdapError {
|
|
||||||
code: LdapResultCode::InsufficentAccessRights,
|
|
||||||
message: "Unauthorized write".to_string(),
|
|
||||||
})?;
|
|
||||||
delete::delete_user_or_group(backend_handler, &self.ldap_info, request).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip_all, level = "debug")]
|
|
||||||
pub async fn do_compare(&self, request: LdapCompareRequest) -> LdapResult<Vec<LdapOp>> {
|
|
||||||
let req = make_search_request::<String>(
|
|
||||||
&self.ldap_info.base_dn_str,
|
|
||||||
LdapFilter::Equality("dn".to_string(), request.dn.to_string()),
|
|
||||||
vec![request.atype.clone()],
|
|
||||||
);
|
|
||||||
compare::compare(
|
|
||||||
request,
|
|
||||||
self.do_search(&req).await?,
|
|
||||||
&self.ldap_info.base_dn_str,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn handle_ldap_message(&mut self, ldap_op: LdapOp) -> Option<Vec<LdapOp>> {
|
|
||||||
Some(match ldap_op {
|
|
||||||
LdapOp::BindRequest(request) => self.do_bind(&request).await,
|
|
||||||
LdapOp::SearchRequest(request) => self
|
|
||||||
.do_search_or_dse(&request)
|
|
||||||
.await
|
|
||||||
.unwrap_or_else(|e: LdapError| vec![make_search_error(e.code, e.message)]),
|
|
||||||
LdapOp::UnbindRequest => {
|
|
||||||
debug!(
|
|
||||||
"Unbind request for {}",
|
|
||||||
self.user_info
|
|
||||||
.as_ref()
|
|
||||||
.map(|u| u.user.as_str())
|
|
||||||
.unwrap_or("<not bound>"),
|
|
||||||
);
|
|
||||||
self.user_info = None;
|
|
||||||
// No need to notify on unbind (per rfc4511)
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
LdapOp::ModifyRequest(request) => self.do_modify_request(&request).await,
|
|
||||||
LdapOp::ExtendedRequest(request) => self.do_extended_request(&request).await,
|
|
||||||
LdapOp::AddRequest(request) => self
|
|
||||||
.create_user_or_group(request)
|
|
||||||
.await
|
|
||||||
.unwrap_or_else(|e: LdapError| vec![make_add_response(e.code, e.message)]),
|
|
||||||
LdapOp::DelRequest(request) => self
|
|
||||||
.delete_user_or_group(request)
|
|
||||||
.await
|
|
||||||
.unwrap_or_else(|e: LdapError| vec![make_del_response(e.code, e.message)]),
|
|
||||||
LdapOp::CompareRequest(request) => self
|
|
||||||
.do_compare(request)
|
|
||||||
.await
|
|
||||||
.unwrap_or_else(|e: LdapError| vec![make_search_error(e.code, e.message)]),
|
|
||||||
op => vec![make_extended_response(
|
|
||||||
LdapResultCode::UnwillingToPerform,
|
|
||||||
format!("Unsupported operation: {op:#?}"),
|
|
||||||
)],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::password::tests::make_bind_success;
|
|
||||||
use chrono::TimeZone;
|
|
||||||
use ldap3_proto::proto::{LdapBindCred, LdapWhoamiRequest};
|
|
||||||
use lldap_domain::{
|
|
||||||
types::{GroupDetails, GroupId, UserId},
|
|
||||||
uuid,
|
|
||||||
};
|
|
||||||
use lldap_domain_handlers::handler::*;
|
|
||||||
use lldap_test_utils::{MockTestBackendHandler, setup_default_schema};
|
|
||||||
use mockall::predicate::eq;
|
|
||||||
use pretty_assertions::assert_eq;
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use tokio;
|
|
||||||
|
|
||||||
pub fn make_user_search_request<S: Into<String>>(
|
|
||||||
filter: LdapFilter,
|
|
||||||
attrs: Vec<S>,
|
|
||||||
) -> LdapSearchRequest {
|
|
||||||
make_search_request::<S>("ou=people,Dc=example,dc=com", filter, attrs)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn make_group_search_request<S: Into<String>>(
|
|
||||||
filter: LdapFilter,
|
|
||||||
attrs: Vec<S>,
|
|
||||||
) -> LdapSearchRequest {
|
|
||||||
make_search_request::<S>("ou=groups,dc=example,dc=com", filter, attrs)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn setup_bound_handler_with_group(
|
|
||||||
mut mock: MockTestBackendHandler,
|
|
||||||
group: &str,
|
|
||||||
) -> LdapHandler<MockTestBackendHandler> {
|
|
||||||
mock.expect_bind()
|
|
||||||
.with(eq(BindRequest {
|
|
||||||
name: UserId::new("test"),
|
|
||||||
password: "pass".to_string(),
|
|
||||||
}))
|
|
||||||
.return_once(|_| Ok(()));
|
|
||||||
let group = group.to_string();
|
|
||||||
mock.expect_get_user_groups()
|
|
||||||
.with(eq(UserId::new("test")))
|
|
||||||
.return_once(|_| {
|
|
||||||
let mut set = HashSet::new();
|
|
||||||
set.insert(GroupDetails {
|
|
||||||
group_id: GroupId(42),
|
|
||||||
display_name: group.into(),
|
|
||||||
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
|
||||||
uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
|
|
||||||
attributes: Vec::new(),
|
|
||||||
modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
|
||||||
});
|
|
||||||
Ok(set)
|
|
||||||
});
|
|
||||||
setup_default_schema(&mut mock);
|
|
||||||
let mut ldap_handler = LdapHandler::new_for_tests(mock, "dc=Example,dc=com");
|
|
||||||
let request = LdapBindRequest {
|
|
||||||
dn: "uid=test,ou=people,dc=example,dc=coM".to_string(),
|
|
||||||
cred: LdapBindCred::Simple("pass".to_string()),
|
|
||||||
};
|
|
||||||
assert_eq!(ldap_handler.do_bind(&request).await, make_bind_success());
|
|
||||||
ldap_handler
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn setup_bound_readonly_handler(
|
|
||||||
mock: MockTestBackendHandler,
|
|
||||||
) -> LdapHandler<MockTestBackendHandler> {
|
|
||||||
setup_bound_handler_with_group(mock, "lldap_strict_readonly").await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn setup_bound_password_manager_handler(
|
|
||||||
mock: MockTestBackendHandler,
|
|
||||||
) -> LdapHandler<MockTestBackendHandler> {
|
|
||||||
setup_bound_handler_with_group(mock, "lldap_password_manager").await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn setup_bound_admin_handler(
|
|
||||||
mock: MockTestBackendHandler,
|
|
||||||
) -> LdapHandler<MockTestBackendHandler> {
|
|
||||||
setup_bound_handler_with_group(mock, "lldap_admin").await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_whoami_empty() {
|
|
||||||
let mut ldap_handler =
|
|
||||||
LdapHandler::new_for_tests(MockTestBackendHandler::new(), "dc=example,dc=com");
|
|
||||||
let request = LdapOp::ExtendedRequest(LdapWhoamiRequest {}.into());
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.handle_ldap_message(request).await,
|
|
||||||
Some(vec![make_extended_response(
|
|
||||||
LdapResultCode::Success,
|
|
||||||
"".to_string(),
|
|
||||||
)])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_whoami_bound() {
|
|
||||||
let mock = MockTestBackendHandler::new();
|
|
||||||
let mut ldap_handler = setup_bound_admin_handler(mock).await;
|
|
||||||
let request = LdapOp::ExtendedRequest(LdapWhoamiRequest {}.into());
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.handle_ldap_message(request).await,
|
|
||||||
Some(vec![make_extended_response(
|
|
||||||
LdapResultCode::Success,
|
|
||||||
"dn:uid=test,ou=people,dc=example,dc=com".to_string(),
|
|
||||||
)])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
pub(crate) mod compare;
|
|
||||||
pub(crate) mod core;
|
|
||||||
pub(crate) mod create;
|
|
||||||
pub(crate) mod delete;
|
|
||||||
pub(crate) mod handler;
|
|
||||||
pub(crate) mod modify;
|
|
||||||
pub(crate) mod password;
|
|
||||||
pub(crate) mod search;
|
|
||||||
|
|
||||||
pub use core::utils::{UserFieldType, map_group_field, map_user_field};
|
|
||||||
pub use handler::LdapHandler;
|
|
||||||
|
|
||||||
pub use core::group::get_default_group_object_classes;
|
|
||||||
pub use core::user::get_default_user_object_classes;
|
|
||||||
@@ -1,306 +0,0 @@
|
|||||||
use crate::{
|
|
||||||
core::{
|
|
||||||
error::{LdapError, LdapResult},
|
|
||||||
utils::{LdapInfo, get_user_id_from_distinguished_name},
|
|
||||||
},
|
|
||||||
handler::make_modify_response,
|
|
||||||
password::{self},
|
|
||||||
};
|
|
||||||
use ldap3_proto::proto::{LdapModify, LdapModifyRequest, LdapModifyType, LdapOp, LdapResultCode};
|
|
||||||
use lldap_access_control::UserReadableBackendHandler;
|
|
||||||
use lldap_auth::access_control::ValidationResults;
|
|
||||||
use lldap_domain::types::UserId;
|
|
||||||
use lldap_opaque_handler::OpaqueHandler;
|
|
||||||
|
|
||||||
async fn handle_modify_change(
|
|
||||||
opaque_handler: &impl OpaqueHandler,
|
|
||||||
user_id: UserId,
|
|
||||||
credentials: &ValidationResults,
|
|
||||||
user_is_admin: bool,
|
|
||||||
change: &LdapModify,
|
|
||||||
) -> LdapResult<()> {
|
|
||||||
if !change
|
|
||||||
.modification
|
|
||||||
.atype
|
|
||||||
.eq_ignore_ascii_case("userpassword")
|
|
||||||
|| change.operation != LdapModifyType::Replace
|
|
||||||
{
|
|
||||||
return Err(LdapError {
|
|
||||||
code: LdapResultCode::UnwillingToPerform,
|
|
||||||
message: format!(
|
|
||||||
r#"Unsupported operation: `{:?}` for `{}`"#,
|
|
||||||
change.operation, change.modification.atype
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if !credentials.can_change_password(&user_id, user_is_admin) {
|
|
||||||
return Err(LdapError {
|
|
||||||
code: LdapResultCode::InsufficentAccessRights,
|
|
||||||
message: format!(
|
|
||||||
r#"User `{}` cannot modify the password of user `{}`"#,
|
|
||||||
&credentials.user, &user_id
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if let [value] = &change.modification.vals.as_slice() {
|
|
||||||
password::change_password(opaque_handler, user_id, value)
|
|
||||||
.await
|
|
||||||
.map_err(|e| LdapError {
|
|
||||||
code: LdapResultCode::Other,
|
|
||||||
message: format!("Error while changing the password: {e:#?}"),
|
|
||||||
})?;
|
|
||||||
} else {
|
|
||||||
return Err(LdapError {
|
|
||||||
code: LdapResultCode::InvalidAttributeSyntax,
|
|
||||||
message: format!(
|
|
||||||
r#"Wrong number of values for password attribute: {}"#,
|
|
||||||
change.modification.vals.len()
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn handle_modify_request<'cred, UserBackendHandler>(
|
|
||||||
opaque_handler: &impl OpaqueHandler,
|
|
||||||
get_readable_handler: impl FnOnce(
|
|
||||||
&'cred ValidationResults,
|
|
||||||
UserId,
|
|
||||||
) -> Option<&'cred UserBackendHandler>,
|
|
||||||
ldap_info: &LdapInfo,
|
|
||||||
credentials: &'cred ValidationResults,
|
|
||||||
request: &LdapModifyRequest,
|
|
||||||
) -> LdapResult<Vec<LdapOp>>
|
|
||||||
where
|
|
||||||
// Note: ideally, get_readable_handler would take UserId by reference, but I couldn't make the lifetimes work.
|
|
||||||
UserBackendHandler: UserReadableBackendHandler + 'cred,
|
|
||||||
{
|
|
||||||
match get_user_id_from_distinguished_name(
|
|
||||||
&request.dn,
|
|
||||||
&ldap_info.base_dn,
|
|
||||||
&ldap_info.base_dn_str,
|
|
||||||
) {
|
|
||||||
Ok(uid) => {
|
|
||||||
let user_is_admin = get_readable_handler(credentials, uid.clone())
|
|
||||||
.ok_or_else(|| LdapError {
|
|
||||||
code: LdapResultCode::InsufficentAccessRights,
|
|
||||||
message: format!(
|
|
||||||
"User `{}` cannot modify user `{}`",
|
|
||||||
credentials.user.as_str(),
|
|
||||||
uid.as_str()
|
|
||||||
),
|
|
||||||
})?
|
|
||||||
.get_user_groups(&uid)
|
|
||||||
.await
|
|
||||||
.map_err(|e| LdapError {
|
|
||||||
code: LdapResultCode::OperationsError,
|
|
||||||
message: format!("Internal error while requesting user's groups: {e:#?}"),
|
|
||||||
})?
|
|
||||||
.iter()
|
|
||||||
.any(|g| g.display_name == "lldap_admin".into());
|
|
||||||
for change in &request.changes {
|
|
||||||
handle_modify_change(
|
|
||||||
opaque_handler,
|
|
||||||
uid.clone(),
|
|
||||||
credentials,
|
|
||||||
user_is_admin,
|
|
||||||
change,
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
}
|
|
||||||
Ok(vec![make_modify_response(
|
|
||||||
LdapResultCode::Success,
|
|
||||||
String::new(),
|
|
||||||
)])
|
|
||||||
}
|
|
||||||
Err(e) => Err(LdapError {
|
|
||||||
code: LdapResultCode::InvalidDNSyntax,
|
|
||||||
message: format!("Invalid username: {e}"),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::{
|
|
||||||
handler::tests::{
|
|
||||||
setup_bound_admin_handler, setup_bound_handler_with_group,
|
|
||||||
setup_bound_password_manager_handler,
|
|
||||||
},
|
|
||||||
password::tests::expect_password_change,
|
|
||||||
};
|
|
||||||
use chrono::TimeZone;
|
|
||||||
use ldap3_proto::proto::LdapResult as LdapResultOp;
|
|
||||||
use lldap_domain::{
|
|
||||||
types::{GroupDetails, GroupId, GroupName, UserId},
|
|
||||||
uuid,
|
|
||||||
};
|
|
||||||
use lldap_test_utils::MockTestBackendHandler;
|
|
||||||
use mockall::predicate::eq;
|
|
||||||
use pretty_assertions::assert_eq;
|
|
||||||
use std::collections::HashSet;
|
|
||||||
|
|
||||||
fn setup_target_user_groups(
|
|
||||||
mock: &mut MockTestBackendHandler,
|
|
||||||
target_user: &str,
|
|
||||||
groups: Vec<&'static str>,
|
|
||||||
) {
|
|
||||||
mock.expect_get_user_groups()
|
|
||||||
.times(1)
|
|
||||||
.with(eq(UserId::from(target_user)))
|
|
||||||
.return_once(move |_| {
|
|
||||||
let mut g = HashSet::<GroupDetails>::new();
|
|
||||||
for group in groups {
|
|
||||||
g.insert(GroupDetails {
|
|
||||||
group_id: GroupId(42),
|
|
||||||
display_name: GroupName::from(group),
|
|
||||||
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
|
||||||
uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
|
|
||||||
attributes: Vec::new(),
|
|
||||||
modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(g)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_password_modify_request(target_user: &str) -> LdapModifyRequest {
|
|
||||||
LdapModifyRequest {
|
|
||||||
dn: format!("uid={target_user},ou=people,dc=example,dc=com"),
|
|
||||||
changes: vec![LdapModify {
|
|
||||||
operation: LdapModifyType::Replace,
|
|
||||||
modification: ldap3_proto::LdapPartialAttribute {
|
|
||||||
atype: "userPassword".to_string(),
|
|
||||||
vals: vec![b"tommy".to_vec()],
|
|
||||||
},
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_modify_success_response() -> Vec<LdapOp> {
|
|
||||||
vec![LdapOp::ModifyResponse(LdapResultOp {
|
|
||||||
code: LdapResultCode::Success,
|
|
||||||
matcheddn: "".to_string(),
|
|
||||||
message: "".to_string(),
|
|
||||||
referral: vec![],
|
|
||||||
})]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_modify_failure_response(code: LdapResultCode, message: &str) -> Vec<LdapOp> {
|
|
||||||
vec![LdapOp::ModifyResponse(LdapResultOp {
|
|
||||||
code,
|
|
||||||
matcheddn: "".to_string(),
|
|
||||||
message: message.to_string(),
|
|
||||||
referral: vec![],
|
|
||||||
})]
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_modify_password_of_regular_as_admin() {
|
|
||||||
let mut mock = MockTestBackendHandler::new();
|
|
||||||
setup_target_user_groups(&mut mock, "bob", Vec::new());
|
|
||||||
expect_password_change(&mut mock, "bob");
|
|
||||||
let ldap_handler = setup_bound_admin_handler(mock).await;
|
|
||||||
let request = make_password_modify_request("bob");
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.do_modify_request(&request).await,
|
|
||||||
make_modify_success_response()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_modify_password_of_regular_as_regular() {
|
|
||||||
let mut mock = MockTestBackendHandler::new();
|
|
||||||
setup_target_user_groups(&mut mock, "test", Vec::new());
|
|
||||||
expect_password_change(&mut mock, "test");
|
|
||||||
let ldap_handler = setup_bound_handler_with_group(mock, "regular").await;
|
|
||||||
let request = make_password_modify_request("test");
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.do_modify_request(&request).await,
|
|
||||||
make_modify_success_response()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_modify_password_of_regular_as_password_manager() {
|
|
||||||
let mut mock = MockTestBackendHandler::new();
|
|
||||||
setup_target_user_groups(&mut mock, "bob", Vec::new());
|
|
||||||
expect_password_change(&mut mock, "bob");
|
|
||||||
let ldap_handler = setup_bound_password_manager_handler(mock).await;
|
|
||||||
let request = make_password_modify_request("bob");
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.do_modify_request(&request).await,
|
|
||||||
make_modify_success_response()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_modify_password_of_admin_as_password_manager() {
|
|
||||||
let mut mock = MockTestBackendHandler::new();
|
|
||||||
setup_target_user_groups(&mut mock, "bob", vec!["lldap_admin"]);
|
|
||||||
let ldap_handler = setup_bound_password_manager_handler(mock).await;
|
|
||||||
let request = make_password_modify_request("bob");
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.do_modify_request(&request).await,
|
|
||||||
make_modify_failure_response(
|
|
||||||
LdapResultCode::InsufficentAccessRights,
|
|
||||||
"User `test` cannot modify the password of user `bob`"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_modify_password_of_other_regular_as_regular() {
|
|
||||||
let ldap_handler =
|
|
||||||
setup_bound_handler_with_group(MockTestBackendHandler::new(), "regular").await;
|
|
||||||
let request = make_password_modify_request("bob");
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.do_modify_request(&request).await,
|
|
||||||
make_modify_failure_response(
|
|
||||||
LdapResultCode::InsufficentAccessRights,
|
|
||||||
"User `test` cannot modify user `bob`"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_modify_password_of_admin_as_admin() {
|
|
||||||
let mut mock = MockTestBackendHandler::new();
|
|
||||||
setup_target_user_groups(&mut mock, "test", vec!["lldap_admin"]);
|
|
||||||
expect_password_change(&mut mock, "test");
|
|
||||||
let ldap_handler = setup_bound_admin_handler(mock).await;
|
|
||||||
let request = make_password_modify_request("test");
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.do_modify_request(&request).await,
|
|
||||||
make_modify_success_response()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_modify_password_invalid_number_of_values() {
|
|
||||||
let mut mock = MockTestBackendHandler::new();
|
|
||||||
setup_target_user_groups(&mut mock, "bob", Vec::new());
|
|
||||||
let ldap_handler = setup_bound_admin_handler(mock).await;
|
|
||||||
let request = {
|
|
||||||
let target_user = "bob";
|
|
||||||
LdapModifyRequest {
|
|
||||||
dn: format!("uid={target_user},ou=people,dc=example,dc=com"),
|
|
||||||
changes: vec![LdapModify {
|
|
||||||
operation: LdapModifyType::Replace,
|
|
||||||
modification: ldap3_proto::LdapPartialAttribute {
|
|
||||||
atype: "userPassword".to_string(),
|
|
||||||
vals: vec![b"tommy".to_vec(), b"other_value".to_vec()],
|
|
||||||
},
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.do_modify_request(&request).await,
|
|
||||||
make_modify_failure_response(
|
|
||||||
LdapResultCode::InvalidAttributeSyntax,
|
|
||||||
"Wrong number of values for password attribute: 2"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,572 +0,0 @@
|
|||||||
use crate::{
|
|
||||||
core::{
|
|
||||||
error::{LdapError, LdapResult},
|
|
||||||
utils::{LdapInfo, get_user_id_from_distinguished_name},
|
|
||||||
},
|
|
||||||
handler::make_extended_response,
|
|
||||||
};
|
|
||||||
use anyhow::Result;
|
|
||||||
use ldap3_proto::proto::{
|
|
||||||
LdapBindCred, LdapBindRequest, LdapOp, LdapPasswordModifyRequest, LdapResultCode,
|
|
||||||
};
|
|
||||||
use lldap_access_control::{AccessControlledBackendHandler, UserReadableBackendHandler};
|
|
||||||
use lldap_auth::access_control::ValidationResults;
|
|
||||||
use lldap_domain::types::UserId;
|
|
||||||
use lldap_domain_handlers::handler::{BackendHandler, BindRequest, LoginHandler};
|
|
||||||
use lldap_opaque_handler::OpaqueHandler;
|
|
||||||
|
|
||||||
pub(crate) async fn do_bind(
|
|
||||||
ldap_info: &LdapInfo,
|
|
||||||
request: &LdapBindRequest,
|
|
||||||
login_handler: &impl LoginHandler,
|
|
||||||
) -> LdapResult<UserId> {
|
|
||||||
if request.dn.is_empty() {
|
|
||||||
return Err(LdapError {
|
|
||||||
code: LdapResultCode::InappropriateAuthentication,
|
|
||||||
message: "Anonymous bind not allowed".to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let user_id = match get_user_id_from_distinguished_name(
|
|
||||||
&request.dn.to_ascii_lowercase(),
|
|
||||||
&ldap_info.base_dn,
|
|
||||||
&ldap_info.base_dn_str,
|
|
||||||
) {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(e) => {
|
|
||||||
return Err(LdapError {
|
|
||||||
code: LdapResultCode::NamingViolation,
|
|
||||||
message: e.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let password = if let LdapBindCred::Simple(password) = &request.cred {
|
|
||||||
password
|
|
||||||
} else {
|
|
||||||
return Err(LdapError {
|
|
||||||
code: LdapResultCode::UnwillingToPerform,
|
|
||||||
message: "SASL not supported".to_string(),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
match login_handler
|
|
||||||
.bind(BindRequest {
|
|
||||||
name: user_id.clone(),
|
|
||||||
password: password.clone(),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(()) => Ok(user_id),
|
|
||||||
Err(_) => Err(LdapError {
|
|
||||||
code: LdapResultCode::InvalidCredentials,
|
|
||||||
message: "".to_string(),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn change_password<B: OpaqueHandler>(
|
|
||||||
backend_handler: &B,
|
|
||||||
user: UserId,
|
|
||||||
password: &[u8],
|
|
||||||
) -> Result<()> {
|
|
||||||
use lldap_auth::*;
|
|
||||||
let mut rng = rand::rngs::OsRng;
|
|
||||||
let registration_start_request =
|
|
||||||
opaque::client::registration::start_registration(password, &mut rng)?;
|
|
||||||
let req = registration::ClientRegistrationStartRequest {
|
|
||||||
username: user.clone(),
|
|
||||||
registration_start_request: registration_start_request.message,
|
|
||||||
};
|
|
||||||
let registration_start_response = backend_handler.registration_start(req).await?;
|
|
||||||
let registration_finish = opaque::client::registration::finish_registration(
|
|
||||||
registration_start_request.state,
|
|
||||||
registration_start_response.registration_response,
|
|
||||||
&mut rng,
|
|
||||||
)?;
|
|
||||||
let req = registration::ClientRegistrationFinishRequest {
|
|
||||||
server_data: registration_start_response.server_data,
|
|
||||||
registration_upload: registration_finish.message,
|
|
||||||
};
|
|
||||||
backend_handler.registration_finish(req).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn do_password_modification<Handler: BackendHandler>(
|
|
||||||
credentials: &ValidationResults,
|
|
||||||
ldap_info: &LdapInfo,
|
|
||||||
backend_handler: &AccessControlledBackendHandler<Handler>,
|
|
||||||
opaque_handler: &impl OpaqueHandler,
|
|
||||||
request: &LdapPasswordModifyRequest,
|
|
||||||
) -> LdapResult<Vec<LdapOp>> {
|
|
||||||
match (&request.user_identity, &request.new_password) {
|
|
||||||
(Some(user), Some(password)) => {
|
|
||||||
match get_user_id_from_distinguished_name(
|
|
||||||
&user.to_ascii_lowercase(),
|
|
||||||
&ldap_info.base_dn,
|
|
||||||
&ldap_info.base_dn_str,
|
|
||||||
) {
|
|
||||||
Ok(uid) => {
|
|
||||||
let user_is_admin = backend_handler
|
|
||||||
.get_readable_handler(credentials, &uid)
|
|
||||||
.expect("Unexpected permission error")
|
|
||||||
.get_user_groups(&uid)
|
|
||||||
.await
|
|
||||||
.map_err(|e| LdapError {
|
|
||||||
code: LdapResultCode::OperationsError,
|
|
||||||
message: format!(
|
|
||||||
"Internal error while requesting user's groups: {e:#?}"
|
|
||||||
),
|
|
||||||
})?
|
|
||||||
.iter()
|
|
||||||
.any(|g| g.display_name == "lldap_admin".into());
|
|
||||||
if !credentials.can_change_password(&uid, user_is_admin) {
|
|
||||||
Err(LdapError {
|
|
||||||
code: LdapResultCode::InsufficentAccessRights,
|
|
||||||
message: format!(
|
|
||||||
r#"User `{}` cannot modify the password of user `{}`"#,
|
|
||||||
&credentials.user, &uid
|
|
||||||
),
|
|
||||||
})
|
|
||||||
} else if let Err(e) =
|
|
||||||
change_password(opaque_handler, uid, password.as_bytes()).await
|
|
||||||
{
|
|
||||||
Err(LdapError {
|
|
||||||
code: LdapResultCode::Other,
|
|
||||||
message: format!("Error while changing the password: {e:#?}"),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
Ok(vec![make_extended_response(
|
|
||||||
LdapResultCode::Success,
|
|
||||||
"".to_string(),
|
|
||||||
)])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => Err(LdapError {
|
|
||||||
code: LdapResultCode::InvalidDNSyntax,
|
|
||||||
message: format!("Invalid username: {e}"),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => Err(LdapError {
|
|
||||||
code: LdapResultCode::ConstraintViolation,
|
|
||||||
message: "Missing either user_id or password".to_string(),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::handler::{
|
|
||||||
LdapHandler, make_modify_response,
|
|
||||||
tests::{
|
|
||||||
setup_bound_admin_handler, setup_bound_password_manager_handler,
|
|
||||||
setup_bound_readonly_handler,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
use chrono::TimeZone;
|
|
||||||
use ldap3_proto::proto::{
|
|
||||||
LdapBindResponse, LdapModify, LdapModifyRequest, LdapModifyType, LdapOp,
|
|
||||||
LdapResult as LdapResultOp,
|
|
||||||
};
|
|
||||||
use ldap3_proto::{LdapPartialAttribute, proto::LdapExtendedRequest};
|
|
||||||
use lldap_domain::{types::*, uuid};
|
|
||||||
use lldap_test_utils::MockTestBackendHandler;
|
|
||||||
use mockall::predicate::eq;
|
|
||||||
use pretty_assertions::assert_eq;
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use tokio;
|
|
||||||
|
|
||||||
pub fn make_bind_result(code: LdapResultCode, message: &str) -> Vec<LdapOp> {
|
|
||||||
vec![LdapOp::BindResponse(LdapBindResponse {
|
|
||||||
res: LdapResultOp {
|
|
||||||
code,
|
|
||||||
matcheddn: "".to_string(),
|
|
||||||
message: message.to_string(),
|
|
||||||
referral: vec![],
|
|
||||||
},
|
|
||||||
saslcreds: None,
|
|
||||||
})]
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn make_bind_success() -> Vec<LdapOp> {
|
|
||||||
make_bind_result(LdapResultCode::Success, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn expect_password_change(mock: &mut MockTestBackendHandler, user: &str) {
|
|
||||||
use lldap_auth::{opaque, registration};
|
|
||||||
let mut rng = rand::rngs::OsRng;
|
|
||||||
let registration_start_request =
|
|
||||||
opaque::client::registration::start_registration("password".as_bytes(), &mut rng)
|
|
||||||
.unwrap();
|
|
||||||
let request = registration::ClientRegistrationStartRequest {
|
|
||||||
username: user.into(),
|
|
||||||
registration_start_request: registration_start_request.message,
|
|
||||||
};
|
|
||||||
let start_response = opaque::server::registration::start_registration(
|
|
||||||
&opaque::server::ServerSetup::new(&mut rng),
|
|
||||||
request.registration_start_request,
|
|
||||||
&request.username,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
mock.expect_registration_start().times(1).return_once(|_| {
|
|
||||||
Ok(registration::ServerRegistrationStartResponse {
|
|
||||||
server_data: "".to_string(),
|
|
||||||
registration_response: start_response.message,
|
|
||||||
})
|
|
||||||
});
|
|
||||||
mock.expect_registration_finish()
|
|
||||||
.times(1)
|
|
||||||
.return_once(|_| Ok(()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_bind() {
|
|
||||||
let mut mock = MockTestBackendHandler::new();
|
|
||||||
mock.expect_bind()
|
|
||||||
.with(eq(lldap_domain_handlers::handler::BindRequest {
|
|
||||||
name: UserId::new("bob"),
|
|
||||||
password: "pass".to_string(),
|
|
||||||
}))
|
|
||||||
.times(1)
|
|
||||||
.return_once(|_| Ok(()));
|
|
||||||
mock.expect_get_user_groups()
|
|
||||||
.with(eq(UserId::new("bob")))
|
|
||||||
.return_once(|_| Ok(HashSet::new()));
|
|
||||||
let mut ldap_handler = LdapHandler::new_for_tests(mock, "dc=eXample,dc=com");
|
|
||||||
|
|
||||||
let request = LdapOp::BindRequest(LdapBindRequest {
|
|
||||||
dn: "uid=bob,ou=people,dc=example,dc=com".to_string(),
|
|
||||||
cred: LdapBindCred::Simple("pass".to_string()),
|
|
||||||
});
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.handle_ldap_message(request).await.unwrap(),
|
|
||||||
make_bind_success()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_admin_bind() {
|
|
||||||
let mut mock = MockTestBackendHandler::new();
|
|
||||||
mock.expect_bind()
|
|
||||||
.with(eq(lldap_domain_handlers::handler::BindRequest {
|
|
||||||
name: UserId::new("test"),
|
|
||||||
password: "pass".to_string(),
|
|
||||||
}))
|
|
||||||
.times(1)
|
|
||||||
.return_once(|_| Ok(()));
|
|
||||||
mock.expect_get_user_groups()
|
|
||||||
.with(eq(UserId::new("test")))
|
|
||||||
.return_once(|_| {
|
|
||||||
let mut set = HashSet::new();
|
|
||||||
set.insert(GroupDetails {
|
|
||||||
group_id: GroupId(42),
|
|
||||||
display_name: "lldap_admin".into(),
|
|
||||||
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
|
||||||
uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
|
|
||||||
attributes: Vec::new(),
|
|
||||||
modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
|
||||||
});
|
|
||||||
Ok(set)
|
|
||||||
});
|
|
||||||
let mut ldap_handler = LdapHandler::new_for_tests(mock, "dc=example,dc=com");
|
|
||||||
|
|
||||||
let request = LdapBindRequest {
|
|
||||||
dn: "uid=test,ou=people,dc=example,dc=com".to_string(),
|
|
||||||
cred: LdapBindCred::Simple("pass".to_string()),
|
|
||||||
};
|
|
||||||
assert_eq!(ldap_handler.do_bind(&request).await, make_bind_success());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_bind_invalid_dn() {
|
|
||||||
let mock = MockTestBackendHandler::new();
|
|
||||||
let mut ldap_handler = LdapHandler::new_for_tests(mock, "dc=example,dc=com");
|
|
||||||
|
|
||||||
let request = LdapBindRequest {
|
|
||||||
dn: "cn=bob,dc=example,dc=com".to_string(),
|
|
||||||
cred: LdapBindCred::Simple("pass".to_string()),
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.do_bind(&request).await,
|
|
||||||
make_bind_result(
|
|
||||||
LdapResultCode::NamingViolation,
|
|
||||||
r#"Unexpected DN format. Got "cn=bob,dc=example,dc=com", expected: "uid=id,ou=people,dc=example,dc=com""#
|
|
||||||
),
|
|
||||||
);
|
|
||||||
let request = LdapBindRequest {
|
|
||||||
dn: "uid=bob,dc=example,dc=com".to_string(),
|
|
||||||
cred: LdapBindCred::Simple("pass".to_string()),
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.do_bind(&request).await,
|
|
||||||
make_bind_result(
|
|
||||||
LdapResultCode::NamingViolation,
|
|
||||||
r#"Unexpected DN format. Got "uid=bob,dc=example,dc=com", expected: "uid=id,ou=people,dc=example,dc=com""#
|
|
||||||
),
|
|
||||||
);
|
|
||||||
let request = LdapBindRequest {
|
|
||||||
dn: "uid=bob,ou=groups,dc=example,dc=com".to_string(),
|
|
||||||
cred: LdapBindCred::Simple("pass".to_string()),
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.do_bind(&request).await,
|
|
||||||
make_bind_result(
|
|
||||||
LdapResultCode::NamingViolation,
|
|
||||||
r#"Unexpected DN format. Got "uid=bob,ou=groups,dc=example,dc=com", expected: "uid=id,ou=people,dc=example,dc=com""#
|
|
||||||
),
|
|
||||||
);
|
|
||||||
let request = LdapBindRequest {
|
|
||||||
dn: "uid=bob,ou=people,dc=example,dc=fr".to_string(),
|
|
||||||
cred: LdapBindCred::Simple("pass".to_string()),
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.do_bind(&request).await,
|
|
||||||
make_bind_result(
|
|
||||||
LdapResultCode::NamingViolation,
|
|
||||||
r#"Not a subtree of the base tree"#
|
|
||||||
),
|
|
||||||
);
|
|
||||||
let request = LdapBindRequest {
|
|
||||||
dn: "uid=bob=test,ou=people,dc=example,dc=com".to_string(),
|
|
||||||
cred: LdapBindCred::Simple("pass".to_string()),
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.do_bind(&request).await,
|
|
||||||
make_bind_result(
|
|
||||||
LdapResultCode::NamingViolation,
|
|
||||||
r#"Too many elements in distinguished name: "uid", "bob", "test""#
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_password_change() {
|
|
||||||
let mut mock = MockTestBackendHandler::new();
|
|
||||||
mock.expect_get_user_groups()
|
|
||||||
.with(eq(UserId::new("bob")))
|
|
||||||
.returning(|_| Ok(HashSet::new()));
|
|
||||||
expect_password_change(&mut mock, "bob");
|
|
||||||
let mut ldap_handler = setup_bound_admin_handler(mock).await;
|
|
||||||
let request = LdapOp::ExtendedRequest(
|
|
||||||
LdapPasswordModifyRequest {
|
|
||||||
user_identity: Some("uid=bob,ou=people,dc=example,dc=com".to_string()),
|
|
||||||
old_password: None,
|
|
||||||
new_password: Some("password".to_string()),
|
|
||||||
}
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.handle_ldap_message(request).await,
|
|
||||||
Some(vec![make_extended_response(
|
|
||||||
LdapResultCode::Success,
|
|
||||||
"".to_string(),
|
|
||||||
)])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_password_change_modify_request() {
|
|
||||||
let mut mock = MockTestBackendHandler::new();
|
|
||||||
mock.expect_get_user_groups()
|
|
||||||
.with(eq(UserId::new("bob")))
|
|
||||||
.returning(|_| Ok(HashSet::new()));
|
|
||||||
use lldap_auth::*;
|
|
||||||
let mut rng = rand::rngs::OsRng;
|
|
||||||
let registration_start_request =
|
|
||||||
opaque::client::registration::start_registration("password".as_bytes(), &mut rng)
|
|
||||||
.unwrap();
|
|
||||||
let request = registration::ClientRegistrationStartRequest {
|
|
||||||
username: "bob".into(),
|
|
||||||
registration_start_request: registration_start_request.message,
|
|
||||||
};
|
|
||||||
let start_response = opaque::server::registration::start_registration(
|
|
||||||
&opaque::server::ServerSetup::new(&mut rng),
|
|
||||||
request.registration_start_request,
|
|
||||||
&request.username,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
mock.expect_registration_start().times(1).return_once(|_| {
|
|
||||||
Ok(registration::ServerRegistrationStartResponse {
|
|
||||||
server_data: "".to_string(),
|
|
||||||
registration_response: start_response.message,
|
|
||||||
})
|
|
||||||
});
|
|
||||||
mock.expect_registration_finish()
|
|
||||||
.times(1)
|
|
||||||
.return_once(|_| Ok(()));
|
|
||||||
let mut ldap_handler = setup_bound_admin_handler(mock).await;
|
|
||||||
let request = LdapOp::ModifyRequest(LdapModifyRequest {
|
|
||||||
dn: "uid=bob,ou=people,dc=example,dc=com".to_string(),
|
|
||||||
changes: vec![LdapModify {
|
|
||||||
operation: LdapModifyType::Replace,
|
|
||||||
modification: LdapPartialAttribute {
|
|
||||||
atype: "userPassword".to_owned(),
|
|
||||||
vals: vec!["password".as_bytes().to_vec()],
|
|
||||||
},
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.handle_ldap_message(request).await,
|
|
||||||
Some(vec![make_modify_response(
|
|
||||||
LdapResultCode::Success,
|
|
||||||
"".to_string(),
|
|
||||||
)])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_password_change_password_manager() {
|
|
||||||
let mut mock = MockTestBackendHandler::new();
|
|
||||||
mock.expect_get_user_groups()
|
|
||||||
.with(eq(UserId::new("bob")))
|
|
||||||
.returning(|_| Ok(HashSet::new()));
|
|
||||||
use lldap_auth::*;
|
|
||||||
let mut rng = rand::rngs::OsRng;
|
|
||||||
let registration_start_request =
|
|
||||||
opaque::client::registration::start_registration("password".as_bytes(), &mut rng)
|
|
||||||
.unwrap();
|
|
||||||
let request = registration::ClientRegistrationStartRequest {
|
|
||||||
username: "bob".into(),
|
|
||||||
registration_start_request: registration_start_request.message,
|
|
||||||
};
|
|
||||||
let start_response = opaque::server::registration::start_registration(
|
|
||||||
&opaque::server::ServerSetup::new(&mut rng),
|
|
||||||
request.registration_start_request,
|
|
||||||
&request.username,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
mock.expect_registration_start().times(1).return_once(|_| {
|
|
||||||
Ok(registration::ServerRegistrationStartResponse {
|
|
||||||
server_data: "".to_string(),
|
|
||||||
registration_response: start_response.message,
|
|
||||||
})
|
|
||||||
});
|
|
||||||
mock.expect_registration_finish()
|
|
||||||
.times(1)
|
|
||||||
.return_once(|_| Ok(()));
|
|
||||||
let mut ldap_handler = setup_bound_password_manager_handler(mock).await;
|
|
||||||
let request = LdapOp::ExtendedRequest(
|
|
||||||
LdapPasswordModifyRequest {
|
|
||||||
user_identity: Some("uid=bob,ou=people,dc=example,dc=com".to_string()),
|
|
||||||
old_password: None,
|
|
||||||
new_password: Some("password".to_string()),
|
|
||||||
}
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.handle_ldap_message(request).await,
|
|
||||||
Some(vec![make_extended_response(
|
|
||||||
LdapResultCode::Success,
|
|
||||||
"".to_string(),
|
|
||||||
)])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_password_change_errors() {
|
|
||||||
let mut mock = MockTestBackendHandler::new();
|
|
||||||
mock.expect_get_user_groups()
|
|
||||||
.with(eq(UserId::new("bob")))
|
|
||||||
.returning(|_| Ok(HashSet::new()));
|
|
||||||
let mut ldap_handler = setup_bound_admin_handler(mock).await;
|
|
||||||
let request = LdapOp::ExtendedRequest(
|
|
||||||
LdapPasswordModifyRequest {
|
|
||||||
user_identity: None,
|
|
||||||
old_password: None,
|
|
||||||
new_password: None,
|
|
||||||
}
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.handle_ldap_message(request).await,
|
|
||||||
Some(vec![make_extended_response(
|
|
||||||
LdapResultCode::ConstraintViolation,
|
|
||||||
"Missing either user_id or password".to_string(),
|
|
||||||
)])
|
|
||||||
);
|
|
||||||
let request = LdapOp::ExtendedRequest(
|
|
||||||
LdapPasswordModifyRequest {
|
|
||||||
user_identity: Some("uid=bob,ou=groups,ou=people,dc=example,dc=com".to_string()),
|
|
||||||
old_password: None,
|
|
||||||
new_password: Some("password".to_string()),
|
|
||||||
}
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.handle_ldap_message(request).await,
|
|
||||||
Some(vec![make_extended_response(
|
|
||||||
LdapResultCode::InvalidDNSyntax,
|
|
||||||
r#"Invalid username: Unexpected DN format. Got "uid=bob,ou=groups,ou=people,dc=example,dc=com", expected: "uid=id,ou=people,dc=example,dc=com""#.to_string(),
|
|
||||||
)])
|
|
||||||
);
|
|
||||||
let request = LdapOp::ExtendedRequest(LdapExtendedRequest {
|
|
||||||
name: "test".to_string(),
|
|
||||||
value: None,
|
|
||||||
});
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.handle_ldap_message(request).await,
|
|
||||||
Some(vec![make_extended_response(
|
|
||||||
LdapResultCode::UnwillingToPerform,
|
|
||||||
"Unsupported extended operation: test".to_string(),
|
|
||||||
)])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_password_change_unauthorized_password_manager() {
|
|
||||||
let mut mock = MockTestBackendHandler::new();
|
|
||||||
let mut groups = HashSet::new();
|
|
||||||
groups.insert(GroupDetails {
|
|
||||||
group_id: GroupId(0),
|
|
||||||
display_name: "lldap_admin".into(),
|
|
||||||
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
|
||||||
uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
|
|
||||||
attributes: Vec::new(),
|
|
||||||
modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
|
||||||
});
|
|
||||||
mock.expect_get_user_groups()
|
|
||||||
.with(eq(UserId::new("bob")))
|
|
||||||
.times(1)
|
|
||||||
.return_once(|_| Ok(groups));
|
|
||||||
let mut ldap_handler = setup_bound_password_manager_handler(mock).await;
|
|
||||||
let request = LdapOp::ExtendedRequest(
|
|
||||||
LdapPasswordModifyRequest {
|
|
||||||
user_identity: Some("uid=bob,ou=people,dc=example,dc=com".to_string()),
|
|
||||||
old_password: Some("pass".to_string()),
|
|
||||||
new_password: Some("password".to_string()),
|
|
||||||
}
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.handle_ldap_message(request).await,
|
|
||||||
Some(vec![make_extended_response(
|
|
||||||
LdapResultCode::InsufficentAccessRights,
|
|
||||||
"User `test` cannot modify the password of user `bob`".to_string(),
|
|
||||||
)])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_password_change_unauthorized_readonly() {
|
|
||||||
let mut mock = MockTestBackendHandler::new();
|
|
||||||
mock.expect_get_user_groups()
|
|
||||||
.with(eq(UserId::new("bob")))
|
|
||||||
.times(1)
|
|
||||||
.return_once(|_| Ok(HashSet::new()));
|
|
||||||
let mut ldap_handler = setup_bound_readonly_handler(mock).await;
|
|
||||||
let request = LdapOp::ExtendedRequest(
|
|
||||||
LdapPasswordModifyRequest {
|
|
||||||
user_identity: Some("uid=bob,ou=people,dc=example,dc=com".to_string()),
|
|
||||||
old_password: Some("pass".to_string()),
|
|
||||||
new_password: Some("password".to_string()),
|
|
||||||
}
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
ldap_handler.handle_ldap_message(request).await,
|
|
||||||
Some(vec![make_extended_response(
|
|
||||||
LdapResultCode::InsufficentAccessRights,
|
|
||||||
"User `test` cannot modify the password of user `bob`".to_string(),
|
|
||||||
)])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,28 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "lldap_opaque_handler"
|
|
||||||
version = "0.1.0"
|
|
||||||
description = "Opaque handler trait for LLDAP"
|
|
||||||
authors.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
homepage.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
repository.workspace = true
|
|
||||||
|
|
||||||
[features]
|
|
||||||
test = []
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
async-trait = "0.1"
|
|
||||||
|
|
||||||
[dependencies.lldap_auth]
|
|
||||||
path = "../auth"
|
|
||||||
features = ["opaque_server", "opaque_client", "sea_orm"]
|
|
||||||
|
|
||||||
[dependencies.lldap_domain]
|
|
||||||
path = "../domain"
|
|
||||||
|
|
||||||
[dependencies.lldap_domain_model]
|
|
||||||
path = "../domain-model"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
mockall = "0.11.4"
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "lldap_sql_backend_handler"
|
|
||||||
version = "0.1.0"
|
|
||||||
description = "SQL backend for LLDAP"
|
|
||||||
authors.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
homepage.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
repository.workspace = true
|
|
||||||
|
|
||||||
[features]
|
|
||||||
test = []
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
anyhow = "*"
|
|
||||||
async-trait = "0.1"
|
|
||||||
base64 = "0.21"
|
|
||||||
bincode = "1.3"
|
|
||||||
itertools = "0.10"
|
|
||||||
ldap3_proto = "0.6.0"
|
|
||||||
orion = "0.17"
|
|
||||||
serde_json = "1"
|
|
||||||
tracing = "*"
|
|
||||||
|
|
||||||
[dependencies.chrono]
|
|
||||||
features = ["serde"]
|
|
||||||
version = "*"
|
|
||||||
|
|
||||||
[dependencies.rand]
|
|
||||||
features = ["small_rng", "getrandom"]
|
|
||||||
version = "0.8"
|
|
||||||
|
|
||||||
[dependencies.sea-orm]
|
|
||||||
workspace = true
|
|
||||||
features = [
|
|
||||||
"macros",
|
|
||||||
"with-chrono",
|
|
||||||
"with-uuid",
|
|
||||||
"sqlx-all",
|
|
||||||
"runtime-actix-rustls",
|
|
||||||
]
|
|
||||||
|
|
||||||
[dependencies.secstr]
|
|
||||||
features = ["serde"]
|
|
||||||
version = "*"
|
|
||||||
|
|
||||||
[dependencies.serde]
|
|
||||||
workspace = true
|
|
||||||
|
|
||||||
[dependencies.uuid]
|
|
||||||
version = "1"
|
|
||||||
features = ["v1", "v3"]
|
|
||||||
|
|
||||||
[dependencies.lldap_access_control]
|
|
||||||
path = "../access-control"
|
|
||||||
|
|
||||||
[dependencies.lldap_auth]
|
|
||||||
path = "../auth"
|
|
||||||
features = ["opaque_server", "opaque_client", "sea_orm"]
|
|
||||||
|
|
||||||
[dependencies.lldap_domain]
|
|
||||||
path = "../domain"
|
|
||||||
|
|
||||||
[dependencies.lldap_domain_handlers]
|
|
||||||
path = "../domain-handlers"
|
|
||||||
|
|
||||||
[dependencies.lldap_domain_model]
|
|
||||||
path = "../domain-model"
|
|
||||||
|
|
||||||
[dependencies.lldap_opaque_handler]
|
|
||||||
path = "../opaque-handler"
|
|
||||||
|
|
||||||
[dev-dependencies.lldap_test_utils]
|
|
||||||
path = "../test-utils"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
log = "*"
|
|
||||||
mockall = "0.11.4"
|
|
||||||
pretty_assertions = "1"
|
|
||||||
|
|
||||||
[dev-dependencies.tokio]
|
|
||||||
features = ["full"]
|
|
||||||
version = "1.25"
|
|
||||||
|
|
||||||
[dev-dependencies.tracing-subscriber]
|
|
||||||
version = "0.3"
|
|
||||||
features = ["env-filter", "tracing-log"]
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
pub(crate) mod logging;
|
|
||||||
pub(crate) mod sql_backend_handler;
|
|
||||||
pub(crate) mod sql_group_backend_handler;
|
|
||||||
pub(crate) mod sql_opaque_handler;
|
|
||||||
pub(crate) mod sql_schema_backend_handler;
|
|
||||||
pub(crate) mod sql_user_backend_handler;
|
|
||||||
|
|
||||||
pub use sql_backend_handler::SqlBackendHandler;
|
|
||||||
pub use sql_opaque_handler::register_password;
|
|
||||||
pub mod sql_migrations;
|
|
||||||
pub mod sql_tables;
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user