pax_global_header00006660000000000000000000000064152733116060014516gustar00rootroot0000000000000052 comment=3dc384df78a02e8010874ade489cdaf10f286a00 linagora-ldap-rest-a4bb78e/000077500000000000000000000000001527331160600157015ustar00rootroot00000000000000linagora-ldap-rest-a4bb78e/.c8rc.json000066400000000000000000000005201527331160600175060ustar00rootroot00000000000000{ "all": true, "src": ["src"], "extension": [".ts"], "reporter": ["text-summary", "html", "lcov"], "report-dir": "coverage", "exclude": [ "**/*.d.ts", "src/hooks.ts", "src/config/schema.ts", "src/**/types.ts", "src/browser/shared/types/index.ts", "test/**", "dist/**" ], "skip-full": false } linagora-ldap-rest-a4bb78e/.dev.mk000077500000000000000000000026071527331160600170760ustar00rootroot00000000000000#!/usr/bin/make -f SRCBROWSER:=$(shell find src/browser -name '*.ts') DSTBROWSER:=$(subst .ts,.js,$(subst src/,static/,$(SRCBROWSER))) SRCFILES:=$(shell find src/*/ -name '*.ts' | grep -v browser/) _SRCFILES:=$(shell find src/*/ -name '*.ts' | grep -v src/config/schema | grep -v browser/) DSTFILES:=$(subst .ts,.js,$(subst src/,dist/,$(_SRCFILES))) PLUGINFILES:=$(shell find dist/plugins -name '*.js' | grep -v auth/ | grep -v json.js | grep -v appAccount) ALLPLUGINS:=$(subst dist/plugins/,--plugin core/,$(subst .js,,$(PLUGINFILES))) LDAPFLATSCHEMAS := $(shell find static/schemas/twake -name '*.json'|grep -v organization|grep -v groups|sed -e 's/^/--ldap-flat-schema /') all: $(DSTFILES) dist/%.js: src/%.ts echo $* $(MAKE) -f .dev.mk _builddev build: npm run build:prod builddev: $(DSTFILES) $(DSTBROWSER) Dockerfile _builddev: npx rimraf dist npx rollup -c npx rollup -c rollup.browser.config.mjs node scripts/moveBrowserLibs.mjs builddocker: Dockerfile docker build -t ldap-rest . Dockerfile: scripts/buildDockerfile.ts $(SRCFILES) bin rollup.config.mjs tsconfig.json static npx tsx scripts/buildDockerfile.ts doc: $(SRCFILES) typedoc --entryPoints src/bin/index.ts $(shell find src -name '*.ts' | grep -v bin/index) --out docs start: $(DSTFILES) node bin/index.mjs --log-level debug $(ALLPLUGINS) $(LDAPFLATSCHEMAS) test: $(DSTFILES) npm run test .PHONY: builddev,builddocker,test linagora-ldap-rest-a4bb78e/.github/000077500000000000000000000000001527331160600172415ustar00rootroot00000000000000linagora-ldap-rest-a4bb78e/.github/CODEOWNERS000066400000000000000000000010651527331160600206360ustar00rootroot00000000000000# A change to a path listed here needs the approval of a human code owner, # other than its author. See "Reviewing changes" in CONTRIBUTING.md. * @yadd @rezk2ll /src/bin/ @yadd @rezk2ll /src/hooks.ts @yadd @rezk2ll /src/lib/auth/ @yadd @rezk2ll /src/lib/authz/ @yadd @rezk2ll /src/lib/ldapActions.ts @yadd @rezk2ll /src/lib/requestContext.ts @yadd @rezk2ll /src/config/ @yadd @rezk2ll /src/plugins/auth/ @yadd @rezk2ll /.github/ @yadd @rezk2ll linagora-ldap-rest-a4bb78e/.github/PULL_REQUEST_TEMPLATE.md000066400000000000000000000013541527331160600230450ustar00rootroot00000000000000## What and why ## Reviews performed - [ ] Self-review of the final diff - [ ] AI review 1 — assistant: - [ ] AI review 2 — assistant: - [ ] Human code owner, other than the author — required for the paths in `.github/CODEOWNERS` ## Checklist - [ ] A test covers the change, and fails without it - [ ] `npm run check` passes - [ ] `CHANGELOG.md` updated when administrators see the change - [ ] The comments around the touched code still explain something true and non-obvious ([AGENTS.md](../AGENTS.md)) linagora-ldap-rest-a4bb78e/.github/audit-allowlist.json000066400000000000000000000000031527331160600232430ustar00rootroot00000000000000[] linagora-ldap-rest-a4bb78e/.github/dependabot.yml000066400000000000000000000025071527331160600220750ustar00rootroot00000000000000version: 2 updates: # Dependencies of the package, see CONTRIBUTING.md#dependencies - package-ecosystem: npm directory: / schedule: interval: weekly groups: production: dependency-type: production patterns: ['*'] development: dependency-type: development patterns: ['*'] open-pull-requests-limit: 5 # `npm run check:ts7` loads TypeScript 7 through the `typescript7` alias, # see CONTRIBUTING.md#tests-and-checks: the `typescript` package stays on # the line the build and ESLint accept. ignore: - dependency-name: 'typescript' update-types: ['version-update:semver-major'] # The Maven dependencies of lsc-plugin, including lsc-core - package-ecosystem: maven directory: /lsc-plugin schedule: interval: weekly open-pull-requests-limit: 5 - package-ecosystem: github-actions directory: / schedule: interval: weekly open-pull-requests-limit: 5 # The base image of Dockerfile, generated by scripts/buildDockerfile.ts: # an update of it is made there. Dependabot would edit the generated file # alone, which the drift check in test.yml refuses. - package-ecosystem: docker directory: / schedule: interval: weekly open-pull-requests-limit: 5 ignore: - dependency-name: 'node' linagora-ldap-rest-a4bb78e/.github/dependency-review-config.yml000066400000000000000000000005101527331160600246400ustar00rootroot00000000000000# Read by actions/dependency-review-action on every pull request. The gate # matches npm run audit:prod: high and critical advisories. fail-on-severity: high # An advisory that cannot apply here goes in this list as its GHSA id, with a # comment saying why and when the entry can go. None is allowlisted today. allow-ghsas: [] linagora-ldap-rest-a4bb78e/.github/workflows/000077500000000000000000000000001527331160600212765ustar00rootroot00000000000000linagora-ldap-rest-a4bb78e/.github/workflows/contracts.yml000066400000000000000000000026771527331160600240350ustar00rootroot00000000000000name: Published contracts # A pull request shows what it does to the options, the REST API and the # plugin API types that were published. Kept out of test.yml: the `breaking` # label changes the verdict, so a label event has to run this job again, and # its own concurrency group keeps that event from cancelling the test matrix. # See docs/development/contracts.md. on: pull_request: types: [opened, synchronize, reopened, labeled, unlabeled] permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: contracts: runs-on: ubuntu-latest name: Published contracts steps: - name: Checkout code uses: actions/checkout@v7 with: fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v7 with: node-version: 24.x cache: 'npm' - name: Install dependencies run: npm ci - name: Compare the published contracts run: | git fetch origin "$BASE_REF" base=$(git merge-base HEAD "origin/$BASE_REF") if [ "$BREAKING" = 'true' ]; then npx tsx scripts/contracts.ts --base "$base" --breaking else npx tsx scripts/contracts.ts --base "$base" fi env: BASE_REF: ${{ github.base_ref }} BREAKING: ${{ contains(github.event.pull_request.labels.*.name, 'breaking') }} linagora-ldap-rest-a4bb78e/.github/workflows/pages.yml000066400000000000000000000030221527331160600231150ustar00rootroot00000000000000name: Publish docs to GitHub Pages # Triggered on tag push (releases) so the published docs always reflect a # released version. `workflow_dispatch` lets a maintainer publish on demand # (e.g. to fix a typo without cutting a new tag). on: push: tags: - 'v*' workflow_dispatch: permissions: contents: read pages: write id-token: write # Only one Pages deployment can run at a time; cancel any in-flight run if # a newer tag arrives. concurrency: group: pages cancel-in-progress: true jobs: build: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v7 - name: Setup Node.js uses: actions/setup-node@v7 with: node-version: 24.x cache: 'npm' - name: Install dependencies run: npm ci - name: Generate OpenAPI spec run: npm run generate:openapi - name: Build static site run: npm run build:pages - name: Configure Pages uses: actions/configure-pages@v6 # Since v4 the dotfiles are left out unless asked: the site carries # the .nojekyll the build writes - name: Upload Pages artifact uses: actions/upload-pages-artifact@v5 with: path: _site include-hidden-files: true deploy: needs: build runs-on: ubuntu-latest environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v5 linagora-ldap-rest-a4bb78e/.github/workflows/release.yml000066400000000000000000000311161527331160600234430ustar00rootroot00000000000000name: Release on: push: tags: - 'v*' permissions: contents: read packages: write # Serialize releases: a later tag must not race an in-flight publish and win # the npm `latest` dist-tag. Never cancel a publish mid-flight. concurrency: group: release cancel-in-progress: false env: REGISTRY_GHCR: ghcr.io REGISTRY_DOCKER: docker.io IMAGE_GHCR: ghcr.io/linagora/ldap-rest IMAGE_DOCKER: docker.io/yadd/ldap-rest jobs: # Gate the whole release on a green build + test suite. Every publish job # (docker, helm, npm) depends on this, so nothing is published if tests fail. test: name: Test # runs-on: linagora # container: # image: node:22-bookworm runs-on: ubuntu-latest timeout-minutes: 20 services: postgres: image: mirror.gcr.io/library/postgres:17-alpine env: POSTGRES_PASSWORD: postgres ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10 valkey: image: mirror.gcr.io/valkey/valkey:8-alpine ports: - 6379:6379 options: >- --health-cmd "valkey-cli ping" --health-interval 5s --health-timeout 5s --health-retries 10 steps: - name: Checkout uses: actions/checkout@v7 - name: Setup Node.js uses: actions/setup-node@v7 with: node-version: 24.x cache: 'npm' - name: Install dependencies run: npm ci # A minor or patch tag cannot carry a breaking change. See # docs/development/contracts.md. - name: Check the tag against the CHANGELOG run: npx tsx scripts/check-release.ts "${GITHUB_REF#refs/tags/v}" # Build before testing: the suite imports the compiled plugins from dist/. - name: Build run: npm run build # From mirror.gcr.io, as in test.yml: Docker Hub limits anonymous pulls - name: Run tests and check coverage run: npm run coverage:check env: DM_TEST_POSTGRES_URL: postgres://postgres:postgres@localhost:5432/postgres DM_TEST_VALKEY_URL: redis://localhost:6379 DM_TEST_LDAP_IMAGE: mirror.gcr.io/osixia/openldap:1.5.0 # Build and push the amd64 image (fast, runs first). docker-amd64: name: Build & Push Docker (amd64) runs-on: ubuntu-latest needs: test outputs: version: ${{ steps.version.outputs.version }} steps: - name: Checkout uses: actions/checkout@v7 - name: Extract version from tag id: version run: | VERSION=${GITHUB_REF#refs/tags/v} echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "Version: $VERSION" - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Login to GitHub Container Registry uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY_GHCR }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Login to Docker Hub uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY_DOCKER }} username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Extract metadata for Docker id: meta uses: docker/metadata-action@v6 with: images: | ${{ env.IMAGE_GHCR }} ${{ env.IMAGE_DOCKER }} tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} - name: Build and push (amd64) uses: docker/build-push-action@v7 with: context: . platforms: linux/amd64 push: true tags: | ${{ env.IMAGE_GHCR }}:${{ steps.version.outputs.version }}-amd64 ${{ env.IMAGE_DOCKER }}:${{ steps.version.outputs.version }}-amd64 labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max # Build the arm64 image (slow; runs in parallel with the helm job). docker-arm64: name: Build & Push Docker (arm64) runs-on: ubuntu-latest needs: docker-amd64 steps: - name: Checkout uses: actions/checkout@v7 - name: Set up QEMU uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Login to GitHub Container Registry uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY_GHCR }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Login to Docker Hub uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY_DOCKER }} username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push (arm64) uses: docker/build-push-action@v7 with: context: . platforms: linux/arm64 push: true tags: | ${{ env.IMAGE_GHCR }}:${{ needs.docker-amd64.outputs.version }}-arm64 ${{ env.IMAGE_DOCKER }}:${{ needs.docker-amd64.outputs.version }}-arm64 cache-from: type=gha cache-to: type=gha,mode=max # Assemble the multi-arch manifests once both arch builds are pushed. docker-manifest: name: Create Multi-arch Manifest runs-on: ubuntu-latest needs: [docker-amd64, docker-arm64] steps: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Login to GitHub Container Registry uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY_GHCR }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Login to Docker Hub uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY_DOCKER }} username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Create and push multi-arch manifests env: VERSION: ${{ needs.docker-amd64.outputs.version }} run: | MAJOR_MINOR=$(echo "$VERSION" | cut -d. -f1,2) MAJOR=$(echo "$VERSION" | cut -d. -f1) for IMAGE in "$IMAGE_GHCR" "$IMAGE_DOCKER"; do for TAG in "$VERSION" "$MAJOR_MINOR" "$MAJOR" "latest"; do docker buildx imagetools create -t "${IMAGE}:${TAG}" \ "${IMAGE}:${VERSION}-amd64" \ "${IMAGE}:${VERSION}-arm64" done done # Package and push the Helm chart as an OCI artifact (parallel with arm64). helm: name: Release Helm Chart runs-on: ubuntu-latest needs: docker-amd64 steps: - name: Checkout uses: actions/checkout@v7 - name: Setup Helm uses: azure/setup-helm@v5 with: version: v3.16.0 - name: Set chart version from tag run: | VERSION=${GITHUB_REF#refs/tags/v} echo "Setting chart version and appVersion to $VERSION" sed -i "s/^version:.*/version: $VERSION/" helm/ldap-rest/Chart.yaml sed -i "s/^appVersion:.*/appVersion: \"$VERSION\"/" helm/ldap-rest/Chart.yaml cat helm/ldap-rest/Chart.yaml - name: Lint chart run: helm lint helm/ldap-rest - name: Package chart run: helm package helm/ldap-rest --destination . - name: Login to GHCR (Helm registry) run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ${{ env.REGISTRY_GHCR }} --username ${{ github.actor }} --password-stdin - name: Push chart to OCI registry run: | VERSION=${GITHUB_REF#refs/tags/v} helm push "ldap-rest-${VERSION}.tgz" oci://${{ env.REGISTRY_GHCR }}/linagora/charts # Publish the package to the npm registry (gated on the test job). npm: name: Publish to npm runs-on: ubuntu-latest needs: test timeout-minutes: 15 # id-token: write authenticates to npm through OIDC (Trusted Publishing): # no long-lived NPM_TOKEN to store or rotate, and a provenance attestation # is attached to every release for free. permissions: contents: read id-token: write steps: - name: Checkout uses: actions/checkout@v7 - name: Setup Node.js uses: actions/setup-node@v7 with: node-version: 24.x cache: 'npm' # registry-url is intentionally omitted: it makes setup-node write an # .npmrc carrying a dummy auth token that can shadow OIDC. npmjs.org is # already the default registry, so OIDC publishing works without it. # Trusted Publishing requires npm >= 11.5.1. Node 24 bundles a newer one # than that, so this is no longer the workaround it was under Node 22 -- # it is a floor, kept because a publish is the wrong place to find out # that a patch release of Node shipped something older. - name: Update npm for Trusted Publishing run: npm install -g npm@latest - name: Install dependencies run: npm ci # Tests already ran in the `test` job; here we only build the artifacts # needed to pack and publish (dist/ + entry-point files). - name: Build package run: npm run build - name: Verify tag matches package.json version run: | TAG="${GITHUB_REF_NAME#v}" PKG="$(node -p "require('./package.json').version")" if [ "$TAG" != "$PKG" ]; then echo "Tag v$TAG does not match package.json version $PKG." echo "Bump the version in package.json before pushing the tag." exit 1 fi echo "Tag matches package.json version: $PKG" - name: Verify package entry points exist run: | node -e ' const fs = require("fs"); const p = require("./package.json"); const files = new Set(); for (const v of Object.values(p.exports || {})) { if (v && typeof v === "object") { if (v.import) files.add(v.import); if (v.types) files.add(v.types); } } for (const b of Object.values(p.bin || {})) files.add(b); const missing = [...files].filter((f) => !fs.existsSync(f)); if (missing.length) { console.error("Missing entry files:\n " + missing.join("\n ")); process.exit(1); } console.log("Verified " + files.size + " package entry files"); ' - name: Pack tarball run: npm pack - name: Publish to npm run: | # Keep prereleases (e.g. v1.2.3-rc.1) off the `latest` dist-tag. case "$GITHUB_REF_NAME" in *-*) NPM_TAG=next ;; *) NPM_TAG=latest ;; esac npm publish --access public --tag "$NPM_TAG" - name: Upload release tarball uses: actions/upload-artifact@v7 with: name: npm-tarball path: ldap-rest-*.tgz if-no-files-found: error # Cut a GitHub Release, gated on a successful npm publish so the two never # disagree. Release notes come from the matching CHANGELOG.md section; the # published tarball is attached. release: name: Create GitHub Release needs: npm runs-on: ubuntu-latest timeout-minutes: 15 permissions: contents: write steps: - name: Checkout uses: actions/checkout@v7 - name: Download release tarball uses: actions/download-artifact@v8 with: name: npm-tarball - name: Extract release notes from CHANGELOG run: | VERSION="${GITHUB_REF_NAME#v}" awk -v ver="$VERSION" ' /^## v/ { if (inSection) exit hdr = "## v" ver if (substr($0,1,length(hdr))==hdr && (length($0)==length(hdr) || substr($0,length(hdr)+1,1)==" ")) { inSection=1; next } } inSection { print } ' CHANGELOG.md > release-notes.md # Fall back to a link if the version has no CHANGELOG entry. if [ ! -s release-notes.md ]; then echo "See [CHANGELOG.md](https://github.com/${GITHUB_REPOSITORY}/blob/v${VERSION}/CHANGELOG.md)." > release-notes.md fi echo "----- release notes -----"; cat release-notes.md - name: Create Release uses: softprops/action-gh-release@v3 with: body_path: release-notes.md fail_on_unmatched_files: true prerelease: ${{ contains(github.ref_name, '-') }} files: ldap-rest-*.tgz linagora-ldap-rest-a4bb78e/.github/workflows/test.yml000066400000000000000000000233371527331160600230100ustar00rootroot00000000000000name: Test Node.js Module # A change reaches `master` through a pull request, and a pull request is # already tested: listening to every push as well ran the whole matrix twice # for each one. The push trigger is kept for the default branch, so what # lands there is tested as it lands. on: push: branches: - master pull_request: types: [opened, synchronize, reopened] permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: # GitHub's dependency review judges what a pull request adds or changes # before a runner installs it. Its severity and allowlist are in # .github/dependency-review-config.yml. dependency-review: if: github.event_name == 'pull_request' runs-on: ubuntu-latest permissions: contents: read steps: - name: Checkout code uses: actions/checkout@v7 - name: Review dependency changes uses: actions/dependency-review-action@v5 with: config-file: ./.github/dependency-review-config.yml test: runs-on: ubuntu-latest strategy: # Each version answers for itself: one of them stumbling should report # that version, not cancel the verdict of the others. fail-fast: false matrix: node-version: [22.x, 24.x, 26.x] # Docker Hub limits anonymous pulls per address, and the runners share # theirs: every test job failed at "Initialize containers" with # toomanyrequests. The images come from mirror.gcr.io, Google's public # cache of Docker Hub, which needs no credentials (a pull request from a # fork has none). The LDAP image the suite starts itself too, through # DM_TEST_LDAP_IMAGE below. services: postgres: image: mirror.gcr.io/library/postgres:17-alpine env: POSTGRES_PASSWORD: postgres ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10 valkey: image: mirror.gcr.io/valkey/valkey:8-alpine ports: - 6379:6379 options: >- --health-cmd "valkey-cli ping" --health-interval 5s --health-timeout 5s --health-retries 10 steps: - name: Checkout code uses: actions/checkout@v7 - name: Setup Node.js ${{ matrix.node-version }} uses: actions/setup-node@v7 with: node-version: ${{ matrix.node-version }} cache: 'npm' - name: Install dependencies run: npm ci # Reads the advisory database, not the Node version - name: Audit production dependencies if: matrix.node-version == '24.x' run: npm run audit:prod - name: Run rollup build to check typescript compilation run: npm run build # Nothing ran ESLint here, which is how fourteen errors accumulated # unnoticed. Warnings stay warnings: only errors fail the build. # # Neither of these reads the runtime, so one version answers for all # of them: running them across the matrix repeats the same work and, # worse, reports a dependency that did not install on an old Node as a # lint error about unresolved types. - name: Lint if: matrix.node-version == '24.x' run: npm run lint - name: Type-check with TypeScript 7 if: matrix.node-version == '24.x' run: npm run check:ts7 - name: Check formatting if: matrix.node-version == '24.x' run: npm run format:check - name: Check the hooks documented if: matrix.node-version == '24.x' run: npm run check:hooks # See docs/architecture/README.md - name: Check the comments if: matrix.node-version == '24.x' run: npm run check:comments # See docs/plugin-development/openapi.md - name: Check the OpenAPI document if: matrix.node-version == '24.x' run: npm run check:openapi # See docs/development/contracts.md - name: Check the options snapshot if: matrix.node-version == '24.x' run: npm run check:options - name: Check the API reports if: matrix.node-version == '24.x' run: npm run check:api # See docs/development/contracts.md - name: Check the published declarations if: matrix.node-version == '24.x' run: npm run check:declarations # Runs the suite once and gates on coverage at the same time, rather # than running it twice. The threshold covers server code only: the # browser libraries have no DOM in the suite, so a global number would # mix two very different situations into one meaningless figure. - name: Run tests and check coverage run: npm run coverage:check env: DM_TEST_POSTGRES_URL: postgres://postgres:postgres@localhost:5432/postgres DM_TEST_VALKEY_URL: redis://localhost:6379 DM_TEST_LDAP_IMAGE: mirror.gcr.io/osixia/openldap:1.5.0 lsc-plugin-test: runs-on: ubuntu-latest name: LSC ldap-rest plugin (Java) env: LSC_CORE_TAG: v2.2 # Bump when the tag moves without changing its name, or delete the # cache in the Actions page, to rebuild lsc-core from the new commit. LSC_CORE_CACHE_REVISION: '1' steps: - name: Checkout code uses: actions/checkout@v7 - name: Setup Java 17 uses: actions/setup-java@v6 with: distribution: temurin java-version: '17' cache: maven # lsc-core is not on Maven Central: build it once per tag and keep the # artifacts Maven installs under org/lsc. The local repository is # restored only on an exact key, so bumping the revision rebuilds it # rather than reusing the previous build. - name: Cache LSC core (${{ env.LSC_CORE_TAG }}) id: lsc-core uses: actions/cache@v6 with: path: ~/.m2/repository/org/lsc key: lsc-core-${{ env.LSC_CORE_TAG }}-${{ env.LSC_CORE_CACHE_REVISION }} - name: Build and install LSC core (${{ env.LSC_CORE_TAG }}) if: steps.lsc-core.outputs.cache-hit != 'true' run: | git clone --depth 1 --branch "$LSC_CORE_TAG" https://github.com/lsc-project/lsc.git /tmp/lsc cd /tmp/lsc mvn -B -DskipTests install - name: Run plugin unit tests working-directory: lsc-plugin run: mvn -B test - name: Build shaded jar working-directory: lsc-plugin run: mvn -B -DskipTests package - name: Upload jar artifact uses: actions/upload-artifact@v7 with: name: lsc-ldaprest-plugin-jar path: lsc-plugin/target/lsc-ldaprest-plugin-*-with-deps.jar if-no-files-found: error # What the release publishes, checked on every pull request: the chart # release.yml lints after the image is pushed, and the image it builds, # from the Dockerfile committed here. package: runs-on: ubuntu-latest name: Helm chart and Docker image timeout-minutes: 20 steps: - name: Checkout code uses: actions/checkout@v7 - name: Setup Node.js uses: actions/setup-node@v7 with: node-version: 24.x cache: 'npm' - name: Install dependencies run: npm ci # The Dockerfile is generated from src and the build scripts, and the # image is built from what is committed: one that drifted builds the # previous commit. A diff here is fixed by committing what the # generator wrote in the runner, not by editing the Dockerfile. # # The generator is called rather than `npm run build:dockerfile`: that # goes through make, which rebuilds the Dockerfile only when a source # is newer, so a checkout that kept its dates would prove nothing. - name: Check the Dockerfile is up to date run: | npx tsx scripts/buildDockerfile.ts git diff --exit-code Dockerfile - name: Setup Helm uses: azure/setup-helm@v5 with: version: v3.16.0 - name: Lint the Helm chart run: helm lint helm/ldap-rest - name: Setup Docker Buildx uses: docker/setup-buildx-action@v4 - name: Build the image to a local tar uses: docker/build-push-action@v7 with: context: . platforms: linux/amd64 push: false load: true tags: ldap-rest:ci cache-from: type=gha # The gha cache refuses a second write to a key it holds, which # every run of the same pull request after the first is cache-to: type=gha,mode=max,ignore-error=true # The image carries no LDAP settings, so the run gives them, pointing # at nothing. `/healthz` answering means the image starts with its # default plugins; `/readyz` answering 503 names the directory it # cannot reach, as a 200 would mean it reached something. - name: Run the image run: | trap 'docker logs --tail 40 ci || true; docker rm -f ci || true' EXIT docker run -d --name ci -p 8081:8081 \ -e DM_LDAP_URL=ldap://127.0.0.1 \ -e DM_LDAP_DN=cn=admin,dc=example,dc=com \ -e DM_LDAP_PWD=admin \ -e DM_LDAP_BASE=dc=example,dc=com \ ldap-rest:ci for i in $(seq 1 30); do curl -fsS http://127.0.0.1:8081/healthz > /dev/null && break test "$(docker inspect -f '{{.State.Running}}' ci)" = true || { echo 'The container stopped'; exit 1; } sleep 2 done curl -fsS http://127.0.0.1:8081/healthz test "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8081/readyz)" = 503 linagora-ldap-rest-a4bb78e/.gitignore000066400000000000000000000001771527331160600176760ustar00rootroot00000000000000CLAUDE.md .claude* CLAUDE.md coverage/ dist/ node_modules static/browser/ static/*.html _site/ scripts/client-values.txt temp/ linagora-ldap-rest-a4bb78e/.mocharc-one.json000066400000000000000000000001651527331160600210470ustar00rootroot00000000000000{ "require": ["tsx/cjs", "test/setup.ts"], "extensions": ["ts", "test.ts"], "exit": true, "timeout": 30000 } linagora-ldap-rest-a4bb78e/.mocharc.json000066400000000000000000000002241527331160600202640ustar00rootroot00000000000000{ "require": ["tsx/cjs", "test/setup.ts"], "extensions": ["ts", "test.ts"], "spec": "test/**/*.test.ts", "exit": true, "timeout": 30000 } linagora-ldap-rest-a4bb78e/.prettierignore000066400000000000000000000003431527331160600207440ustar00rootroot00000000000000# Build files dist/ build/ helm/ package.json openapi.json docs/usage/options.snapshot.json etc/api/ # Dependencies node_modules/ # sourcemap and minified files *.min.js *.map # Other coverage/ .nyc_output/ logs/ *.log .env linagora-ldap-rest-a4bb78e/.prettierrc000066400000000000000000000002611527331160600200640ustar00rootroot00000000000000{ "semi": true, "trailingComma": "es5", "singleQuote": true, "printWidth": 80, "tabWidth": 2, "useTabs": false, "bracketSpacing": true, "arrowParens": "avoid" } linagora-ldap-rest-a4bb78e/.vscode/000077500000000000000000000000001527331160600172425ustar00rootroot00000000000000linagora-ldap-rest-a4bb78e/.vscode/settings000066400000000000000000000007101527331160600210230ustar00rootroot00000000000000{ "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.fixAll.eslint": true, "source.fixAll": true }, "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "eslint.validate": [ "javascript", "javascriptreact", "typescript", "typescriptreact" ] }linagora-ldap-rest-a4bb78e/AGENTS.md000066400000000000000000000060151527331160600172060ustar00rootroot00000000000000# AGENTS.md Read this first. Follow it exactly. Skipping steps will break CI, block merges, corrupt the codebase or make the maintainers unhappy. ## Presentation - LDAP-Rest is a light directory manager: a REST API in front of an LDAP directory. - The server is written in TypeScript. Features are plugins extending `DmPlugin`, communicating through hooks. - The build is based on Rollup. `rollup.config.mjs` generates the `exports` field of `package.json`: never edit it by hand. - Test with `npm run test:dev`, or `npm run test:one `. ## Code comments - Comment only unconventional or tricky code: a non-obvious constraint, a workaround, a subtle ordering, a security-relevant detail. - Never paraphrase the code. If the comment restates what the next lines do, delete it. - No project history in comments: no "used to", "since 0.6", "replaced the old check". Exception: rare cases where the history is required to understand why the implementation looks the way it does. - No design rationale in build files, scripts or config files. Link to the relevant documentation instead, if anything. - When you change code, update or remove the comments around it. An obsolete comment is worse than no comment: it misleads reviewers, auditors and agents. ## Documentation - Document architecture choices and feature implementation once, in concise Markdown, in the `docs` folder. - Record significant decisions in a single place; do not duplicate them in comments, changelog or commits. - Elsewhere, reference that document rather than repeating its content. - Command line options are documented in `docs/usage/configuration.md`. ## Specific files ### CHANGELOG.md - Audience: administrators operating LDAP-Rest. - List user-visible changes only: new features, behaviour changes, removals, fixes, security notes. - The `Unreleased` section describes the difference with the last release, not the intermediate steps taken during development. Rewrite entries rather than appending corrections. - A breaking change goes under a `### Breaking Changes` heading: a non-major release cannot contain one, see [docs/development/contracts.md](docs/development/contracts.md). - Released sections are never modified. - Keep entries short; link to the documentation for details. ### docs/usage/upgrading.md - Audience: administrators upgrading between released versions. - State only the actions required and their observable effects. ## Git commit messages - Audience: developers. - A concise subject line, then a short body explaining why when it is not obvious. Not a diary, not a copy of the documentation. - Reference issues/PRs here — this is where history belongs. ## Before submitting - [ ] Every comment explains something non-obvious and still true. - [ ] No history, issue numbers or rationale in comments or build files. - [ ] Design changes are documented once, in `docs`. - [ ] CHANGELOG entries are written for administrators and reflect the current state. - [ ] Commit messages are concise and explain why. linagora-ldap-rest-a4bb78e/CHANGELOG.md000066400000000000000000003456401527331160600175260ustar00rootroot00000000000000# Changelog ## v2.0.5 (2026-11-06) Rebuild with updated Alpine packages: alpine-release updated to 3.23.8-r0, apk-tools updated to 3.0.9-r0, libapk updated to 3.0.9-r0. ## v2.0.4 (2026-11-05) ### Security - An `authzDynamic` ACL flag grants a permission only when the token's JSON holds the boolean `true`; `"true"`, `"yes"` and `1` are refused ([#657](https://github.com/linagora/ldap-rest/issues/657), [upgrading](docs/usage/upgrading.md#an-authzdynamic-flag-must-be-a-json-boolean)). - `--trusted-proxy` refuses a malformed address or prefix at startup, where `10.0.0.0/2.` was read as `10.0.0.0/2` and trusted `0.0.0.0/2` ([#663](https://github.com/linagora/ldap-rest/issues/663), [upgrading](docs/usage/upgrading.md#a-malformed-trusted-proxy-stops-the-server)). ### Fixed - The declarations of each sub-path resolve for a plugin compiled with `moduleResolution: nodenext`; the types crossing a file boundary are no longer read as `any` ([#638](https://github.com/linagora/ldap-rest/issues/638)). - A plugin keeps the types the published declarations name without installing them: `@types/express` and `@types/pg` ship with the package, `lemonldap-ng-handler` is described structurally, and the browser declarations no longer import CSS ([#674](https://github.com/linagora/ldap-rest/issues/674)). - An IPv6 network written with a dotted IPv4 tail (`64:ff9b::10.0.0.0/120`, `::ffff:10.0.0.0/104`) is read as written ([#660](https://github.com/linagora/ldap-rest/issues/660), [details](docs/usage/plugins/auth/trusted-proxy.md#supported-formats)). - An IPv4-mapped address matches an IPv4 network in any spelling, and a mapped network matches the peer Node reports ([#664](https://github.com/linagora/ldap-rest/issues/664)). - A peer address carrying a zone index (`fe80::1%eth0`) is matched on the address alone; a trusted entry carrying one is refused ([#665](https://github.com/linagora/ldap-rest/issues/665)). - A creation sending a fixed attribute with a default under an option (`L;lang-fr`, or an alias such as `localityName;lang-fr`) is refused with `400` instead of storing a second value ([#661](https://github.com/linagora/ldap-rest/issues/661), [details](docs/client-development/schemas/README.md#fixed-attributes)). - An organization renamed or moved through a DN written with another spelling of its type (`OU=`, `2.5.4.11=`, `OID.2.5.4.11=`) is synchronised, its paths and links included ([#659](https://github.com/linagora/ldap-rest/issues/659), [details](docs/usage/plugins/ldap/organizations.md#move-organization)). - A rename no longer rewrites a link to another organization whose name ends with the renamed one (`x,ou=Sales`), and matches a sub-organization link in any case ([#658](https://github.com/linagora/ldap-rest/issues/658)). - A SCIM base header whose value starts with `#` (`ou=#,…`, the BER hexstring form included) is ignored instead of failing every operation ([#662](https://github.com/linagora/ldap-rest/issues/662)). ## v2.0.3 (2026-11-04) ### Security - `/logout` refuses a request a browser reports as coming from another site (`403`) and leaves the session alone, where a cross-site link ended it; a console served from another origin lists it in `--oidc-logout-origins` ([GHSA-v2cg-hch2-c7fx](https://github.com/linagora/ldap-rest/security/advisories/GHSA-v2cg-hch2-c7fx), [details](docs/usage/plugins/auth/oidc.md#logout), [upgrading](docs/usage/upgrading.md#a-console-on-another-origin-must-be-trusted-for-logout)). - The organization tree is held to the caller's branches: another branch's description, sub-organizations and local administrators no longer come back from `GET /ldap/organizations/:dn`, its subnode listings or its searches ([GHSA-qgm2-pmv5-9g66](https://github.com/linagora/ldap-rest/security/advisories/GHSA-qgm2-pmv5-9g66), [details](docs/usage/plugins/auth/authz-linid1.md#what-an-administrator-reads), [upgrading](docs/usage/upgrading.md#the-organization-tree-is-held-to-branches)). ## v2.0.2 (2026-11-04) ### Security - An `authzDynamic` token can no longer reach entries beside its ACL branches by writing the DN with an escaped comma, and a branch configured with an equivalent but different spelling (spaces, escapes, `;` separators, OID attribute types) is accepted again ([GHSA-5732-mx58-jf5p](https://github.com/linagora/ldap-rest/security/advisories/GHSA-5732-mx58-jf5p), [details](docs/usage/plugins/auth/authz-dynamic.md#acl-json)). ## v2.0.1 (2026-11-02) Security update of the published image and a fix to plugin per-instance overrides. ### Security - The published image upgrades the Alpine packages of its base image when it is built, so security fixes published after the base image was built reach it ([#634](https://github.com/linagora/ldap-rest/issues/634), [details](docs/usage/operations.md#health-and-readiness)). ### Fixed - A plugin loaded with per-instance overrides no longer counts operations on its own, skips the authorization composition check, or places its routes after the error handler, where a failure answered as HTML and went unlogged ([#625](https://github.com/linagora/ldap-rest/issues/625), [#626](https://github.com/linagora/ldap-rest/issues/626)). ## v2.0.0 (2026-11-01) Plugin services, deprecation aliases and a series of directory-read fixes. Five breaking changes, each with a decision or a configuration change, are in [upgrading](docs/usage/upgrading.md#from-130). ### Breaking Changes - A plugin with its own `service` member must rename it ([upgrading](docs/usage/upgrading.md#a-plugin-with-its-own-service-member)). - A plugin with its own `provides` member must rename it ([upgrading](docs/usage/upgrading.md#a-plugin-with-its-own-provides-member)). - A path parameter is percent-decoded once; a client that encoded one twice now names the escaped value ([upgrading](docs/usage/upgrading.md#a-path-parameter-is-percent-decoded-once)). - A paged search opens its connection on the first page, not when `search()` is called ([upgrading](docs/usage/upgrading.md#a-paged-search-opens-its-connection-on-the-first-page)). - A tombstone's deletion date is read only as a GeneralizedTime with its zone or an ISO 8601 date ([upgrading](docs/usage/upgrading.md#a-deletion-date-written-in-another-shape)). ### Added - Plugins can provide a typed service under a token and reach another plugin's service with `this.service(token)`, which returns the provided instance or `null` ([details](docs/plugin-development/services.md)). - A renamed option can keep its former CLI name and environment variable as deprecated aliases, still read with a startup warning, a plugin's own options included ([details](docs/plugin-development/options.md#deprecating-an-option)). ### Fixed - Invalid DN syntax answers `400` instead of `500` on delete, read and move ([#619](https://github.com/linagora/ldap-rest/issues/619), [#621](https://github.com/linagora/ldap-rest/issues/621)); an escape RFC 4514 does not define is refused on creation ([#500](https://github.com/linagora/ldap-rest/issues/500)); a DN whose parent spells the branch differently is addressed by every flat route ([#587](https://github.com/linagora/ldap-rest/issues/587)); a DN a client writes is held to RFC 4514 on every route that writes one ([#592](https://github.com/linagora/ldap-rest/issues/592)). - An entry or a group named by a DN is written under the name the directory gives it, so two spellings of one name are one entry ([#501](https://github.com/linagora/ldap-rest/issues/501), [details](docs/usage/plugins/ldap/flat-generic.md#create-entry)). - The LDAP search cache no longer merges attribute names holding a separator ([#562](https://github.com/linagora/ldap-rest/issues/562)) and drops a cached read however the write spells the DN ([#563](https://github.com/linagora/ldap-rest/issues/563)). - A password policy whose `pwdMaxAge` goes past the representable date range reports no expiry ([#553](https://github.com/linagora/ldap-rest/issues/553)); a duration or count is bounded to 2⁵³ − 1 ([#558](https://github.com/linagora/ldap-rest/issues/558)); a time the password policy reads honours its zone and fraction ([#557](https://github.com/linagora/ldap-rest/issues/557), [details](docs/usage/plugins/ldap/password-policy.md#managing-policies)). - A directory time is read by one reader everywhere: a deletion date no longer reads a year below 100 as 19xx nor refuses an offset or a fraction, and a GeneralizedTime read by SCIM is written in UTC ([#586](https://github.com/linagora/ldap-rest/issues/586)). - A request carrying `status` instead of `statusCode` answers that status ([#555](https://github.com/linagora/ldap-rest/issues/555)); a non-string identifier answers `400` naming the attribute ([#556](https://github.com/linagora/ldap-rest/issues/556)); a `Content-Type` without a media type answers `400` ([#554](https://github.com/linagora/ldap-rest/issues/554)). - The Twake instances plugin matches an account or organization under another spelling of its branch, or with an escaped comma in its RDN, and reads the organization id from the entry ([#601](https://github.com/linagora/ldap-rest/issues/601), [#610](https://github.com/linagora/ldap-rest/issues/610), [#617](https://github.com/linagora/ldap-rest/issues/617)). - A delete under another spelling of a tombstone's DN writes a tombstone, and a delete of an existing tombstone writes nothing ([#615](https://github.com/linagora/ldap-rest/issues/615)). - SCIM answers `400 invalidValue` for a `subDn` holding an escape RFC 4514 does not define ([#602](https://github.com/linagora/ldap-rest/issues/602), [details](docs/usage/plugins/integrations/scim.md#writing-a-custom-mapping-schema)). ## v1.3.0 (2026-10-31) The October 2026 code-quality work and the five security findings it fixed. Four breaking changes; ten checks before deploying, several for a plugin built outside this repository, [upgrading](docs/usage/upgrading.md#from-121). ### Added - `GET /v1/config` answers the server's `version` ([details](docs/client-development/api/rest-api.md#get-configuration)). - An undeclared option or `DM_*` variable is named at startup, and refused with `--strict-options` ([details](docs/usage/configuration.md#general-options)). - The image declares a `HEALTHCHECK` ([details](docs/usage/operations.md#health-and-readiness)). ### Breaking Changes - Hooks go through `this.hookBus` and are declared in `src/hooks.ts` ([details](docs/usage/upgrading.md#from-121), [#415](https://github.com/linagora/ldap-rest/issues/415)). - `--group-allow-unexistent-members` is gone ([upgrading](docs/usage/upgrading.md#the-group-member-check-option)). - `Config` has no index signature; declare options with `defineOptions` ([upgrading](docs/usage/upgrading.md#a-config-key-a-plugin-did-not-declare)). - `TwakePlugin` takes the WebAdmin URL, token and concurrency ([upgrading](docs/usage/upgrading.md#a-plugin-extending-twakeplugin)). ### Changed - A malformed request answers `400` with every failing path ([details](docs/plugin-development/routes.md#refusals)); Node 22 is the oldest runtime ([upgrading](docs/usage/upgrading.md#from-121)). - The image loads `core/demo/helloworld` and starts without `DM_PLUGINS` ([details](docs/usage/operations.md#health-and-readiness)). - Declared options are checked when their plugin loads ([details](docs/plugin-development/options.md)). ### Fixed - A wrapped error keeps its status and class ([details](docs/client-development/api/rest-api.md#error-handling)); the LDAP pool waits over `--ldap-pool-size` instead of opening a connection per request ([details](docs/usage/operations.md#metrics), [#460](https://github.com/linagora/ldap-rest/issues/460)). - An attribute is checked and written whatever its case or `;binary` option, two names of one are refused, and a `null` for an undeclared one is left out ([#497](https://github.com/linagora/ldap-rest/issues/497), [#495](https://github.com/linagora/ldap-rest/issues/495)). - Sizes past 2⁵³ − 1 bytes and JSON numbers that are not plain decimals are refused; a date below year 100 or with a leap second is read as sent ([#493](https://github.com/linagora/ldap-rest/issues/493), [#496](https://github.com/linagora/ldap-rest/issues/496), [details](docs/usage/plugins/ldap/enterprise-rules.md#scheduled-removal)). - The French labels of the Twake schemas use the typographic apostrophe ([#462](https://github.com/linagora/ldap-rest/issues/462)). ### Security - A DN-named identifier no longer backtracks, and a control character in a name is refused ([details](docs/usage/plugins/ldap/flat-generic.md#create-entry)); a schema pattern reads no value past 4096 characters, and the shipped address patterns are linear ([details](docs/client-development/schemas/README.md#maxlength-optional)). - An undeclared name such as `constructor` or `__proto__` is refused by a strict schema ([#543](https://github.com/linagora/ldap-rest/issues/543)). - The raw API hides an attribute under its OID and aliases, refuses filters on it, and answers 503 when the schema cannot be read ([details](docs/usage/plugins/ldap/raw.md#hidden-attributes)). ## v1.2.1 (2026-10-28) Fifteen fuzzing findings, no new option. One check before deploying, [upgrading](docs/usage/upgrading.md#from-120-to-121). ### Fixed - A name read from a DN decodes its hex escapes (`ou=Sales\2C EMEA` is `Sales, EMEA`), a name starting or ending with a tab, a newline or a Unicode space is stored as sent, and more spellings of one DN compare equal ([#469](https://github.com/linagora/ldap-rest/issues/469), [#473](https://github.com/linagora/ldap-rest/issues/473), [#474](https://github.com/linagora/ldap-rest/issues/474), [#475](https://github.com/linagora/ldap-rest/issues/475)) - An organization whose id holds `$'`, `` $` ``, `$&` or `$$` is read and written under its own branch; the Twake branch patterns must hold `{org}` exactly once ([#472](https://github.com/linagora/ldap-rest/issues/472)) - A write naming an attribute by something that is neither a descriptor nor a numeric OID (`OID.OID.2.5.4.4`, `__proto__`) is refused with 400 ([#476](https://github.com/linagora/ldap-rest/issues/476), [#477](https://github.com/linagora/ldap-rest/issues/477)) - The directory schema is read whatever whitespace separates its tokens, and a definition cut after a keyword no longer discards the whole schema ([#470](https://github.com/linagora/ldap-rest/issues/470), [#478](https://github.com/linagora/ldap-rest/issues/478)) - SCIM: a malformed request is answered 400 where it was answered 500 or written as sent: a filter nested more than 32 levels deep, a PATCH or Bulk operation of the wrong shape or nested too deeply, a body whose `userName`, `displayName`, `id` or attributes are not of their RFC 7643 type (an object was stored as `[object Object]`), a sub-attribute path naming an inherited property; `excludedAttributes` drops the elements it leaves empty ([details](docs/usage/plugins/integrations/scim.md#filter-and-patch-behavior); [#471](https://github.com/linagora/ldap-rest/issues/471), [#479](https://github.com/linagora/ldap-rest/issues/479), [#480](https://github.com/linagora/ldap-rest/issues/480), [#481](https://github.com/linagora/ldap-rest/issues/481), [#482](https://github.com/linagora/ldap-rest/issues/482), [#483](https://github.com/linagora/ldap-rest/issues/483)) ## v1.2.0 (2026-10-28) Five changes need a check before deploying, [upgrading](docs/usage/upgrading.md#from-11-to-120). ### Breaking Changes - An exception nothing caught ends the process with 1, where it kept serving in an unknown state ([#423](https://github.com/linagora/ldap-rest/issues/423)) - `--ldap-dn` and `--ldap-pwd` have no default: the server stops at startup without them, and the Docker image no longer carries `admin` in its `ENV` ([#431](https://github.com/linagora/ldap-rest/issues/431)) - Every entity route, flat, group and organization, checks the shape of a value the same way and answers 400 where it wrote what it was sent: a list for a single-valued attribute, text for a `number`, the deletion of a `required` attribute, an undeclared attribute or a `fixed` one in a change; `null` clears an attribute in a `replace` and is refused elsewhere, where it answered 500; a group creation writes its `fixed` attributes at their default ([#416](https://github.com/linagora/ldap-rest/issues/416), [#448](https://github.com/linagora/ldap-rest/issues/448)) ### Features - On `SIGTERM` and `SIGINT`, the server leaves the endpoints, finishes what is in flight within `--shutdown-timeout` (20 s), closes the broker, the storage backend and the directory connections, then ends; `/readyz` answers `stopping` meanwhile, [docs](docs/usage/operations.md#shutdown) ([#423](https://github.com/linagora/ldap-rest/issues/423)) - `GET /healthz` and `GET /readyz`, outside the API prefix and without authentication: `/readyz` answers 200 once the plugins are loaded and the directory answers, 503 otherwise; `storage`, `rabbitmq`, `james` and `migrationProxy` report their backend there, `degraded` when it cannot be reached. The Helm chart probes them over HTTP, [docs](docs/usage/operations.md#health-and-readiness) ([#423](https://github.com/linagora/ldap-rest/issues/423)) - `core/metrics` serves Prometheus metrics on `--metrics-port` (9464): the requests, the directory operations, the pool, the cache, the calls to Twake, the Cloudery, cozy-stack and CrowdSec, the hooks that fail and the process; the Helm chart exposes the port and renders a `ServiceMonitor` on request. A plugin of your own calls its services through `this.fetch` to be counted, [docs](docs/usage/operations.md#metrics) ([#423](https://github.com/linagora/ldap-rest/issues/423)) - Every answer carries an `X-Request-Id`, and every line logged while the request is served carries it as `requestId`; a line a hook writes names its plugin and the hook, [docs](docs/usage/operations.md#request-id) ([#423](https://github.com/linagora/ldap-rest/issues/423)) - `core/twake/migrationProxy` keeps the migrated-users list of the Twake Mail migration proxy in step with the entries of `--migration-proxy-branch`: an entry gaining its mail is put on the list with its addresses, the addresses of a migrated one follow it, a deleted one is taken off; `GET /api/v1/users/:user/mail-migration` says where an account lives, [docs](docs/usage/plugins/integrations/mail-migration.md) ([#466](https://github.com/linagora/ldap-rest/issues/466)) - The Twake plugins take their webadmin URL with a trailing slash ### Fixes - On every route, a `required` number takes `0` and a `required` boolean `false`; they were refused as missing ([#416](https://github.com/linagora/ldap-rest/issues/416)) - A group schema declaring an `array` attribute without `items` no longer makes every creation answer 500 ([#448](https://github.com/linagora/ldap-rest/issues/448)) - `ldap/bulkImport`: a row with a value under a `fixed` attribute of the schema fails, where the value replaced the fixed one, [docs](docs/usage/plugins/ldap/bulk-import.md#fixed-attributes) ([#445](https://github.com/linagora/ldap-rest/issues/445)) - The flat routes write the classes of the entity on a creation whatever `objectClass` the request carried, as the organization routes do ([#441](https://github.com/linagora/ldap-rest/issues/441)) - `--logger` takes `console` or `file`; an empty value is `console`, any other stops the server at startup, [upgrading](docs/usage/upgrading.md#--logger-console-or-file) ([#432](https://github.com/linagora/ldap-rest/issues/432)) - Organizations write the `fixed` attributes of their schema at their default on a creation and refuse them in a change, as groups and the flat routes do ([#430](https://github.com/linagora/ldap-rest/issues/430)) - The flat routes check an empty string against the schema, and every route refuses an empty DN for a pointer attribute; `[]` in a `replace` is still how an attribute is cleared ([#429](https://github.com/linagora/ldap-rest/issues/429)) - `core/rabbitmq`: SIGTERM and SIGINT end the process once the broker connection is closed, or after 10 seconds; a pod lived on until the end of its grace period ([#425](https://github.com/linagora/ldap-rest/issues/425)) - `core/rabbitmq`: its client's lines are server lines under `plugin: rabbitmq`, following `--logger` and `--log-level`; they went to standard output in the client's own format ([#465](https://github.com/linagora/ldap-rest/pull/465)) - `--logger file`: a line's `error` follows its message; it was dropped ([#465](https://github.com/linagora/ldap-rest/pull/465)) - The error middleware and the routes answering through `serverError` send the same 5xx body; `--debug` names the failure in it on both paths, `DM_DEBUG` is read, and `--debug` no longer takes the next argument as its value ([#426](https://github.com/linagora/ldap-rest/issues/426)) - `groups`, `organizations` and `flatGeneric` announce their schema file (`schemaUrl` in `GET /config`) at the URL where a static plugin serves it merged, as SCIM does since 1.1.0, and leave it out where none does ([#413](https://github.com/linagora/ldap-rest/issues/413)) ## v1.1.0 (2026-10-25) Two changes need a check before deploying, [upgrading](docs/usage/upgrading.md#from-10-to-110). ### Security - `core/static`: the files it serves cannot be framed by another origin (`frame-ancestors 'self'`, `X-Frame-Options: SAMEORIGIN`); a portal that frames them must be named with `--static-csp` and `--static-frame-options`, [docs](docs/usage/plugins/utilities/static.md#security-headers). Every response carries `X-Content-Type-Options: nosniff`, none `X-Powered-By` ([#407](https://github.com/linagora/ldap-rest/issues/407), [#409](https://github.com/linagora/ldap-rest/pull/409)) ### Breaking Changes - An empty `--static-name` is `static`, as announced, not the root; a name Express would not match as written stops the server at startup ([#409](https://github.com/linagora/ldap-rest/pull/409)) ### Fixes - SCIM's schema files (`schemaUrls` in `GET /config`) are announced at the URL of the static plugin serving them, which `--static-name` or `--schemas-path` could make answer 404 ([#411](https://github.com/linagora/ldap-rest/issues/411), [#412](https://github.com/linagora/ldap-rest/pull/412)) ## v1.0.1 (2026-10-25) ### Security - `/logout` (`core/auth/openidconnect`) ends the session here, not only the browser's copy: with `core/bcl` loaded, it records the session it ends, and a copy of the cookie is refused instead of working until its tokens expire. Without `core/bcl`, the first `/logout` warns that it cannot, [docs](docs/usage/plugins/auth/oidc.md#logout) ## v1.0.0 (2026-10-24) Authorization fails closed: every operation made for a request is judged as its caller unless made as the server (`ldap.system`), and a write is judged before the route looks in the directory. The public API is declared stable, [docs](docs/README.md#api-stability). Breaking changes since 0.26: [upgrading](docs/usage/upgrading.md#from-026-to-100). ### Security - An operation made while serving a request is judged as its caller, even when the code did not hand the request down; `ldap.system` makes one as the server, hooks act as the server (`authzDynamic` included), and a lint rule refuses an operation naming neither, [docs](docs/plugin-development/README.md#using-ldap-operations). The server refuses to start with an authorization plugin and no authentication plugin, which let every request through, [docs](docs/usage/plugins/auth/README.md#what-is-refused-at-startup) ([#390](https://github.com/linagora/ldap-rest/pull/390), [#391](https://github.com/linagora/ldap-rest/pull/391)) - The authorization plugins judge before any other plugin: the trash let a caller delete what they could not write, `enterpriseRules` answered 409 where the caller had no right ([#378](https://github.com/linagora/ldap-rest/pull/378)) - A write is judged before the route looks in the directory, so a caller without the right gets the same 403 whether the entry exists or not: renames, flat entries, groups, organizations, moves, tombstones, app accounts; creating a group no longer creates its external members before it is judged. A bulk import row the caller may not write reports that refusal, whether its entry exists, in a dry run or not, [docs](docs/usage/plugins/ldap/bulk-import.md#authorization) ([#381](https://github.com/linagora/ldap-rest/pull/381), [#384](https://github.com/linagora/ldap-rest/pull/384)) - `authzPerBranch`: the most specific branch rule applies, whatever the order the rules are written in, in reads, writes and searches — a restriction on a sub-branch was ignored; `default` no longer overrides a rule refusing a permission; a branch cannot be renamed or moved out of a rule, nor created, by a caller it refuses; setting the organization link along with other changes needs write where the entry stands (read was enough); branches are compared as DNs, however spelled, [docs](docs/usage/plugins/auth/authz-per-branch.md#permission-resolution) ([#388](https://github.com/linagora/ldap-rest/pull/388)) - The organization link escaped the move check when spelled in another case, under its OID or an alias, added rather than replaced, or given several values: an account or a group could be attached to an organization the caller may not write. It is read under any name, adding one is judged as a move, and several or empty values are refused. Every write names its attributes as the server does: an option other than `;binary`, one attribute named twice, or a rename to an RDN naming another attribute or holding several values is refused (400); route checks find schema attributes whatever their case (fixed attributes, group members) ([#384](https://github.com/linagora/ldap-rest/pull/384), [#405](https://github.com/linagora/ldap-rest/pull/405)) - `twake/groups`: any caller got the groups and member profiles of every organization; the routes read as the caller, and under an attachment model a group carries its organization's link ([#383](https://github.com/linagora/ldap-rest/pull/383)) - SCIM: a member given by DN was taken as it stood, so a caller could put in its group an account it may not read; it is looked up as the caller. A DN is compared to the user base RDN by RDN ([#401](https://github.com/linagora/ldap-rest/pull/401)) - Routes no longer tell whether an entry exists where the caller cannot read it: quota and app accounts, `raw` children and search, SCIM `PUT /Users/{id}`. Uniqueness (409) and schema pointers still answer about the whole directory, by design ([#381](https://github.com/linagora/ldap-rest/pull/381), [#393](https://github.com/linagora/ldap-rest/pull/393), [#401](https://github.com/linagora/ldap-rest/pull/401)) ### Fixes - `enterpriseRules`: re-saving an unchanged value no longer conflicts with the entry's applicative copy (409); a rule applies whatever the spelling of the attribute, and a move rechecks the mail domains ([#402](https://github.com/linagora/ldap-rest/issues/402), [#404](https://github.com/linagora/ldap-rest/pull/404)) - `organizations`: `/subnodes` and `/subnodes/search` answered 403 to an administrator held to their organization; deleting `OU=…` skipped the emptiness check ([#403](https://github.com/linagora/ldap-rest/issues/403), [#405](https://github.com/linagora/ldap-rest/pull/405)) - `groups`: a name with a comma that is no DN answers 400 (500 before); a DN outside the groups branch answers 404, where it was read and written as a group; the update route no longer writes the attributes the schema fixes ([#399](https://github.com/linagora/ldap-rest/pull/399), [#405](https://github.com/linagora/ldap-rest/pull/405)) - Shipped schemas: a mail domain or nomenclature value still referenced is not deleted (409); a schema whose branch is absent no longer refuses every delete ([#398](https://github.com/linagora/ldap-rest/pull/398)) - `james`: a mailbox type pointer written `CN=TeamMailbox,…` skipped the team mailbox checks ([#405](https://github.com/linagora/ldap-rest/pull/405)) - `passwordPolicy`: unlocking a user that is not there answered 500 (404) ([#393](https://github.com/linagora/ldap-rest/pull/393)) - `externalUsersInGroups` refuses an external member in any mail domain the directory declares, listed in `GET /config` (`managedDomains`), [docs](docs/usage/plugins/ldap/external-users.md#important-notes) ([#379](https://github.com/linagora/ldap-rest/pull/379)) - The search cache no longer keeps `memberOf` stale after a membership change, [docs](docs/usage/configuration.md#the-search-cache) ([#377](https://github.com/linagora/ldap-rest/pull/377)); UTF-8 hex escapes in DNs are decoded ([#376](https://github.com/linagora/ldap-rest/pull/376)) ### Features - `--app-accounts-user-attribute` sets the attribute the app account routes match `:user` against ([#400](https://github.com/linagora/ldap-rest/pull/400)) ## v0.26.0 (2026-10-23) ### Features - `user.created`, published by `core/twake/instances`, carries the account's Matrix ID (`matrixId`, `@local:domain` from its `mail`), which cozy-stack stores instead of deriving it from the instance's email, [docs](docs/usage/plugins/integrations/instances.md#providers) ([#375](https://github.com/linagora/ldap-rest/pull/375)) ## v0.25.1 (2026-10-23) ### Fixes - Removing the last member of a group through the API leaves the placeholder (`--group-dummy-user`) in its place, instead of a generic 409; without a placeholder, the 409 says a group needs one member, [docs](docs/usage/plugins/ldap/groups.md#remove-member) ([#373](https://github.com/linagora/ldap-rest/pull/373)) ## v0.25.0 (2026-10-23) ### Features - `GET /password-policies` also answers the `attributes` a policy can be given, those the directory's schema defines (OpenLDAP 2.4 lacks some), [docs](docs/usage/plugins/ldap/password-policy.md#managing-policies) ([#366](https://github.com/linagora/ldap-rest/pull/366)) - `GET /authz/scope` says whether the caller may list the password policies (`passwordPolicies`), so a client no longer has to ask and be refused, [docs](docs/usage/plugins/ldap/password-policy.md#managing-policies) ([#371](https://github.com/linagora/ldap-rest/pull/371)) - The common refusals carry a `code` (`unique`, `mailDomain`, `forbidden`) and the `attribute`, `value` or `domains` at fault, next to their message; the bulk import reports them per row, [docs](docs/client-development/api/rest-api.md#error-response-format) ([#372](https://github.com/linagora/ldap-rest/pull/372)) - `--authz-transit-origin-attribute` records the organization an entry put in transit leaves, and removes it when the entry is claimed, [docs](docs/usage/plugins/auth/authz-linid1.md#where-an-entry-comes-from) ([#370](https://github.com/linagora/ldap-rest/pull/370)) ### Fixes - The mail domain refusal lists a domain linked at several levels once ([#350](https://github.com/linagora/ldap-rest/issues/350)) ### Security - Docker image rebuilt on the latest Node.js 24, for CVE-2026-105481, CVE-2026-105482, CVE-2026-105483, CVE-2026-106069, CVE-2026-106072, CVE-2026-106073 and CVE-2026-106074 - `ip-address` 10.7.4, for GHSA-rxp7-6hxx-x8q5 ## v0.24.0 (2026-10-22) ### Features - Schemas: `referentialIntegrity: "remove"` takes a deleted entry's DN out of the attribute, including when the entry is moved to the trash, [docs](docs/usage/plugins/ldap/enterprise-rules.md#deletion-and-references) ([#365](https://github.com/linagora/ldap-rest/pull/365)) ### Fixes - Twake schemas: deleting an account takes it out of the organizations it managed or administered, the mailboxes delegated to it, the groups and shared mailboxes it owned and the devices it owned or managed: an account created again with the same address inherited the old one's rights, [notes](docs/usage/upgrading.md#links-to-deleted-accounts) ([twake-directory-manager#101](https://github.com/linagora/twake-directory-manager/issues/101), [#365](https://github.com/linagora/ldap-rest/pull/365)) - `core/ldap/groups`: an entry moved to the trash leaves its groups, as a deleted one does; an entry created again with the same DN inherited its memberships, [docs](docs/usage/plugins/ldap/trash.md#limitations) ([#365](https://github.com/linagora/ldap-rest/pull/365)) ## v0.23.0 (2026-10-22) ### Features - Schemas: `help` says what an attribute is, for a client to show on demand next to its label, [docs](docs/usage/plugins/ldap/flat-generic.md#explaining-an-attribute-help) ([#364](https://github.com/linagora/ldap-rest/pull/364)) - Schemas: the roles `givenName` and `surname` name the parts of a person's name, which a client composes the `displayName` from; the shipped user schemas mark `givenName` and `sn`, [docs](docs/usage/plugins/ldap/flat-generic.md#semantic-roles) ([#364](https://github.com/linagora/ldap-rest/pull/364)) - Twake users: the Employee ID explains itself, and the Department comes right after it ([#364](https://github.com/linagora/ldap-rest/pull/364)) ## v0.22.1 (2026-10-21) ### Fixes - `core/auth/authzLinid1`: the administrators of `--authz-global-organization` link an organization to any mail domain, as those of the top organization do; where the top organization is a bare container, nobody could give a new organization a domain. `GET /authz/scope` reports it as `linksAnyDomain`, [docs](docs/usage/plugins/auth/authz-linid1.md#mail-domains) ([#363](https://github.com/linagora/ldap-rest/pull/363)) ## v0.22.0 (2026-10-21) ### Features - `twake/james`: the mail domains of the directory are created in James, and removed with their entry; a domain still linked by an organization, or with addresses at it, cannot be deleted, [docs](docs/usage/plugins/integrations/james-mail.md#mail-domains), [notes](docs/usage/upgrading.md#james-follows-the-mail-domains) ([#361](https://github.com/linagora/ldap-rest/pull/361)) - `core/auth/authzLinid1`: `--authz-global-write-branches` reserves the writes and deletes of branches outside the tree, such as the mail domains, to the administrators of `--authz-global-organization`, while every administrator keeps reading them, [docs](docs/usage/plugins/auth/authz-linid1.md#global-branches) ([#361](https://github.com/linagora/ldap-rest/pull/361)) ## v0.21.3 (2026-10-20) ### Fixes - `twake/james`: removing a user's quota, or setting it to `0`, deletes their James quota so that the domain or global one applies; it set a 0 byte quota, refusing every mail, [docs](docs/usage/plugins/integrations/james-mail.md#quota-changes) ([#360](https://github.com/linagora/ldap-rest/pull/360)) ## v0.21.2 (2026-10-20) ### Features - Twake schema `twake/teamMailboxes.json`: team mailboxes as an entity of their own, in `ou=teammailboxes`, which Twake Mail does not expand as lists, [docs](docs/usage/plugins/integrations/james-mailboxes.md#team-mailboxes-in-a-branch-of-their-own) ([#358](https://github.com/linagora/ldap-rest/pull/358)) ### Fixes - Flat entities: an attribute the schema marks `fixed` gets its `default` at creation, as documented; it was checked, then not written - Creating an entry in a branch that does not exist answers `404`, naming the branch; it was a `500` ## v0.21.1 (2026-10-20) ### Security - `core/ldap/passwordPolicy`: `GET /password-policy` and `GET /config` served the default policy to every caller, whatever the authorization plugins allow. The first is now judged like `/password-policies`; the second no longer carries it (`ldapPolicy`) ([#356](https://github.com/linagora/ldap-rest/pull/356)) ### Features - `core/auth/authzLinid1`: `--authz-global-branches` keeps branches outside the organization tree, such as the password policies, to the administrators of `--authz-global-organization` (the top organization by default), [docs](docs/usage/plugins/auth/authz-linid1.md#global-branches) ## v0.21.0 (2026-10-20) ### Breaking Changes - `core/ldap/passwordPolicy`: with several policies and no `--ppolicy-default-dn`, none is taken as the default; it was the first one found, [notes](docs/usage/upgrading.md#several-password-policies) ### Features - `core/ldap/passwordPolicy`: list, create, edit, delete and assign password policies, under the new `--ppolicy-base` ([docs](docs/usage/plugins/ldap/password-policy.md#managing-policies)) - Writes no longer read the entry first to repair its object classes: only a write the directory refuses is repaired, and shows as `err=65` in its logs ([details](docs/usage/plugins/ldap/flat-generic.md#entries-created-by-other-tools)) ### Fixes - `core/ldap/passwordPolicy`: password status and `expiring-soon` use each account's own policy (`pwdPolicySubentry`); status no longer fails with a 500 on unlocked accounts, nor unlock with a 404; a policy without `pwdAllowUserChange` reads as `true` - `twake/instances`, `twake/clouderyProvision`: an account another tool created without the class its instance address needs gets it, then its address and `user.created`. Accounts already blocked need a call, [notes](docs/usage/upgrading.md#accounts-another-tool-created) ([#348](https://github.com/linagora/ldap-rest/issues/348)) ## v0.20.3 (2026-10-16) ### Security - `core/ldap/accountLifecycle`: `POST /{entity}/{id}/password` and `/status` skipped the branch checks of `authzPerBranch` and `authzLinid1`: any authenticated caller could reset a password, and read the generated one, or change an account's state ([#339](https://github.com/linagora/ldap-rest/pull/339)) ### Fixes - Errors that answered 500 answer 400 or 404: a write to a missing entry or value, a DN that is not one, a JSON number or boolean (written as text, or `TRUE` / `FALSE`, where the schema declares a `number` or a `boolean`), a group member change sent through `PUT`, an impossible group move ([#332](https://github.com/linagora/ldap-rest/issues/332), [#333](https://github.com/linagora/ldap-rest/issues/333), [#334](https://github.com/linagora/ldap-rest/issues/334), [#335](https://github.com/linagora/ldap-rest/issues/335), [#347](https://github.com/linagora/ldap-rest/pull/347)) - A write whose body is not a JSON object answers 400; some routes did not answer at all ([#331](https://github.com/linagora/ldap-rest/issues/331)) - `PUT` on a flat entity, a group or an organization refuses a body naming no attribute, or holding keys other than `add`, `replace` and `delete`; it changed nothing and answered success ([#336](https://github.com/linagora/ldap-rest/issues/336)) - `POST /{entity}/{id}/password` refuses a password over 256 characters or holding a control character, and a `forceChange` that is not a boolean ([#337](https://github.com/linagora/ldap-rest/issues/337)) - `twake/instances`, `twake/clouderyProvision`: members whose `uid` holds characters such as `_` or `@` get an instance; two `uid`s reducing to the same slug cannot share one. Members already blocked need a call, [notes](docs/usage/upgrading.md#members-whose-uid-the-cloudery-refused-as-a-slug) ([#330](https://github.com/linagora/ldap-rest/issues/330)) - Returned entries no longer carry empty `*`, `+` and `1.1` keys ([#338](https://github.com/linagora/ldap-rest/issues/338)) - The bundled user, group and unit editors save what was changed, and the browser examples work again ([#342](https://github.com/linagora/ldap-rest/pull/342), [#347](https://github.com/linagora/ldap-rest/pull/347)) ## v0.20.2 (2026-10-15) ### Fixes - `twake/instances`: members of an organization created before 0.13.0 get their instance; those added before this fix need a call, [notes](docs/usage/upgrading.md#members-of-organizations-created-before-0130) ([#329](https://github.com/linagora/ldap-rest/pull/329)) ## v0.20.1 (2026-10-14) ### Fixes - `ldap/groups`: a raw `match` filter accepts `.`, `@` and `'` (linagora/twake-directory-manager#70) ## v0.20.0 (2026-10-13) ### Breaking Changes - `twake/james`: a team mailbox only takes members whose address is at its domain (linagora/twake-directory-manager#66); `--james-team-mailbox-any-domain` lifts the rule, [notes](docs/usage/upgrading.md#team-mailboxes-keep-their-members-to-their-domain) ## v0.19.1 (2026-10-13) ### Fixes - `ldap/groups`: adding or removing members is authorized as a change to the group, and a member the caller cannot read is refused (#325) - `ldap/passwordPolicy`: the per-user routes and the lists are authorized for the caller - authorization plugins: a delegated administrator only links an organization to mail domains an organization above it links, [docs](docs/usage/plugins/auth/authz-linid1.md#mail-domains) (#325) ## v0.19.0 (2026-10-12) ### Breaking Changes - `twake/james` no longer copies `--forward-attribute` to Twake Mail forwards, a user setting; `--james-sync-forwards` restores it, [notes](docs/usage/upgrading.md#forwards-are-no-longer-copied-to-twake-mail) ### Features - Twake schemas: Mongolian (`mn`) texts, [docs](docs/usage/plugins/ldap/flat-generic.md#naming-things-in-the-readers-language) - `ldap/externalUsersInGroups`: `/api/v1/config` publishes the external members branch, [docs](docs/usage/plugins/ldap/external-users.md#config-api) ### Fixes - `ldap/externalUsersInGroups`: an escaped address is stored unescaped, member DNs and managed domains are compared case-insensitively, and only direct children of the branch count - `twake/james`: team mailboxes are created, named by the local part of their address, [docs](docs/usage/plugins/integrations/james-mailboxes.md#team-mailboxes) - `twake/james`: a renamed account keeps its default identity and signatures, [docs](docs/usage/plugins/integrations/james-mail.md#mail-address-changes) - `twake/james`: an address an account takes loses the forwards a former owner left, [docs](docs/usage/plugins/integrations/james-mail.md#forwards-left-on-an-address) - `twake/james`: a mail added to an existing entry is provisioned as at creation ## v0.18.0 (2026-10-12) ### Breaking Changes - `twake/james` no longer copies mailing lists to James address groups: Twake Mail reads them from LDAP with its `LDAPMailingList` mailet. The address groups already created must be deleted, [notes](docs/usage/upgrading.md#mailing-lists-are-no-longer-copied-to-james) - Twake schemas: `hint` and `group` are maps keyed by language tag, like `label`, and any schema may write them so. A client reading them as strings must be upgraded, [notes](docs/usage/upgrading.md#the-twake-schemas-translate-their-hints-and-form-sections), [docs](docs/usage/plugins/ldap/flat-generic.md#naming-things-in-the-readers-language) ### Fixes - A refused attribute is reported as `Attribute "x" …` on every route, with the English `hint` of the schema for an invalid value - `ldap/groups`: a value of the wrong type, or a member that does not exist, answers 400 instead of 500, [docs](docs/client-development/api/rest-api.md#add-group-member) - An empty list clears an attribute on a replace, on every route; on an add or a creation it answers 400 instead of 500 - `twake/james`: a group with a `mail` is no longer handled as a user, [docs](docs/usage/plugins/integrations/james-mail.md#groups) - `twake/james`: a team mailbox follows its group (members, type, address, deletion), not only its creation, [docs](docs/usage/plugins/integrations/james-mailboxes.md#following-the-group) - `twake/groups.json`, `standard/groups.json`: a group's `owner` may be any entry under the base, as `member` may - Twake nomenclature schemas: `cn` and `description` have English and French labels - `twake/nomenclature/twakeDomain.json`: a mail domain can be named by its `associatedDomain` - Flat entities: reading one entry returns the operational attributes its schema declares, such as `memberOf`, [docs](docs/usage/plugins/ldap/flat-generic.md#attributes-a-client-may-not-send) ## v0.17.1 (2026-10-10) ### Fixes - `twake/james`: the calls James refuses while its LDAP replica lags behind the write (rename, quota, alias, forward, delegation, identity) are retried, see `--james-retry-attempts` and `--james-retry-delay`, [docs](docs/usage/plugins/integrations/james-mail.md#replication-lag) - `twake/james`: a team mailbox is created before its members are added - `ldap/onChange`: deleting a user fires the mail hook only; its James data is kept - `ldap/organizations`: the GET of an organization that does not exist answers 404 instead of 500 - A plugin module requested twice is loaded once, and a failed load can be tried again ## v0.17.0 (2026-10-09) ### Breaking Changes - `--quota-attribute` (`DM_QUOTA_ATTRIBUTE`) defaults to `mailQuotaSize`, the quota attribute of the Twake schemas, instead of `mailQuota`: `twake/james` now pushes to Twake Mail the quota the console edits. A deployment whose quota lives in `mailQuota` must now set `DM_QUOTA_ATTRIBUTE=mailQuota`; one that already sets the option keeps its value, [notes](docs/usage/upgrading.md#the-quota-attribute-defaults-to-mailquotasize) ### Features - Schemas: a schema file can `extends` another one, shipped (`"extends": "ldap-rest:twake/users.json"`) or not, and hold only its differences; `null` removes a key, such as an attribute. Applies to every schema option, and `core/static` serves the merged schema, [docs](docs/client-development/schemas/README.md#extending-a-schema) ### Fixes - Twake schemas: the user schema no longer offers `mailQuota`, which the Twake directory schema does not define: a value typed there made the directory refuse the whole change. The mailbox quota is `mailQuotaSize` - A write the directory refuses for its content (an attribute its schema does not define, a value of the wrong syntax, a missing mandatory attribute, a naming rule) answers 400 with the directory's diagnostic instead of 500; a modify adding a value the entry already holds, and a rename or a move onto a DN already taken, answer 409, [docs](docs/client-development/api/rest-api.md#400-bad-request) - `core/static`: a schema file configured outside `--static-path` is served at the `schemaUrl` the configuration API gives for it, instead of the shipped schema of that URL or a 404 - `core/static`: a schema two directories deep or more (`schemas/twake/nomenclature/`), or with a dot in its name (`my.schema.json`), is served with its placeholders replaced instead of as a raw file, and a file under `schemas/` that is not JSON is served instead of refused ## v0.16.2 (2026-10-09) ### Features - `ldap/organizations`: `GET /api/v1/ldap/organizations/:dn/search?q=` searches the organizations of a whole subtree, at any depth, by name, description and path, for a department autocomplete over a wide tree. Bounded by `--ldap-organization-max-subnodes`; a final `moreIndicator` row marks a partial answer and tells the cap from a directory size limit. Advertised as `endpoints.search` by the config API, [docs](docs/client-development/api/rest-api.md#search-organizations-in-a-subtree) ## v0.16.1 (2026-10-07) ### Fixes - `twake/james`: the display name goes to the user's default identity, created when missing, instead of the server-set one, which made Twake Mail add a second identity named "givenName sn", [docs](docs/usage/plugins/integrations/james-mail.md#display-name-and-identity) - `ldap/onChange`: a change of the display name attribute alone (`--display-name-attribute`, `displayName` by default) fires `onLdapDisplayNameChange` ## v0.16.0 (2026-10-07) ### Breaking Changes - `twake/spaces`: with `--rabbitmq-url` and `--twake-group-organization-dn`, the server does not start when the directory schema does not define the organization domain attribute, [notes](docs/usage/upgrading.md#space-events-need-the-organization-domain-attribute) ### Security - Build: fast-glob is replaced with tinyglobby, which drops braces and its unpatched advisory GHSA-vfj7-8cjw-p6xm ### Features - `twake/spaces`: space events carry `organizationDomain`, read from `--twake-instance-organization-domain-attribute` (default `twakeDomain`), [docs](docs/usage/plugins/integrations/spaces.md#events) - `twake/spaces`: space sync. On `twake.space.sync.requested`, read from `--twake-space-sync-queue`, the plugin publishes a space, an organization or every organization as `twake.space.synced`, then `twake.space.sync.completed`, [docs](docs/usage/plugins/integrations/spaces.md#sync) ### Fixes - `twake/groups`: the search also matches the `cn` of a group without a display name ([#289](https://github.com/linagora/ldap-rest/issues/289)) - `ldap/organizations`: the subnode search also matches the user's display name ([#290](https://github.com/linagora/ldap-rest/issues/290)) ## v0.15.0 (2026-10-06) ### Breaking Changes - Array options: a value given on the command line replaces the default instead of being added to it, [notes](docs/usage/upgrading.md#an-array-option-on-the-command-line-replaces-its-default) - Array options from the environment are split according to what they hold: a DN option splits on `;` only, `--auth-hmac` and `--auth-totp` never on spaces, and an empty variable keeps the default; the plural command-line form splits on `;` before `,`, [notes](docs/usage/upgrading.md#to-0150) ([#284](https://github.com/linagora/ldap-rest/issues/284)) - The server refuses to start on an HMAC or TOTP entry that cannot work, an empty `DM_LDAP_URL`, or a number option that is not an integer, [notes](docs/usage/upgrading.md#an-empty-environment-variable-or-a-number-that-is-not-one) ### Security - `core/auth/totp`: an entry whose digits field was not a number registered a user whose code was the string `NaN`, so `Bearer NaN` authenticated; such an entry now stops the server ([#284](https://github.com/linagora/ldap-rest/issues/284)) - proxy-addr 2.0.8: behind `--trusted-proxy`, an IPv4-mapped IPv6 address could pass for a trusted subnet (GHSA-jqcg-44mw-7w3h); http-cache-semantics 4.3.0 (GHSA-ch52-4w7c-c8xp) - `lsc-plugin`: jackson-databind 2.22.3 (CVE-2026-19032, CVE-2026-68497, CVE-2026-83557, CVE-2026-91776, CVE-2026-91777) ### Features - `core/twake/spaces`: spaces of an organization under `/api/v1/organizations/:id/spaces`, holding users and linked groups as viewer, editor or admin, [spaces](docs/usage/plugins/integrations/spaces.md): - `--twake-space-user-role-attribute` keeps each user's role in every space on their entry, as `:`; - with RabbitMQ, changes are published as `twake.space.*` events on `--twake-space-exchange` (default `space`); without the role attribute, a role changed twice in quick succession may be announced twice or not at all; - when a space's last admin is deleted, its editors, or else its viewers, become admins, and a space left with no user of its own is deleted. It loads `core/ldap/onChange`. With the role attribute or RabbitMQ, it does not start when `core/ldap/trash` watches the group or space branches, nor when RabbitMQ cannot be reached. ### Fixes - `core/twake/groups`: `?search=` with an empty value lists everything instead of answering 400; a request an authorization plugin refuses answers 403 instead of 500; moving a group to another organization with its members is refused - `--group-class top,groupOfNames,twakeGroup` (comma-separated, singular form) no longer gives one invalid object class, [details](docs/usage/configuration.md#array-options) - `core/twake/lifecycleEvents`: an operational attribute such as `entryUUID` named by a rule is published instead of left out, [rules](docs/usage/plugins/integrations/lifecycle-events.md#rules) ## v0.14.0 (2026-10-02) ### Breaking Changes - `core/twake/instances`: `--twake-instance-organization-account` is removed, [notes](docs/usage/upgrading.md#--twake-instance-organization-account-is-removed) ([#262](https://github.com/linagora/ldap-rest/pull/262)) ### Security - `core/auth/hmac` refuses a GET, DELETE or HEAD carrying a body, which the signature did not cover, and a signed body under a charset other than UTF-8, which would be read as other characters — [notes](docs/usage/upgrading.md#hmac-refuses-a-body-it-does-not-sign) ([#257](https://github.com/linagora/ldap-rest/issues/257)) ### Features - Plugin API: `ldapmodifyend`, `ldapdeleteend` and `ldaprenameend` fire once a write is over, whatever became of it, with its operation number, which `currentOperation()` gives the request and "done" hooks of a modify, delete or rename: for a plugin that keeps something from one to the other — [hooks](docs/plugin-development/hooks.md#ldapmodifyend-ldapdeleteend-ldaprenameend) - Plugin API: `registerBodyParser` runs a plugin's body parser ahead of the global ones and of authentication, and `jsonBodyParser` builds one that keeps the bytes `core/auth/hmac` checks — [plugin development](docs/plugin-development/README.md#parsing-request-bodies) ### Fixes - Browser `HmacAuthClient`: `post`, `put` and `patch` send a string body as `application/json` unless the caller sets another `Content-Type`; it went as `text/plain` and was refused ([#259](https://github.com/linagora/ldap-rest/issues/259)) - `core/ldap/onChange` no longer keeps, for the life of the process, the entry it read before a write that never completed: a delete the trash or a tombstone takes over, and any modify, delete or rename refused by a hook or by the directory. After a refused rename, a later move of the entry no longer published a rename with that stale entry as its "before" ([#225](https://github.com/linagora/ldap-rest/issues/225)) - `core/scim` parses SCIM bodies only when it is loaded: without it, an `application/json` request under `--scim-prefix` is held to the global 100 kB limit again, and an `application/scim+json` body is no longer read, so `core/auth/hmac` refuses it with `401` ([#268](https://github.com/linagora/ldap-rest/issues/268)) ## v0.13.4 (2026-10-02) ### Features - `core/twake/instances`: `--twake-instance-cloudery-offer` takes one offer per `--twake-instance-dn` expression, separated by `;` in the same order, see [instances](docs/usage/plugins/integrations/instances.md#configuration); a single offer still applies to every account ([#263](https://github.com/linagora/ldap-rest/issues/263)) ## v0.13.3 (2026-10-02) ### Features - `core/twake/instances`: `--twake-instance-organization-account` names an account of each organization that gets the organization instance's address, see [instances](docs/usage/plugins/integrations/instances.md#organizations) ([#255](https://github.com/linagora/ldap-rest/issues/255)) ### Fixes - `core/auth/hmac` hashes the body bytes received, as documented, instead of the body re-serialized with `JSON.stringify`: a request whose JSON is not serialized that way, or a form body, was refused — [notes](docs/usage/upgrading.md#hmac-hashes-the-body-bytes-received) ([#251](https://github.com/linagora/ldap-rest/issues/251)) - `core/scim`: `--scim-bulk-max-payload-size` applies to a SCIM request sent as `application/json` too; it was cut at body-parser's 100kb default and answered 413. The `application/scim+json` parser added in v0.13.1 now only covers the SCIM prefix ## v0.13.2 (2026-10-01) ### Security - `core/auth/hmac`: the signature did not cover a body that no global parser had read — a `multipart/form-data` upload, a `text/plain` body — and such a request was accepted with whatever body it carried. It is refused now — [notes](docs/usage/upgrading.md#hmac-refuses-a-body-it-cannot-check) ([GHSA-j5c6-cg2q-vrfw](https://github.com/linagora/ldap-rest/security/advisories/GHSA-j5c6-cg2q-vrfw)) ## v0.13.1 (2026-10-01) ### Fixes - `core/auth/hmac` accepts a signed SCIM write sent as `application/scim+json`; it hashed an empty body and answered 401 ([#246](https://github.com/linagora/ldap-rest/issues/246)) - `core/twake/tombstone`: the `deleted` event of a tombstoned entry carries the deleting request's `$context` (actor, request id, source); it had none ([#247](https://github.com/linagora/ldap-rest/issues/247)) ## v0.13.0 (2026-10-01) ### Breaking Changes - `core/auth/openidconnect`: a session ends with its access token, unless a refresh token renews it — [notes](docs/usage/upgrading.md#an-openid-connect-session-ends-with-its-access-token) - `core/ldap/trash`: `--trash-watched-bases` separates its DNs with `;`, so each base is a whole branch. [notes](docs/usage/upgrading.md#trash-watched-bases-are-separated-by-) ([#226](https://github.com/linagora/ldap-rest/issues/226)) ### Features - `core/twake/lifecycleEvents`: groups too, with `updated`, `memberAdded` and `memberRemoved`, `$changed.` sources and member lists in payloads ([#233](https://github.com/linagora/ldap-rest/pull/233)). [doc](docs/usage/plugins/integrations/lifecycle-events.md) - `core/twake/groups`: organization groups, one branch per organization, under `/v1/organizations/:id/groups` ([#234](https://github.com/linagora/ldap-rest/pull/234)). [doc](docs/usage/plugins/integrations/groups.md) - `abstract/ldapFlat`: `GET /api/v1/ldap/users`, and the list of every other flat entity, take `limit` and answer at most that many entries, with `X-Result-Truncated: true` when others were left out. The groups list does not take it yet — [doc](docs/usage/plugins/ldap/flat-generic.md#long-lists) ### Bug Fixes - `abstract/ldapFlat`: `GET /api/v1/ldap/users`, and the list of every other flat entity, answer `422` naming the cause to a list longer than the directory's size limit; it was a `500`. The groups list still answers `500` — [doc](docs/usage/plugins/ldap/flat-generic.md#long-lists) - `core/auth/openidconnect`: a client asking for JSON only (`Accept: application/json`) with no session is answered `401`; it got no answer at all - `core/scim`: `active` reads, filters and clears only the configured lock value, so a ppolicy lockout after failed binds reads as active and `"active": true` leaves it in place ([#232](https://github.com/linagora/ldap-rest/pull/232)). [doc](docs/usage/plugins/integrations/scim.md#deactivating-an-account-active) - `declareObjectClasses` keeps one declaration per base and class set, so building the same `LdapFlat` again no longer grows the list ([#229](https://github.com/linagora/ldap-rest/issues/229)) - An add writes the entry the `ldapaddrequest` hooks return, the one `ldapadddone` and `ldapaddafter` receive; a hook returning a new object had its changes dropped ([#244](https://github.com/linagora/ldap-rest/issues/244)) - `core/twake/instances`: an organization is linked to its instance before its members are sent, which the Cloudery refused otherwise ([#236](https://github.com/linagora/ldap-rest/pull/236)). [notes](docs/usage/upgrading.md#twakeinstances-links-an-organization-before-its-members) - `core/twake/groups`: `static/schemas/twake/organizationGroups.json`, a group schema for organization groups; the default one refused them ([#238](https://github.com/linagora/ldap-rest/issues/238)). [doc](docs/usage/plugins/integrations/groups.md) - `core/twake/groups`: a SCIM group of an organization gets a generated `cn`, its SCIM `id`, once `--scim-group-mapping` maps `displayName` to the display name attribute, and SCIM no longer refuses a duplicate `displayName`. A `scimgroupcreate` hook receives the group base and may set the new group's `id` ([#239](https://github.com/linagora/ldap-rest/issues/239)). [doc](docs/usage/plugins/integrations/groups.md#scim-groups) ## v0.12.0 (2026-09-28) ### Deprecations - `core/twake/cozyProvision` and `core/twake/clouderyProvision` are replaced by `core/twake/instances`, whose options are renamed; the server does not start with both — [notes](docs/usage/upgrading.md#twakeinstances-replaces-cozyprovision-and-clouderyprovision) ### Features - `core/auth/openidconnect`: `/logout` also ends the session at the provider (RP-Initiated Logout) — [doc](docs/usage/plugins/auth/oidc.md#logout) ([#224](https://github.com/linagora/ldap-rest/pull/224)) - `core/twake/lifecycleEvents`: publishes account lifecycle events (created, role changed, disabled, enabled, deleted) to RabbitMQ, whichever API wrote the entry — [doc](docs/usage/plugins/integrations/lifecycle-events.md) ([#203](https://github.com/linagora/ldap-rest/pull/203)) - `core/twake/instances`: each account created through this server gets its workplace instance, announced with `user.created` — [doc](docs/usage/plugins/integrations/instances.md) ([#228](https://github.com/linagora/ldap-rest/pull/228)) - `core/twake/tombstone`: a deleted account stays as a tombstone, its identifiers reserved, until an operator erases it — [doc](docs/usage/plugins/integrations/tombstone.md) ([#204](https://github.com/linagora/ldap-rest/pull/204)) - `ldapaddafter` hook, awaited once an add has written the entry — [doc](docs/plugin-development/hooks.md#ldapaddafter) ([#227](https://github.com/linagora/ldap-rest/pull/227)) ### Bug Fixes - `core/scim`: two PATCHes removing different members of a group at once no longer undo each other — [doc](docs/usage/plugins/integrations/scim.md#filter-and-patch-behavior) ([#231](https://github.com/linagora/ldap-rest/pull/231)) ## v0.11.2 (2026-09-27) ### Bug Fixes - `core/ldap/flatGeneric`: attaching an account another tool created as a bare `inetOrgPerson` no longer fails: a write under a declared branch adds the auxiliary class it needs (`--ldap-flat-auto-repair`, on by default), and a change the entry cannot hold answers `409` instead of `500` — [doc](docs/usage/plugins/ldap/flat-generic.md#entries-created-by-other-tools) ([#223](https://github.com/linagora/ldap-rest/pull/223)) ## v0.11.1 (2026-09-27) ### Features - `core/auth/authzLinid1`, `core/auth/authzPerBranch`: a transit branch (`--authz-transit-branch`) to hand accounts and groups over between organizations; an account attached to none is in transit, option or not — [doc](docs/usage/plugins/auth/authz-linid1.md#transit-handing-an-entry-over) ([#220](https://github.com/linagora/ldap-rest/issues/220)) - `core/storage`: `postgres` and `valkey` backends — [doc](docs/usage/plugins/utilities/storage.md#backends) ([#183](https://github.com/linagora/ldap-rest/issues/183)) ### Bug Fixes - authorization by attachment: nobody, a global administrator included, could write an entry attached to no organization ## v0.11.0 (2026-09-26) ### Breaking Changes - `core/auth/authzLinid1` judges accounts and groups by the organization they are attached to, whatever `--authz-filter-attached-entries` says — every administrator reads the organization tree, and a caller administering no branch is refused — [notes](docs/usage/upgrading.md#authzlinid1-judges-accounts-and-groups-by-their-organization) ([#216](https://github.com/linagora/ldap-rest/issues/216)) ### Security - `core/ldap/groups`: any authenticated caller could list every group and its members — [notes](docs/usage/upgrading.md#group-reads-are-judged) ([#216](https://github.com/linagora/ldap-rest/issues/216)) ### Features - `core/auth/fake`: a fixed identity for development, refused with `NODE_ENV=production` — [doc](docs/usage/plugins/auth/fake.md) ### Bug Fixes - `core/ldap/organizations`: an entry with a single `objectClass` could not be modified (500) ## v0.10.0 (2026-09-26) ### Breaking Changes - `--ldap-base` (`DM_LDAP_BASE`) is required: the server no longer guesses it from `--ldap-dn` — [notes](docs/usage/upgrading.md#ldap-base-is-now-required) - `core/auth/authzPerBranch`: the `groups` rules of `--authz-per-branch-config` granted nothing, and now apply ([#212](https://github.com/linagora/ldap-rest/issues/212), [notes](docs/usage/upgrading.md#group-rules-now-apply)) - A `uid` naming several entries is refused by `core/auth/authzLinid1`, and gets no `groups` rule from `core/auth/authzPerBranch`, instead of being resolved to whichever entry the server listed first — [notes](docs/usage/upgrading.md#an-identity-naming-several-entries-is-refused) - `onLdapChange` gives the full values on each side of a change, not the values the request named — [notes](docs/usage/upgrading.md#onldapchange-gives-full-values) ### Security - `core/auth/authzLinid1`: an identity holding `*` resolved to the first user the pattern matched, and was judged with that user's permissions - `core/twake/appAccountsConsistency`: deleting a user whose mail holds `*` deleted every applicative account the pattern matched ### Features - `core/twake/cozyProvision`, `core/twake/clouderyProvision`: an empty `--cozy-user-deleted-routing-key` stops them publishing the deletion event, for a deployment where another plugin publishes it. The instance is still deleted - `core/ldap/onChange` publishes `onLdapEntryChange`: the entry before and after each add, modify, rename and delete ([#206](https://github.com/linagora/ldap-rest/issues/206), [doc](docs/usage/plugins/ldap/on-change.md)) - The `ldap*done` hooks and `onLdapEntryChange` receive who made the write and through which door (REST or SCIM) ([#207](https://github.com/linagora/ldap-rest/issues/207)) - A plugin declares the operational attributes it follows, such as `pwdAccountLockedTime`, and `onLdapEntryChange` gives them on both sides ([#208](https://github.com/linagora/ldap-rest/issues/208)) ### Bug Fixes - Authorization plugins: a caller renamed, moved to the trash or deleted through ldap-rest no longer keeps the rights of its former DN until the cache TTL runs out (5 minutes for `core/auth/authzLinid1`) - `core/auth/authzLinid1`: a failed directory lookup read as an unknown user, which `--authz-unresolved-user allow` let through, and was cached. It fails the request now, and the next one searches again - Values written into a search filter unescaped: a DN holding `(` or `)` failed the search, and one holding `*` matched nothing - `core/ldap/groups`: a deleted entry stayed in its groups - `core/ldap/organizations`: an organization looked empty, and was deleted with entries still linked to it - `core/ldap/departmentSync`: a renamed organization left the links to it behind - `core/auth/authzLinid1`: an administrator got no permission from the organizations naming them - `core/ldap/groups`: an entry left its groups when its delete was asked for, even when the delete was refused, failed, or was turned into a move by `core/ldap/trash`. It leaves them once the delete has landed. A group losing its last member keeps the `--group-dummy-user` placeholder instead of the deleted DN - `core/ldap/onChange`: a write that leaves every value as it was fires no hook, a rename fires them, and a modify of an entry that has children is no longer missed. `onLdapDisplayNameChange` fires only when the name changed - `core/ldap/trash`: moving an entry to the trash fires the `ldaprenamedone` hooks, as a rename does ## v0.9.0 (2026-09-24) Authorization that says what it judges: rules keyed on a login rather than on whatever an authenticator publishes, plugins scoped to the authenticators whose requests they judge, and a server that refuses to start on a combination nobody decided. Several paths where a request went unchecked are closed, and closing them changes what some deployments answer. **Read [Upgrading](docs/usage/upgrading.md) before deploying this one.** Every change below that needs a decision links to its notes there. ### Breaking Changes - Two authorization plugins judging the same requests stop the server, unless `authz_for` separates them or `--authz-combine` keeps the AND they composed as. `GET /v1/authz/scope` answers `described: false` where nothing can describe the caller's scope, rather than `unrestricted: true` ([#189](https://github.com/linagora/ldap-rest/issues/189), [notes](docs/usage/upgrading.md#two-authorization-plugins-judging-the-same-requests-no-longer-start)) - A second word after an option taking a list — `--authz-for oidc authToken` — is refused rather than dropped in silence — [notes](docs/usage/upgrading.md#a-second-word-after-an-option-taking-a-list-is-refused) - `getLogger()` is typed `winston.Logger | undefined`: a plugin reading it without a guard stops compiling ([#199](https://github.com/linagora/ldap-rest/issues/199), [notes](docs/usage/upgrading.md#getlogger-may-return-undefined-and-its-type-says-so)) - `/subnodes/search` caps its matches at `--ldap-organization-max-subnodes` and ends the list with the `moreIndicator` row `/subnodes` already used — [notes](docs/usage/upgrading.md#subnodessearch-caps-what-it-returns) - `DM.claimedAuthPrefixes` and `claimedPrefixes` are gone: nothing called them, and they described a mechanism the dispatcher does not implement ### Security - `lib/authz/base`: an authenticated identity that did not resolve skipped every check, so `authzLinid1` behind OpenID Connect or LLNG checked nothing across the whole tree. It is refused now, and `--authz-unresolved-user allow` restores the old behaviour — [notes](docs/usage/upgrading.md#an-identity-that-does-not-resolve-is-refused) - `core/auth/authzDynamic`: the token ACLs stepped aside for any request another authenticator had identified, so a `core/auth/token` static token reached the whole directory unscoped. They apply to every token now, and `--authz-dynamic-bypass` names what may pass without one — [notes](docs/usage/upgrading.md#authzdynamic-no-longer-steps-aside-for-another-authenticator) - `core/auth/openidconnect`: `auth_path_prefix` was ignored, a named instance could mount after `authzPerRoute` and leave its rules inert, and a second authentication composed as an AND. It registers with the authentication dispatcher now — [notes](docs/usage/upgrading.md#openid-connect-honours-auth_path_prefix) - `core/auth/llng`: a request a `skip` rule let through was published as authenticated by nobody, which every authorization plugin skips. It answers 401 now, and an identity header sent by the client is dropped ([#190](https://github.com/linagora/ldap-rest/issues/190), [notes](docs/usage/upgrading.md#coreauthllng-refuses-a-request-the-handler-named-nobody-for)) - `core/ldap/organizations`: `/subnodes/search` searched without the request, so no authorization plugin checked it — the gap `abstract/ldapFlat` closed in 0.8.2 — [notes](docs/usage/upgrading.md#subnodessearch-is-authorized-and-lists-the-children-by-page) - `lib/authz/base`: moving an entry out of an organization the caller could not read went through, the refusal being swallowed by its own `catch` - `bin`: a named instance of a priority plugin — `core/auth/trustedProxy:tp2:{…}` — lost its rank and could register after the routes it guards, letting a forged `X-Forwarded-For` reach what `rateLimit` and `crowdsec` key on ### Features - `--authz-identity`: authorization rules can be keyed on a login. Every authenticator publishes `req.userName` beside `req.user` — `--oidc-username-claim`, `--llng-username-header` — and says at startup what it publishes ([#187](https://github.com/linagora/ldap-rest/issues/187), [notes](docs/usage/plugins/auth/README.md#what-a-rule-is-keyed-on)) - `authz_for` scopes an authorization plugin to the authenticators whose requests it judges, recorded in `req.authenticators`; `--authz-scope-source` chooses which one `authzScope` answers with ([#189](https://github.com/linagora/ldap-rest/issues/189), [notes](docs/usage/plugins/auth/README.md#several-authorization-plugins)) - `core/storage`: keyed storage with a deadline, in an LDAP branch or in a directory of files — [notes](docs/usage/plugins/utilities/storage.md) - Back-Channel Logout: `core/bcl` ends the session here when the provider ends it — [notes](docs/usage/plugins/auth/back-channel-logout.md) - `--authz-filter-attached-entries`: an account is judged by the organization it is attached to rather than by the `ou=users` every account shares. Off by default — [notes](docs/usage/upgrading.md#judging-an-account-by-what-it-is-attached-to) - `lib/ldapActions`: `ldap.system` for the reads that are nobody's request — a uniqueness or referential-integrity check ([#190](https://github.com/linagora/ldap-rest/issues/190)) ### Bug Fixes - A refused LDAP operation names the authorization plugin that refused it in the log, and each plugin says at startup what it judges ([#189](https://github.com/linagora/ldap-rest/issues/189)) - `lib/auth/base`: the `afterAuth` hooks never ran. They run on every authenticated request now - `core/ldap/organizations`: a node with more children than the directory lists in one answer returned `200 []` from `/subnodes`. It returns what the directory gives and a row saying there is more, and its children are searched by page ([#179](https://github.com/linagora/ldap-rest/issues/179), [notes](docs/usage/plugins/ldap/organizations.md#get-organization-subnodes)) - `lib/utils`: a hook failing under `launchHooks` threw from its own error report, the logger having been captured before `setLogger` — an unhandled rejection, or a refused OpenID Connect login. It is reported now, with the plugin and hook it belongs to ([#182](https://github.com/linagora/ldap-rest/issues/182)) - `lib/expressFormatedResponses`: `serverError` reached before `setLogger` threw instead of sending its response ([#199](https://github.com/linagora/ldap-rest/issues/199)) ## v0.8.2 (2026-09-23) ### Security - `abstract/ldapFlat`: reading, listing, modifying and deleting a flat entity ran with no authorization check. The request never reached the directory call, and every plugin skips its check without one, so any authenticated caller reached every account whatever branch they were granted. `renameEntry` was fixed for this in 0.8.0, the four others were left ## v0.8.1 (2026-09-22) ### Bug Fixes - `plugins/ldap/groups`: a branch under the group base — an `ou=` holding some of the lists — was answered as a group of its own. The listing searches the whole subtree and asks for the main attribute; an entry without one is answered with an empty array, which the guard meant to skip a nameless entry read as a name, so the branch reached the client keyed on `""`. A console listing groups showed a row with no name, and an empty option wherever it let one be picked ## v0.8.0 (2026-09-22) An enterprise directory manager: the rules a real deployment needs, the endpoints an administration console reads, and a script that says what a migration will refuse before it does. The console itself is [Twake Directory Manager](https://github.com/linagora/twake-directory-manager). **Read [Upgrading](docs/usage/upgrading.md) before deploying this one.** Every breaking change below is explained there, with what to do about it. ### Breaking Changes - Node 20 is the floor, the version Debian 13 ships — [notes](docs/usage/upgrading.md#node-20-is-the-floor) - `static/schemas/twake` describe an enterprise directory rather than a bare CRUD surface: `uid`, `twakeDepartmentPath`, `twakeAccountStatus` and `twakeDeliveryMode` are computed and refused in a request body, and the schemas need `core/ldap/enterpriseRules` loaded — [notes](docs/usage/upgrading.md#the-twake-schemas-now-need-coreldapenterpriserules), [notes](docs/usage/upgrading.md#clients-must-stop-sending-the-computed-attributes) - `static/schemas/twake/users.json` requires `employeeNumber`, `displayName` and `givenName`, `cn` stops being required, and `employeeNumber` is unique apart from the `UNIT` placeholder — [notes](docs/usage/upgrading.md#employeenumber-is-unique-apart-from-the-unit-placeholder) - An array's `items.test` and `items.branch` are enforced on the flat, organization and group routes, where they never were. Stored values are untouched — [notes](docs/usage/upgrading.md#run-the-audit-before-switching) - `twakeDepartmentPath` reads from the root down; what a directory of the old convention holds is still accepted — [notes](docs/usage/upgrading.md#organization-paths-nothing-to-convert) - A flat schema claiming a name another schema or an LDAP plugin already serves is refused instead of loading unreachable — [notes](docs/usage/upgrading.md#a-flat-schema-may-not-claim-a-url-an-ldap-plugin-serves) - `--ldap-cache-ttl` defaults to `0`: the search cache is off until asked for — [notes](docs/usage/upgrading.md#the-search-cache-works-now-and-is-off-by-default) - `core/auth/llng` needs a configuration it can use at startup, and stops the server without one — [notes](docs/usage/upgrading.md#the-llng-handler-now-needs-a-working-configuration-at-startup) ### Security - Bump `csv-parse` to 7.0.2 (GHSA-8cw4-87c7-c6xx): a `__proto__` header would have been copied onto the entry `plugins/ldap/bulkImport` was building, and replaced its prototype wherever that cell held several values — assigning a string to `__proto__` is a silent no-op, assigning the array a cell holding a `;` becomes is not ### Features - `plugins/ldap/enterpriseRules`, `plugins/ldap/accountLifecycle` and `plugins/auth/authzScope`: the rules a directory needs, the status and password endpoints, and the answer to which branches a caller administers. None holds an attribute name, a domain or a nomenclature value — every rule is a schema marker on `abstract/ldapFlat` (`role`, `hint`, `generated`, `readOnly`, `neverReturn`, `generatedFrom`, `searchable`), see [flat-generic](docs/usage/plugins/ldap/flat-generic.md) - `POST /v1/ldap/{resource}/{id}/rename` changes an identifier and rewrites what named its DN before it answers, `207` when a rewrite did not land — [notes](docs/usage/upgrading.md#renaming-an-entry-and-what-the-cascade-reaches) - `GET /ldap/{entity}?match=…&attribute=a,b,c` answers on any of several attributes, `searchable` saying which are worth scanning - `npm run audit:directory` reports what a schema would refuse of what is already stored, quoting each rule's own `hint`, see [directory-audit](docs/usage/directory-audit.md) - `static/schemas`: the missing `domains` nomenclature, English and French labels on every Twake nomenclature and on its values (`entity.valueLabels`), and `static/schemas/example`, a worked configuration keeping national formats, mail domains and quota defaults out of the code ### Bug Fixes - `plugins/twake/calendar`: only the creation hook checked that an entry was in the resource branch — a modification or a deletion reached Calendar for any entry of the entity — and the branch was matched as text, so a partial `--calendar-resource-base` took a sibling branch with it and a DN written with spaces was not recognised at all. The resource identifier came from an unanchored search for `cn=`/`uid=`, so an entry whose own RDN was neither borrowed an ancestor's; it is read from the entry's own RDN now, unescaped, and the three hooks share it. It is percent-encoded in the WebAdmin path, see [Upgrading](docs/usage/upgrading.md#calendar-resource-ids-and-the-branch-they-are-looked-for-in) - `lib/ldapActions`: a cached entry was handed out with its value arrays shared, so a caller sorting one or appending to it wrote into the cache and every later reader saw it until the TTL ran out. They are copied - `abstract/ldapFlat`: `renameEntry` never handed the request on, so a rename through it ran with no authorization check at all, and accepted identifiers the schema refuses on creation. The route is new in this release, so no deployment was exposed - Creating an entry that already exists answered `500` on some routes and `409` on others; it is recognised once, in `lib/ldapActions`, and answers `409` everywhere — [notes](docs/usage/upgrading.md#creating-an-entry-that-already-exists-answers-409) - `lib/ldapActions`: the base-scope search cache never stored a result, and `rename` and `move` dropped nothing when they changed one — [notes](docs/usage/upgrading.md#the-search-cache-works-now-and-is-off-by-default) - `plugins/auth/llng` never initialized the LemonLDAP::NG handler, so every request answered `500`, and two instances given different `--llng-ini` silently shared one configuration - `plugins/ldap/groups`: the placeholder member is hidden when a group is read as well as listed, and recognised by its DN wherever it is read — the delete guard, SCIM and `plugins/twake/james` all compared text. Refusing to remove it answers `400`, and a listing that did not ask for `member` no longer answers `"member": [null]` - `abstract/ldapFlat`: a pointer's `branch` was matched as a text suffix; `searchEntriesByName` interpolated its value into an LDAP filter raw; and a business rule that refused a creation answered `500` - `plugins/ldap/organizations`: `ou` rejected every name carrying a space, an apostrophe or an `&` — which is most of a real directory - `plugins/ldap/departmentSync`: moving or renaming an organization left its subtree, and the users and groups linked to it, on the former parent's path ### Improvements - `plugins/twake/calendar` looks a registered user up by email instead of downloading every one. Releases before 1.0.0.1 ignore the parameter and keep working - `plugins/ldap/flatGeneric`: `entity.valueLabels` reaches a client at the top level of its `flatResources` entry, beside `label` and `singularLabel` - `generatedFrom.regenerateOnChange` is gone — declared, documented, and read nowhere. The rename endpoint is what changes an identifier ### Dependencies - `csv-parse` 7 trims ECMAScript whitespace: cells padded with a non-breaking space are now trimmed, and a BOM before the first header no longer ends up in the column name ### Deprecations - `plugins/twake/calendarResources` is renamed `plugins/twake/calendar`, see [Upgrading](docs/usage/upgrading.md#calendarresources-is-renamed-calendar) ## v0.7.0 (2026-09-02) See [Upgrading](docs/usage/upgrading.md) before deploying this one. ### Breaking Changes - `plugins/scim`: a Group's `externalId` no longer answers its `entryUUID`. RFC 7643 section 3.1 makes it the _provisioning client's_ identifier, so serving a server-assigned value discarded the id Okta or Entra ID sent, and `filter=externalId eq ""` searched `entryUUID` and matched nothing. Name an attribute with `--scim-group-external-id-attribute` to store it; left unset, `externalId` is not supported on Groups. Users are unaffected — theirs was already in `employeeNumber` ### Features - `plugins/scim`: SCIM `active` is writable — how Okta and Entra ID disable a user. It was read-only, so a deactivation was silently dropped on create and answered `400 invalidPath` on PATCH. It is modelled on the presence of one LDAP attribute, `--scim-user-lock-attribute` (default `pwdAccountLockedTime`, which needs the ppolicy overlay) written with `--scim-user-lock-value` - `plugins/scim`: `attributes` and `excludedAttributes` (RFC 7644 section 3.9) are honoured instead of parsed and dropped — on the lists, the single-resource GETs and the answers of POST, PUT and PATCH - `lib/ldapActions`: `forRequest(req)` binds every directory operation to one request, so the authorization hooks always see it. The unbound methods take the request as a trailing optional argument and omitting it skips every check silently — four such bugs shipped. Request handlers should bind once; an eslint rule enforces it across the SCIM plugin ### Bug Fixes - `plugins/scim`: four ways a deactivation could answer `200` and leave the account binding — the string `"false"` unread by POST and PUT, a `PUT` omitting `active` releasing the lock anyway (so a routine profile sync defeated a ppolicy auto-lockout), a `PUT` swallowing the directory's refusal, and lock configurations that could never lock, now refused at startup - `plugins/scim`: PATCH operations play in the order RFC 7644 section 3.5.2 gives them, against the entry as it stands, and only the difference is sent. Two operations on one attribute no longer collapse into whichever the emitter kept, and an add stays an incremental LDAP `add` rather than a computed `replace` that would drop a concurrent one - `plugins/scim`: `remove` on a Group's `members` naming members that all fail to resolve — an identity provider withdrawing someone the directory no longer holds — was read as the bare `remove members` and emptied the group, answering `200`. Only the bare form means all - `plugins/scim`: removing an attribute the entry does not hold no longer fails the whole atomic modify, taking the operations sent alongside it down with it - `plugins/scim`: `/Bulk` answered `500` quoting the raw error, including the `[authz-forbidden]` marker and the branch DN behind it. It applies the same translation as every other route now - `plugins/scim`: a directory whose schema does not define the lock attribute answered a bare `500` to any deactivation; it is `400 invalidValue` naming the flags to change - `lib/ldapActions`: the wrappers around `add`, `modify`, `rename`, `move` and `delete` dropped the driver's numeric result code, leaving callers to tell noSuchObject from a schema refusal by matching its wording - `lib/ldapActions`: a `modify` that emitted none of the changes it was given is a `warn` naming the DN, no longer a `debug` line shared with the routine empty case ## v0.6.2 (2026-09-02) ### Security - `plugins/ldap/onChange`, `plugins/ldap/organizations`: both rebuilt the hook tuple without the request. `launchHooksChained` feeds each hook's return value to the next, so an authorization plugin registered after either of them saw no request — and `shouldSkipAuthorization()` returns true the moment it is missing. Every `modify`, and every `rename` for the second plugin, went through unchecked, whatever `core/auth/authzPerBranch` or `core/auth/authzLinid1` said. `core/auth/authzDynamic` was not affected: it reads its token from an `AsyncLocalStorage` rather than from the request. Neither plugin sits in `priority.json`, and the remaining plugins load concurrently, so which side of the line a deployment fell on was decided by a load race and could differ from one restart to the next. Present since at least v0.4.7. Note for upgrades: writes that this bypass was letting through are refused now. An identity whose grants were never quite right may start seeing `403` where it saw `200` — that is the fix, not a regression, but check your `authzPerBranch` or `authzLinid1` grants before rolling this out widely ### Bug Fixes - `plugins/scim`: `active` was answered as `true` for every user, locked ones included. It is read from the presence of `pwdAccountLockedTime`, which is operational and no search named it, so a disabled account was reported to the provisioning system as enabled - `plugins/scim`: a `PATCH` whose operations all translated to no LDAP change answered `200` without reaching `ldapActions`, where write permission is checked, so an identity with read and no write was told its write had succeeded. Nothing was written either way — the answer was the problem, and a provisioning system records it as applied - `plugins/scim`: a filter on `active pr` emitted `(active=*)`. `active` is not an LDAP attribute, so the directory refused the search with `attribute type undefined` - `plugins/ldap/onChange`: the entry snapshot taken before each modify was only ever cleared when the operation was unknown, so the map grew by one entry per modify for the lifetime of the process ## v0.6.1 (2026-09-02) ### Breaking Changes - `plugins/scim`: reads are now authorized — a breaking change shipped in a patch release because it closes a read-authorization bypass. Every `ldapActions.search()` the plugin issued omitted the request, so `ldapsearchrequest` — the hook the authorization plugins use — skipped its check, and any authenticated identity could read any branch the plugin was pointed at whatever `core/auth/authzPerBranch` or `core/auth/authzLinid1` said. `core/auth/authzDynamic` was not affected: it reads its token from an `AsyncLocalStorage` rather than from the request. `GET /Users`, `GET /Users/{id}` and their Group counterparts now answer `403` where read is denied. The bypass is not new — it predates 0.6.0 — but the pagination fix in this same release removes what used to limit it. A list previously refused with `tooMany` past `--scim-max-results`, so an unauthorized read of a subtree larger than 200 entries returned nothing; it now pages through the whole subtree. Fixing the two together is the point. Note for upgrades: an identity that writes needs `read` on the same branch. A SCIM write answers with the resource it just changed, so it reads the entry back, and a grant of `write` without `read` no longer serves a write ### Security - `plugins/scim`: `PATCH /scim/v2/Users/{id}` called `ldapActions.modify()` without the request, so every authorization plugin skipped its check — a token denied write on a branch could still modify a user there through PATCH. POST, PUT and DELETE, and the Groups PATCH, were unaffected - dependencies update ### Bug Fixes - `plugins/scim`: `meta.created` and `meta.lastModified` carried the directory's GeneralizedTime (`20250101120000Z`) straight through. RFC 7643 section 2.3.5 wants an `xsd:dateTime`, so a strict client rejected every resource. They are now converted (`2025-01-01T12:00:00Z`) - `plugins/scim`: a create answered 201 without the `Location` response header RFC 7644 section 3.1 requires — only `meta.location` inside the body. It is now sent on POST, and on the PUT and PATCH answers as well - `plugins/scim`: a PATCH `remove` operation without a `path` was applied to every key of its `value` instead of being refused. RFC 7644 section 3.5.2.2 requires `400` with `scimType: noTarget`, since a pathless remove names no target - `plugins/scim`: a list could not be served at all beyond `--scim-max-results`. `GET /Users` and `GET /Groups` fetched their whole result set and answered `400 tooMany` as soon as it passed that figure — so a directory of more than 200 entries was unlistable, filter or no filter, and no `startIndex` reached past that window. RFC 7644 section 3.4.2.4 asks for a page. The window is now cut server-side while walking a paged search: `startIndex` reaches any offset, `totalResults` is the real size of the result set, and only the requested page is held in memory Two notes on configuration. `--scim-max-results` keeps its name but now means the maximum page size — the cap on `count`, which is what `ServiceProviderConfig.filter.maxResults` advertises. And the new `--scim-max-scanned` (10000) bounds how far a list walks to count its result set, past which `tooMany` is answered as RFC 7644 section 3.12 provides for ## v0.6.0 (2026-08-01) ### Breaking Changes - `plugins/twake/appAccounts*`: applicative entries are built from an allowlist (`--applicative-account-attribute`) instead of the whole user entry minus `--ldap-operational-attribute`. An attribute not named there is no longer copied into the applicative branch — name it to keep it (#103) - `plugins/twake/appAccountsConsistency`: a mail change now deletes the app accounts instead of recreating them without their password. They could not authenticate, yet were still listed and still counted against `--max-app-accounts` (#103) ### Security - `lib/utils`: a DN read from a JSON body was parsed by looping on its `.length`, so `{"length": 1e100}` hung the worker (CWE-834). `parseDn()` and `unescapeDnValue()` now answer 400 on a non-string - `plugins/ldap/groups`: `member`, `targetOrgDn` and `newCn` were forwarded without a type check, unlike the equivalent organization and flat-branch endpoints; a non-string now gets a 400 - `lsc-plugin`: jackson-databind 2.17.2 → 2.22.1, clearing five advisories (CVE-2026-54512, CVE-2026-54513, CVE-2026-54514, CVE-2026-54515, CVE-2026-59888) ## v0.5.0 (2026-08-01) ### Features - `plugins/ldap/raw` + `browser/ldap-browser`: read-only low-level browsing of the directory — root DSE, parsed schema (RFC 4512, `SUP` chains resolved), any entry by DN with its operational attributes, the children of a node, and arbitrary searches. Access is bounded by `--ldap-raw-base`, narrowed by the authorization plugins, and credential attributes (`userPassword` and its Samba, Kerberos and AD counterparts) are stripped unless `--ldap-raw-show-secrets` says otherwise. The browser library ships a tree, a schema-annotated attribute table and a filter search, demo page at `/static/examples/web/ldap-browser.html` - `lib/auth`: `--auth-path-prefix` scopes an authentication plugin to path prefixes, so one server can serve populations that authenticate differently — `/api/m` behind a token, `/api/admin` behind OIDC. A credential is only valid on the branch it was scoped to, and a plugin left unscoped guards everything no other plugin claims. Authentication is dispatched from a single layer the server mounts, so declaration order no longer decides what is protected and the most specific claim wins. A route outside every prefix with no unscoped plugin is served without authentication: those routes are named in a warning at startup. Plugins extending `AuthBase` no longer mount a middleware of their own: `api()` registers them with the dispatcher, so a third-party plugin that called `super.api(app)` keeps working, but one that relied on where its layer sat in the stack no longer can ### Bug fixes - `bin`: a plugin loaded with overrides (`module:name:{json}`) received a server view built by spreading, so it carried the data but none of the methods and any `this.server.something()` threw - `bin`: the error middleware was never replaced, only stacked — the lookup used Express 4's `app._router`, which Express 5 renamed - `bin`: a plugin registered under a name already taken was dropped at `info` level, so its routes answered 404 with nothing in the log to explain it; now a warning naming the plugin and how to load one twice - `plugins/auth/rateLimit`: warn when `core/auth/trustedProxy` is absent. The limiter keys on `X-Forwarded-For`, which is forgeable without it, and the brute-force protection is then decorative - `plugins/twake/appAccountsApi`: never report a password operation that did not happen. `POST` used to return `200` with a password that authenticated nowhere when the principal update failed, and `DELETE` removed an app account while leaving its credential valid. Both now return `500`; `DELETE` keeps the account so the call stays replayable, and a password already absent counts as revoked (#105) ### Internal - Tooling and tests, no runtime change: coverage is measured and gated in CI (`npm run coverage`, `npm run coverage:check` — 86.6% of statements on server code), the fourteen ESLint errors are cleared and CI now runs lint and format checks, and tests wait on the condition they expect instead of a fixed delay ## v0.4.7 (2026-07-26) ### Features - `plugins/weblogs`: let any plugin enrich the access log line. The entry said _that_ a request happened, never _what_ was requested, so on `POST` / `DELETE` routes carrying their parameters in the body the URL alone was useless for auditing. A handler can now set `req.logDetails = { … }` and those fields are merged into the single `notice` entry emitted when the response completes — no second log line to correlate by timestamp. Core fields (`method`, `url`, `status`, `duration`, `ip`, `user`, `error`) always take precedence, so a plugin cannot spoof them (#104) ## v0.4.6 (2026-07-17) ### Features - docker images amd64 + arm64 - CI: automated release pipeline triggered on `vX.Y.Z` tags. Each tag now builds and pushes multi-arch (amd64 + arm64) Docker images to `ghcr.io/linagora/ldap-rest` and `docker.io/yadd/ldap-rest`, publishes the Helm chart as an OCI artifact to `oci://ghcr.io/linagora/charts/ldap-rest`, publishes the npm package (via npm OIDC Trusted Publishing, with a provenance attestation), and cuts a matching GitHub Release. Prerelease tags (`vX.Y.Z-rc.N`) are published to the npm `next` dist-tag (#101, #102) - `helm/ldap-rest`: Helm chart for deploying ldap-rest on Kubernetes. Derived from the in-house deployment chart (same `env` / `secrets` / `externalFileConfig` values interface) with TCP probes, optional ingress and service account, and standard Helm labels (#101) ## v0.4.5 (2026-07-16) ### Features - `bin/sync-james`: reconcile mail aliases in addition to quotas, as a catch-up for aliases that were not propagated to James (e.g. when the event-based sync failed). ## v0.4.4 (2026-07-10) ### Features - `plugins/twake/calendarResources`: propagate LDAP user identity changes (email, first name, last name) to the Twake Calendar registered users via the WebAdmin API. Registered users are keyed by an internal id and `GET /registeredUsers` exposes no filter, so the plugin lists the registered users, locates the entry by email, then `PATCH /registeredUsers?id={id}` with the LDAP values. The sync is driven by the configured mail / first name / last name attributes — `--calendar-firstname-attribute` (default `givenName`) and `--calendar-lastname-attribute` (default `sn`) (#100) ### Bug Fixes - `plugins/twake/james`: use the standard Apache James / Twake-Mail WebAdmin routes. The forwards and JMAP identities calls targeted routes that do not exist and silently returned 404: forwards now use `/address/forwards/{mail}/targets/{forward}` (was `/domains/{domain}/forwards/…`) and identities `/users/{mail}/identities` (was `/jmap/identities/{mail}`). The mailbox rename call also gains the required `force` query parameter and is now skipped when the new mail is empty instead of renaming the mailbox to a literal `null` address (#99) - `plugins/scim`: force usernames to lowercase on SCIM user creation (#96) ## v0.4.3 (2026-07-08) ### Bug Fixes - `plugins/twake/clouderyProvision`: skip instance creation when an instance already exists for the computed FQDN. The instance slug is deterministic, so when a user is re-imported after their LDAP entry was recreated while the Cloudery instance survived, the existing instance is now reused instead of letting Cloudery mint a numbered duplicate (`slug2`). The existence lookup fails open: if it errors, provisioning falls through to create as before (#94) ### Misc - Dockerfile: add the missing `DM_CLOUDERY_INVITED_ATTRIBUTE="twakeInvited"` environment default, backing the invited-attribute feature introduced in v0.4.1 ## v0.4.2 (2026-07-06) ### Bug Fixes - `plugins/twake/clouderyProvision`: force `cn` to the `userName` when provisioning B2B users. The core SCIM mapping sets `cn` from `name.formatted`, which is not the desired value for B2B provisioning (#93) ### Misc - `plugins/twake/clouderyProvision`: added detailed provisioning logs ## v0.4.1 (2026-06-30) ### Features - `plugins/twake/clouderyProvision`: provisioned B2B users are now marked as pending invitation. On provisioning, the configurable invited attribute (`twakeInvited` by default, set via `cloudery_invited_attribute` / `DM_CLOUDERY_INVITED_ATTRIBUTE`) is written as `"TRUE"` on the user entry; the registration app clears it to `"FALSE"` once onboarding completes (#90) ## v0.4.0 (2026-06-19) ### Breaking Changes - `core/twake/appAccountsApi`: the `:user` path param of the app-account endpoints is now resolved against the **mail** attribute (globally unique) by default, instead of the LDAP `uid`. `uid` is not unique across the directory, so the previous lookup could create/list/delete app accounts against the wrong same-named user (#88). Callers must now pass the principal email as `:user`. Set `app_accounts_user_attribute=uid` (`DM_APP_ACCOUNTS_USER_ATTRIBUTE=uid`) to restore the previous `:user = uid` contract — only safe where uid is unique directory-wide. Generated app-account uids are now prefixed from the (sanitized) resolved `:user` value: `_c` by default, still `_c` in uid mode - Authorization denials from `core/auth/authzPerBranch` now return **403** for every operation (read, write, move, delete); previously read and move denials surfaced as `500`. This aligns it with `core/auth/authzDynamic` ### Bug Fixes - SCIM writes now honour `core/auth/authzPerBranch` (#80). `core/scim` did not propagate the authenticated request down to the LDAP action layer, so the `ldap{add,modify,delete}request` authorization hooks ran without `req.user` and `shouldSkipAuthorization` allowed the write unconditionally — an identity restricted to one branch could create or delete entries in any branch via SCIM. The request is now threaded through every SCIM `ldap.add/modify/delete`. `ldap.delete` also gained a `req` argument and `AuthzBase` now implements a `ldapdeleterequest` hook (it enforced no delete permission before). The `authzDynamic` path was unaffected (it reads its token from AsyncLocalStorage) - `core/twake/appAccountsApi`: escape LDAP filter metacharacters in principal and uid lookups, reject ambiguous principal lookups with `409` instead of silently using the first match, and guarantee a generated app-account uid is unique across the whole applicative branch (prevents cross-user collisions in the shared branch) ## v0.3.10 (2026-06-19) ### Bug Fixes - `core/twake/appAccountsApi`: drop the unused `core/auth/token` dependency (#83). It auto-loaded the token-auth plugin and registered its global middleware, forcing Bearer auth on the app-accounts endpoints and returning `401` under HMAC-only deployments. The plugin never reads `req.user` or the token, so the dependency enabled nothing; the endpoints now use the deployment's configured authentication like every other API plugin - `core/twake/appAccountsConsistency`: harden the re-entrancy guard so that deleting a single app account no longer cascades into deleting the user's other app accounts and principal entry (#84). The guard compared the configured `applicative_account_base` as a plain string suffix, which false-negatived on DN-format differences (case, whitespace around separators, escaped commas, multi-valued RDN ordering) returned by the server, letting the plugin's own delete event slip through and trigger the delete-by-mail cascade. It now relies on new `normalizeDn` / `isDnInBranch` helpers in `lib/utils` for a robust RDN-by-RDN comparison (`normalizeDn` avoids a ReDoS-prone regex flagged by CodeQL). The previously load-time-skipped `appAccounts*` test suites now actually run in CI, plus a regression test covering the single-delete case - `sync-app-accounts`: fix the bulk backfill CLI, which previously could not create any principal account and never returned control. A base-scoped search on a missing entry raises `noSuchObject` instead of returning an empty set, so it is now treated as "absent" and the missing principal is created. Attributes that come back as empty arrays (a requested-but-absent attribute) are skipped to avoid `add` errors (`no values for attribute type`). The script now fails fast with a clear message when `applicative_account_base` does not exist, drops a broken `unbind()` teardown call, and exits cleanly once finished (pooled LDAP connections were keeping the process alive after the summary) ## v0.3.9 (2026-06-18) ### Bug Fixes - `core/twake/appAccountsConsistency`: ignore mail-change events whose DN originates in the applicative branch (`applicative_account_base`). Those entries are outputs of the plugin, never source users, so reacting to the plugin's own writes caused idempotent `AlreadyExists` churn and, during a mail change, a re-entrant deletion cascade that could drop a user's app accounts. This makes it safe to nest `applicative_account_base` under `ldap_base` ## v0.3.8 (2026-06-18) ### Improvements - `core/twake/clouderyProvision`: provisioned users now carry their organization role and phone numbers. The role is read from a request header (`--cloudery-org-role-header`, `DM_CLOUDERY_ORG_ROLE_HEADER`, default `x-cloudery-org-role`), falling back to `--cloudery-default-org-role` (`DM_CLOUDERY_DEFAULT_ORG_ROLE`, default `member`), and is written back to the LDAP entry under `--cloudery-org-role-attribute` (`DM_CLOUDERY_ORG_ROLE_ATTRIBUTE`, default `twakeOrganizationRole`). Phone numbers are taken from `--cloudery-phones-attribute` (`DM_CLOUDERY_PHONES_ATTRIBUTE`, default `twakePhones`) and sent during provisioning ## v0.3.7 (2026-06-17) ### New Features - `core/twake/cozyProvision` and `core/twake/clouderyProvision`: the RabbitMQ routing keys for the user-created and user-deleted events are now configurable via `--cozy-user-created-routing-key` (`DM_COZY_USER_CREATED_ROUTING_KEY`) and `--cozy-user-deleted-routing-key` (`DM_COZY_USER_DELETED_ROUTING_KEY`), defaulting to `user.created` and `domain.user.deleted` ## v0.3.6 (2026-06-17) ### New Features - New plugin `core/twake/clouderyProvision`: hooks the SCIM lifecycle to provision a Cloudery instance on user create and tear it down on delete. It writes the returned workspace FQDN and organization id back onto the LDAP entry, and publishes the `user.created` and `domain.user.deleted` events. Provisioning is gated on workflow success, and the deletion event is only emitted once the instance is actually destroyed - `core/scim/baseResolver`: support resolving the SCIM insertion base from a request header, gated to a configured root and never overriding an explicit per-user map entry, so one shared auth token can serve every organization - New shared `rabbitmq` plugin, extracted from `cozyProvision`, so any plugin can publish lifecycle events on a common connection - New `lsc-plugin`: an LSC destination plugin (Java) that routes sync writes through ldap-rest's HTTP API instead of binding LDAP directly, so they benefit from ACL, schema validation, audit, and the downstream provisioning hooks. Supports Bearer and HMAC-SHA256 auth and maps the CREATE/UPDATE/DELETE/MODRDN operations onto the matching endpoints ### Build - `rollup`: resolve external builtins and dependency subpaths ### Dependencies - Update dependencies ## v0.3.5 (2026-05-18) ### Bug Fixes - `core/twake/appAccountsConsistency`: rename the config key `ldap_operational_attributes` to `ldap_operational_attribute`, matching the documented CLI/env option `--ldap-operational-attribute`, so the configured operational-attribute list is actually applied. Also strip `dn` unconditionally from entries before `ldap.add`, preventing `LDAP add error: UndefinedTypeError: dn` failures when the operational attribute list is misconfigured - `core/twake/cozyProvision`: destroy the Cozy instance on SCIM delete via `DELETE /instances/` on the Cozy admin API before publishing the `b2b` / `domain.user.deleted` event. A 404 is treated as success so the lifecycle stays idempotent, and the b2b event is emitted even when the destroy fails so peer instances still drop their contact cards. Avoids leftover instances silently re-attaching on re-import - `core/twake/cozyProvision`: set `OIDCID` on `POST /instances` to the SCIM `userName`, so the OIDC callback no longer fails with `Invalid sub: != ""` for SCIM-provisioned users ### Build - Docker image now uses `node:24-alpine` instead of `node:22-alpine` ### Dependencies - Update `express-rate-limit` to 8.5.2, `fast-xml-builder` and other transitive deps ## v0.3.4 (2026-05-05) ### Bug Fixes - `core/twake/cozyProvision`: a series of fixes so SCIM-provisioned users land on a usable Cozy instance ## v0.3.3 (2026-05-05) ### Bug Fixes - `core/scim`: pass hook payloads as spread args to `launchHooks` instead of wrapping them in an array. SCIM `*done` hooks - `core/twake/cozyProvision`: rename the user identifier in the `auth/user.created` message body from `sub` to `twakeId` ## v0.3.2 (2026-05-04) ### New Features - New plugin `core/twake/cozyProvision`: hooks the SCIM lifecycle to provision a Cozy instance after user creation and to publish `auth` / `user.created` and `b2b` / `domain.user.deleted` events on RabbitMQ. - New plugin `core/auth/authzPerRoute`: restricts requests by HTTP method and path glob based on `req.user` ### Tests - Widen TTL margins in `cache-manager` tests to deflake CI ## v0.3.1 (2026-04-29) ### New Features - OpenAPI generator now parses `@openapi` and `@openapi-component` YAML directives in route JSDoc, so plugins are self-documenting (summary, description, parameters, requestBody, responses, security, tags, reusable component schemas via `$ref`) - it skips routes that have no `@openapi` block and logs a `Skipping undocumented route` warning, so the published reference reflects intentionally-documented endpoints only - it now recognises `TwakePlugin` and `AuthzBase` descendants and walks `src/abstract/`, covering the James plugin and the generic `LdapFlat` CRUD surface (with a `{resource}` path placeholder) - Annotate every API-exposing plugin with OpenAPI metadata: SCIM 2.0 (Users, Groups, Bulk, Discovery), `ldapOrganizations`, `ldapGroups`, `ldapPasswordPolicy`, `ldapBulkImport`, `twake/appAccountsApi`, `twake/james`, `static`, `configApi`, `hello/helloworld`, `authzDynamic`, plus the abstract `LdapFlat` routes — 51 operations across 10 tags, backed by 30 component schemas ### Documentation - Add `docs/plugin-development/openapi.md` guide explaining the generator contract and the YAML directives - Fix broken link to `hooks.md` in README ### Bug Fixes - Rename `core/ldap/organization` plugin source file to `organizations.ts` so the documented plugin name `core/ldap/organizations` actually loads; keep the singular path as a deprecated alias that emits a one-time warning at module load (slated for removal at the next major release) - Word the plugin-path deprecation warning around the plugin path itself, not around the loading entry point (`DM_PLUGINS`) ### Security - Harden DN handling and LDAP filter escaping in the `ldapOrganizations` plugin: - `moveOrganization` now uses `getRdn()` / `isChildOf()` instead of `dn.split(',')[0]` and `endsWith()`, so escaped commas, multi-valued RDNs and attribute-name casing differences no longer bypass the descendant / same-location checks - Replace `topOrg.replace(/^ou=[^,]+,/, '')` with `getParentDn()` in both call sites, going through the existing DN parser - `escapeLdapFilter()` the request-controlled `dn` and `objectClass` query parameter in `getOrganisationSubnodes`, and the path segment in `checkDeptPath`, closing LDAP filter injection vectors - Throw `NotFoundError` / `BadRequestError` / `ConflictError` instead of plain `Error`, so HTTP responses carry meaningful 4xx codes (404 / 400 / 409) instead of a generic 500 - Stop double-wrapping caught LDAP errors that would otherwise lose their original status code - `ConfigApi.getTop` now points at `/v1/ldap/organizations/top` (the actual GET route) instead of the collection root that only accepts POST ## v0.3.0 (2026-04-25) ### New Features - Add `core/scim` plugin: SCIM 2.0 identity provisioning endpoint (`/scim/v2/Users` and `/scim/v2/Groups`), with per-tenant LDAP base resolution via `--scim-user-base-template` / `--scim-group-base-template` - Add `core/auth/authzDynamic` plugin: bearer-token authentication and per-branch authorization sourced from a dedicated LDAP branch, with in-memory cache (TTL + optional reload endpoint), constant-time password verification, and `AsyncLocalStorage`-scoped ACL enforcement on every downstream LDAP operation ### Security - Enforce base-DN scope in `LdapFlat` operations: full DNs must be a direct child of the configured base, blocking sibling-branch access via crafted DNs - Reject escaped-comma DN injection in `LdapFlat.resolveDn`: the parent DN check now uses parsed RDN components, so payloads like `cn=pwn\,ou=titles,ou=…` can no longer bypass a textual suffix check - Detect DNs by `mainAttribute=` prefix instead of looking for a comma, so RDN values that legally contain commas (e.g. `Smith, John`) are no longer misclassified as DNs - Address CodeQL and Copilot findings on the SCIM and authzDynamic plugins - Update dependencies ## v0.2.2 (2026-04-08) ### New Features - Update Twake-Drive plugin to be add a data deletion method ### Security - Update dependencies ## v0.2.1 (2026-03-02) ### Bug Fixes - Fix Twake Drive plugin authentication: use Basic Auth instead of Bearer token for Cozy Admin API compatibility ## v0.2.0 (2026-03-02) ### New Features - Add `twake/drive` plugin for Twake Drive (Cozy) integration: - Propagate email address changes to Twake Drive via Admin API - Propagate display name changes with fallback logic (displayName → cn → givenName+sn) - Propagate disk quota changes (`twakeDriveQuota` attribute) - Support domain template for flexible domain generation (e.g., `{uid}.company.cloud`) - Public methods: `blockInstance()`, `unblockInstance()`, `syncUserToCozy()`, `getCozyDomain()`, `getDisplayNameFromDN()`, `getMailFromDN()`, `getDriveQuotaFromDN()` - Add `onLdapDriveQuotaChange` hook for drive quota change detection - Add `--drive-quota-attribute` configuration option ### Security - Update dependencies - Add domain validation to prevent URL injection attacks in Twake Drive plugin - Add warning log when authentication token is not configured for Twake plugins ### Documentation - Add comprehensive documentation for Twake Drive plugin ## v0.1.9 (2026-02-23) ### Security - Add `escapeDnValue()` to all DN constructions to prevent LDAP injection attacks - Add `validateDnValue()` to reject control characters and invisible Unicode in DN values - Fix DN extraction regex to properly handle escaped commas ### New Features - Export `escapeDnValue`, `escapeLdapFilter`, and `validateDnValue` utilities for plugins ### Tests - Add comprehensive test suite for LDAP DN utilities (31 tests) ## v0.1.8 (2026-02-09) ### New Features - Add `deleteUserData` method to James plugin for GDPR data deletion - Add `deleteUserData` method to Calendar Resources plugin for GDPR data deletion ### Maintenance - Update dependencies - Fix lint errors and improve TypeScript typing ## v0.1.7 (2025-01-20) ### New Features - Add `passwordPolicy` plugin for OpenLDAP ppolicy administration ### Improvements - Add race condition protection to LDAP connection pool cleanup - Fix memory leak in Modal: use DisposableComponent for event cleanup - Add log before rejections - Add some standard schemas (automountMaps, devices, dhcpHosts, dnsRecords, netgroups, posixAccounts, posixGroups, sshPublicKeys, sudoRules) ### Maintenance - Update dependencies and require diff>=8.0.3 - Fix links in documentation - Improve TypeScript exports ## v0.1.6 (2025-12-01) - Export ldapActions types ## v0.1.5 (2025-12-01) - Improve documentation and exports ## v0.1.4 (2025-11-29) - Optimization & security - Improve tests ## v0.1.3 (2025-11-25) - Add plugin `core/auth/trustedProxy` - use `Auth-User` header when set - Improve error reporting with proper HTTP codes - User quota usage feature into James plugin ## v0.1.2 (2025-11-07) - Run Docker container as non-root user - Fix load order to keep logs - Fix dependencies ## v0.1.1 (2025-11-05) - Fix exports - Export all utils - Add SECURITY.md ## v0.1.0 (2025-11-03) - New plugins - Multiple LDAP URLs - Add robust error handling to prevent server crashes - Expose configuration via configApi - Add Docker Swarm example - Add log level "notice" - Add OBM schemas - Export abstract classes in package.json - Remove dead code from ldapActions and ldapFlat - Fix embedded LDAP server timing and reliability in tests ## v0.0.1 (2025-10-16) - **Initial release** linagora-ldap-rest-a4bb78e/CONTRIBUTING.md000066400000000000000000000203461527331160600201370ustar00rootroot00000000000000# Contributing to LDAP-Rest The rules on comments, documentation, changelog and commit messages are in [AGENTS.md](./AGENTS.md); they apply to humans too. ## Getting started Prerequisites: Node.js 22+, Docker (the test suite starts its own LDAP server). ```bash git clone https://github.com/linagora/ldap-rest.git cd ldap-rest npm install npm run build:dev ``` To run the server with every plugin loaded, point it to a directory first: ```bash export DM_LDAP_URL="ldap://localhost:389" export DM_LDAP_DN="cn=admin,dc=example,dc=com" export DM_LDAP_PWD="admin" export DM_LDAP_BASE="dc=example,dc=com" export DM_LDAP_TOP_ORGANIZATION="ou=organization,dc=example,dc=com" npm run start:dev ``` ## Where to read next - [Plugin development](./docs/plugin-development/README.md): architecture, plugins, [hooks](./docs/plugin-development/hooks.md), [testing](./docs/plugin-development/testing.md) - [Configuration](./docs/usage/configuration.md): options and environment variables - [Client development](./docs/client-development/README.md): REST API, browser libraries, schemas ## Tests and checks ```bash npm run test:dev # build, then the whole suite npm run test:one test/foo.test.ts # a subset npm run check # types, ESLint, Prettier, hooks, OpenAPI document, options snapshot, no client values npm run check:ts # types, with the TypeScript 6 compiler npm run check:ts7 # types, with TypeScript 7 npm run check:declarations # published declarations under nodenext, after a build npm run fix # ESLint and Prettier fixes ``` CI runs both type checks, and both are blocking: a change must type-check under the `typescript` 6 line that the build loads and under the `typescript7` npm alias of the stable TypeScript 7 release. The two packages both ship a `tsc` binary, so each script runs the package entry rather than `node_modules/.bin/tsc`. The two stay apart because `@typescript-eslint` refuses TypeScript 7 outright, and reads the `typescript` package it finds; the alias keeps the newer compiler in the type check without giving it to the linter. Without `DM_LDAP_*` variables, the tests run against an embedded LDAP server in Docker; set them as above to use yours. How the embedded server works: [testing](./docs/plugin-development/testing.md). ### Coverage CI runs `npm run coverage:check`: the suite, once, failing below the thresholds set in `package.json`. They sit a few points under the measured values to catch drift; when they fail, the fix is usually a missing test, not a lower threshold. Raise them when the real figure moves up. The gate excludes `src/browser`, which the suite cannot exercise without a DOM, and the type-only files listed in `.c8rc.json`. `npm run coverage` still reports everything in `coverage/index.html`. ### Dependencies Dependabot opens the updates (`.github/dependabot.yml`), grouped for npm. `npm run audit:prod`, run by CI, fails on a `high` or `critical` advisory in a production dependency, and on an entry of `.github/audit-allowlist.json` that is past its day or matches no advisory. An allowlist entry names a package or a GHSA id (`npm audit` gives no CVE), `until`, the last day (UTC) it applies, and `reason`. An advisory named by its GHSA covers the packages that pull the vulnerable one in as well. Fix the advisory; allow it only where it cannot apply here, and remove the entry once it is fixed. A pull request gets the same gate from GitHub's dependency review, configured in `.github/dependency-review-config.yml`. Its `allow-ghsas` list is the equivalent of `.github/audit-allowlist.json`: add a GHSA only with a comment saying why it cannot apply, and remove it once the dependency is patched. ### Package overrides `package.json` overrides raise the floor of a transitive dependency that no released parent resolves to a patched version. Each one names the advisory that keeps it there, and the path that pulls the package in: | Override | Advisory | Resolved through | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | | `diff >=8.0.3` | `GHSA-73rr-hh4g-fpgx` (low), denial of service in `parsePatch` and `applyPatch` | `mocha`, test only | | `glob >=11.1.0` | `GHSA-5j98-mcp5-4vw2` (high), command injection through the CLI | build and test tools | | `minimatch ^10.2.3` | `GHSA-3ppc-4f35-3m26`, `GHSA-7r86-cg39-jmmj` and `GHSA-23c5-xmqv-rm74` (high), regular-expression denial of service | build and test tools | | `postcss-selector-parser ^7.1.6` | `GHSA-rj75-hqrm-r3gf` (medium), quadratic selector parsing | `rollup-plugin-postcss`, build only | | `serialize-javascript >=7.0.3` | `GHSA-5c6j-r48x-rmvq` (high), remote code execution through `RegExp.flags` | `@rollup/plugin-terser` and `mocha`, build and test only | To check one: remove it, run `npm install`, then `npm ls ` and `npm audit`. Keep it out when every resolved version is outside the advisory's vulnerable range; otherwise put back the lowest floor that stays patched. An override no dependency resolves any more is stale: remove it. ## Submitting changes 1. Branch from `master`: `feat/` or `fix/`. 2. Add tests: a fix comes with a test that fails without it. 3. Commit following [Conventional Commits](https://www.conventionalcommits.org/): `fix(authz): …`, `docs(upgrading): …`. 4. Open a pull request on GitHub, filling the template: it records the reviews below. ## Deprecating a published contract A published contract is the configuration, the REST API or the plugin types listed in [API stability](./docs/README.md#api-stability). Removing or narrowing one is a breaking change: the pull request carries the `breaking` label and the release is a major one. Prefer deprecating first, so the users have a release to move: - **A renamed option** keeps its old CLI name and environment variable as an alias for two minor releases. The parser reads them as the canonical name and the server warns at startup; the alias leaves in a major release. An array option's plural form cannot be aliased, so renaming it is a breaking change. - **A route or response field** is marked `deprecated: true` in its OpenAPI declaration, and the `CHANGELOG.md` entry names the release that removes it. The removal waits for a major release. - **A default behaviour change** goes behind a new option first, defaulting to the old behaviour. The default flips in a major release. - **A plugin type** that moves or is renamed keeps its old export for one version, re-exported from the previous sub-path, and the removal waits for a major release. [Published contracts](./docs/development/contracts.md) describes how CI detects a break and how the snapshots are regenerated. ## Reviewing changes Every pull request gets two independent reviews from distinct AI assistants, in addition to the author's own reading. They look for correctness bugs, security issues, missing tests and drift from [AGENTS.md](./AGENTS.md). Their findings are discussed in the pull request, not applied silently. They do not replace an owner. An assistant has no stake in the code, does not arbitrate architectural decisions, and cannot take over a path when its maintainer leaves. A change to a path listed in `.github/CODEOWNERS` therefore still needs a human code owner's approval, from someone other than the author. The pull request records who performed which review. A merged change without that record is a gap: fill it in the next pull request, and prefer a visible review over an undocumented one. ## License By contributing to LDAP-Rest, you agree that your contributions will be licensed under the [AGPL-3.0 License](./LICENSE). linagora-ldap-rest-a4bb78e/Dockerfile000066400000000000000000000274771527331160600177140ustar00rootroot00000000000000## Generated by scripts/buildDockerfile.ts: edit it there FROM node:25-alpine@sha256:bdf2cca6fe3dabd014ea60163eca3f0f7015fbd5c7ee1b0e9ccb4ced6eb02ef4 AS builder WORKDIR /app-build COPY .dev.mk . COPY bin ./bin COPY package.json . COPY package-lock.json . COPY rollup.config.mjs . COPY tsconfig.json . COPY scripts ./scripts COPY src ./src COPY static ./static RUN npm ci && NODE_ENV=production node_modules/.bin/rollup -c && npm pack && mv *.tgz /tmp/app.tgz WORKDIR /app RUN npm install --no-package-lock /tmp/app.tgz && rm -f /tmp/app.tgz && npm cache clean --force FROM node:25-alpine@sha256:bdf2cca6fe3dabd014ea60163eca3f0f7015fbd5c7ee1b0e9ccb4ced6eb02ef4 AS runtime RUN apk upgrade --no-cache WORKDIR /app COPY --from=builder /app /app ENV NODE_ENV=production \ DM_PORT="8081" \ DM_SHUTDOWN_TIMEOUT="20000" \ DM_PLUGINS=core/static,core/demo/helloworld \ DM_LOG_LEVEL="notice" \ DM_LOGGER="console" \ DM_DEBUG=false \ DM_STRICT_OPTIONS=false \ DM_LOG_FILE="error.log" \ DM_API_PREFIX="/api" \ DM_MAIL_DOMAIN= \ DM_LDAP_BASE= \ DM_LDAP_DN= \ DM_LDAP_PWD= \ DM_LDAP_URL=ldap://localhost \ DM_LDAP_USER_ATTRIBUTE="uid" \ DM_LDAP_CACHE_MAX="1000" \ DM_LDAP_CACHE_TTL="0" \ DM_LDAP_POOL_SIZE="5" \ DM_LDAP_CONNECTION_TTL="60" \ DM_SCHEMAS_PATH="/app/node_modules/ldap-rest/static/schemas" \ DM_MAIL_ATTRIBUTE="mail" \ DM_QUOTA_ATTRIBUTE="mailQuotaSize" \ DM_DELEGATION_ATTRIBUTE="twakeDelegatedUsers" \ DM_ALIAS_ATTRIBUTE="mailAlternateAddress" \ DM_FORWARD_ATTRIBUTE="mailForwardingAddress" \ DM_DISPLAY_NAME_ATTRIBUTE="displayName" \ DM_DRIVE_QUOTA_ATTRIBUTE="twakeDriveQuota" \ DM_USER_CLASSES=top,twakeAccount,twakeWhitePages \ DM_LDAP_TOP_ORGANIZATION= \ DM_LDAP_ORGANIZATION_CLASSES=top,organizationalUnit,twakeDepartment \ DM_LDAP_ORGANIZATION_LINK_ATTRIBUTE="twakeDepartmentLink" \ DM_LDAP_ORGANIZATION_PATH_ATTRIBUTE="twakeDepartmentPath" \ DM_LDAP_ORGANIZATION_PATH_SEPARATOR=" / " \ DM_LDAP_ORGANIZATION_MAX_SUBNODES="50" \ DM_ORGANIZATION_SCHEMA= \ DM_ENTERPRISE_DOMAIN_NAME_ATTRIBUTE="associatedDomain" \ DM_ENTERPRISE_DOMAIN_LINK_ATTRIBUTE= \ DM_METRICS_PORT="9464" \ DM_STORAGE_BACKEND= \ DM_STORAGE_SWEEP_INTERVAL="600" \ DM_STORAGE_LDAP_BASE= \ DM_STORAGE_LDAP_OBJECT_CLASS="applicationProcess" \ DM_STORAGE_FILE_DIRECTORY= \ DM_STORAGE_POSTGRES_URL= \ DM_STORAGE_POSTGRES_TABLE="ldap_rest_storage" \ DM_STORAGE_VALKEY_URL= \ DM_STORAGE_VALKEY_PREFIX="ldap-rest:" \ DM_BCL_RETENTION="604800" \ DM_LDAP_GROUP_BASE= \ DM_LDAP_GROUPS_MAIN_ATTRIBUTE="cn" \ DM_GROUP_CLASSES=top,groupOfNames \ DM_ALLOW_UNEXISTENT_MEMBERS=false \ DM_GROUP_DEFAULT_ATTRIBUTES="{}" \ DM_GROUP_DUMMY_USER="cn=fakeuser" \ DM_GROUP_SCHEMA="/app/node_modules/ldap-rest/static/schemas/twake/groups.json" \ DM_EXTERNAL_MEMBERS_BRANCH="ou=contacts,dc=example,dc=com" \ DM_EXTERNAL_BRANCH_CLASSES=top,inetOrgPerson \ DM_STATIC_PATH="/app/node_modules/ldap-rest/static" \ DM_STATIC_NAME="static" \ DM_STATIC_CSP="frame-ancestors 'self'" \ DM_STATIC_FRAME_OPTIONS="SAMEORIGIN" \ DM_LDAP_FLAT_SCHEMA= \ DM_LDAP_FLAT_AUTO_REPAIR=true \ DM_LDAP_RAW_BASE= \ DM_LDAP_RAW_HIDDEN_ATTRIBUTES= \ DM_LDAP_RAW_SHOW_SECRETS=false \ DM_LDAP_RAW_MAX_RESULTS="200" \ DM_LDAP_RAW_SCHEMA_CACHE_TTL="3600" \ DM_BULK_IMPORT_SCHEMAS= \ DM_BULK_IMPORT_MAX_FILE_SIZE="10485760" \ DM_BULK_IMPORT_BATCH_SIZE="100" \ DM_LDAP_CONCURRENCY="10" \ DM_JAMES_WEBADMIN_URL="http://localhost:8000" \ DM_JAMES_WEBADMIN_TOKEN= \ DM_JAMES_SIGNATURE_TEMPLATE= \ DM_JAMES_CONCURRENCY="10" \ DM_JAMES_INIT_DELAY="1000" \ DM_JAMES_RETRY_ATTEMPTS="5" \ DM_JAMES_RETRY_DELAY="1000" \ DM_JAMES_MAILING_LIST_BRANCHES= \ DM_JAMES_MAILBOX_TYPE_ATTRIBUTE="twakeMailboxType" \ DM_JAMES_SYNC_FORWARDS=false \ DM_JAMES_TEAM_MAILBOX_ANY_DOMAIN=false \ DM_MIGRATION_PROXY_URL= \ DM_MIGRATION_PROXY_TOKEN= \ DM_MIGRATION_PROXY_BRANCHES= \ DM_MIGRATION_PROXY_CONCURRENCY="10" \ DM_MIGRATION_PROXY_RETRY_ATTEMPTS="5" \ DM_MIGRATION_PROXY_RETRY_DELAY="1000" \ DM_TWAKE_DRIVE_WEBADMIN_URL= \ DM_TWAKE_DRIVE_WEBADMIN_TOKEN= \ DM_TWAKE_DRIVE_CONCURRENCY="10" \ DM_TWAKE_DRIVE_DOMAIN_ATTRIBUTE="twakeCozyDomain" \ DM_TWAKE_DRIVE_DEFAULT_DOMAIN_TEMPLATE= \ DM_CALENDAR_WEBADMIN_URL="http://localhost:8080" \ DM_CALENDAR_WEBADMIN_TOKEN= \ DM_CALENDAR_CONCURRENCY="10" \ DM_CALENDAR_RESOURCE_BASE= \ DM_CALENDAR_RESOURCE_OBJECTCLASS= \ DM_CALENDAR_RESOURCE_CREATOR="admin@example.com" \ DM_CALENDAR_RESOURCE_DOMAIN= \ DM_CALENDAR_FIRSTNAME_ATTRIBUTE="givenName" \ DM_CALENDAR_LASTNAME_ATTRIBUTE="sn" \ DM_COZY_ADMIN_URL= \ DM_COZY_ADMIN_USER="admin" \ DM_COZY_ADMIN_PASSPHRASE= \ DM_COZY_ORG_ID= \ DM_COZY_ORG_DOMAIN= \ DM_COZY_DEFAULT_LOCALE="fr" \ DM_COZY_CONTEXT_NAME="default" \ DM_COZY_APPS="home,drive,settings,notes,dataproxy" \ DM_COZY_AUTH_EXCHANGE="auth" \ DM_COZY_B2B_EXCHANGE="b2b" \ DM_COZY_USER_CREATED_ROUTING_KEY="user.created" \ DM_COZY_USER_DELETED_ROUTING_KEY="domain.user.deleted" \ DM_CLOUDERY_MANAGER_URL= \ DM_CLOUDERY_MANAGER_TOKEN= \ DM_CLOUDERY_OFFER="b2b_twake_default" \ DM_CLOUDERY_DOMAIN= \ DM_CLOUDERY_USER_BRANCH= \ DM_CLOUDERY_ORG_ID_HEADER="x-cloudery-org-id" \ DM_CLOUDERY_ORG_ROLE_HEADER="x-cloudery-org-role" \ DM_CLOUDERY_DEFAULT_ORG_ROLE="member" \ DM_CLOUDERY_FQDN_ATTRIBUTE="twakeWorkspaceUrl" \ DM_CLOUDERY_ORG_ID_ATTRIBUTE="twakeOrganizationId" \ DM_CLOUDERY_ORG_ROLE_ATTRIBUTE="twakeOrganizationRole" \ DM_CLOUDERY_PHONES_ATTRIBUTE="twakePhones" \ DM_CLOUDERY_INVITED_ATTRIBUTE="twakeInvited" \ DM_CLOUDERY_DEFAULT_LOCALE="en" \ DM_CLOUDERY_WORKFLOW_POLL_INTERVAL_MS="2000" \ DM_CLOUDERY_WORKFLOW_MAX_ATTEMPTS="60" \ DM_TWAKE_LIFECYCLE_ROLE_ATTRIBUTE= \ DM_TWAKE_LIFECYCLE_LOCK_ATTRIBUTE= \ DM_TWAKE_LIFECYCLE_LOCK_VALUE= \ DM_TWAKE_LIFECYCLE_DELETED_ATTRIBUTE= \ DM_TWAKE_LIFECYCLE_DELETED_VALUE="TRUE" \ DM_TWAKE_LIFECYCLE_DELETED_AT_ATTRIBUTE= \ DM_TWAKE_LIFECYCLE_DELETED_AT_FORMAT="iso8601" \ DM_TWAKE_LIFECYCLE_REASON_ATTRIBUTE= \ DM_TWAKE_LIFECYCLE_RULES= \ DM_TWAKE_LIFECYCLE_MEMBER_ATTRIBUTE="member" \ DM_TWAKE_GROUP_BASE= \ DM_TWAKE_GROUP_USER_BASE= \ DM_TWAKE_GROUP_ORGANIZATION_DN= \ DM_TWAKE_GROUP_ORGANIZATION_STATUS_ATTRIBUTE="twakeOrgStatus" \ DM_TWAKE_GROUP_ORGANIZATION_DELETED_VALUE="deleted" \ DM_TWAKE_GROUP_DISPLAY_NAME_ATTRIBUTE="twakeDisplayName" \ DM_TWAKE_GROUP_COLOR_ATTRIBUTE="twakeGroupColor" \ DM_TWAKE_GROUP_CREATED_AT_ATTRIBUTE="twakeCreatedAt" \ DM_TWAKE_GROUP_MAX_PAGE_LIMIT="1000" \ DM_TWAKE_GROUP_MEMBER_FIELDS="{}" \ DM_TWAKE_SPACE_BASE= \ DM_TWAKE_SPACE_CLASS=top,twakeSpace \ DM_TWAKE_SPACE_DISPLAY_NAME_ATTRIBUTE="twakeDisplayName" \ DM_TWAKE_SPACE_ADMIN_ATTRIBUTE="twakeSpaceAdmin" \ DM_TWAKE_SPACE_EDITOR_ATTRIBUTE="twakeSpaceEditor" \ DM_TWAKE_SPACE_VIEWER_ATTRIBUTE="twakeSpaceViewer" \ DM_TWAKE_SPACE_USER_ROLE_ATTRIBUTE= \ DM_TWAKE_SPACE_EXCHANGE="space" \ DM_TWAKE_SPACE_SYNC_QUEUE="twake.space.sync.requested.ldap-rest" \ DM_TWAKE_TOMBSTONE_DN= \ DM_TWAKE_TOMBSTONE_DEFAULT_REASON="deleted" \ DM_TWAKE_TOMBSTONE_REASON_HEADER="x-deletion-reason" \ DM_TWAKE_TOMBSTONE_REASONS= \ DM_TWAKE_TOMBSTONE_CLEAR_ATTRIBUTES= \ DM_TWAKE_TOMBSTONE_ERASE_MIN_AGE="2592000" \ DM_TWAKE_TOMBSTONE_GROUP_BASES= \ DM_TWAKE_INSTANCE_DN= \ DM_TWAKE_INSTANCE_SKIP_ATTRIBUTE= \ DM_TWAKE_INSTANCE_SKIP_VALUE= \ DM_TWAKE_INSTANCE_PROVIDER="cloudery" \ DM_TWAKE_INSTANCE_ID="{uid}" \ DM_TWAKE_INSTANCE_FQDN_ATTRIBUTE="twakeWorkspaceUrl" \ DM_TWAKE_INSTANCE_SENT_ATTRIBUTE="twakeCreatedEventAt" \ DM_TWAKE_INSTANCE_LOCALE= \ DM_TWAKE_INSTANCE_TIMEOUT="30000" \ DM_TWAKE_INSTANCE_CLOUDERY_URL= \ DM_TWAKE_INSTANCE_CLOUDERY_TOKEN= \ DM_TWAKE_INSTANCE_CLOUDERY_OFFER="b2b_twake_default" \ DM_TWAKE_INSTANCE_CLOUDERY_DOMAIN= \ DM_TWAKE_INSTANCE_CLOUDERY_ORGANIZATION_OFFER= \ DM_TWAKE_INSTANCE_COZY_URL= \ DM_TWAKE_INSTANCE_COZY_USER="admin" \ DM_TWAKE_INSTANCE_COZY_PASSPHRASE= \ DM_TWAKE_INSTANCE_COZY_DOMAIN= \ DM_TWAKE_INSTANCE_COZY_CONTEXT="default" \ DM_TWAKE_INSTANCE_COZY_APPS="home,drive,settings,notes,dataproxy" \ DM_TWAKE_INSTANCE_COZY_ORG_ID= \ DM_TWAKE_INSTANCE_COZY_ORG_DOMAIN= \ DM_TWAKE_INSTANCE_AUTH_EXCHANGE="auth" \ DM_TWAKE_INSTANCE_B2B_EXCHANGE="b2b" \ DM_TWAKE_INSTANCE_USER_CREATED_KEY="user.created" \ DM_TWAKE_INSTANCE_ORGANIZATION_CREATED_KEY="organization.created" \ DM_TWAKE_INSTANCE_WORKPLACE_CREATED_KEY="workplace.created" \ DM_TWAKE_INSTANCE_QUEUE="workplace.created.ldap-rest" \ DM_TWAKE_INSTANCE_ORGANIZATION_BASE= \ DM_TWAKE_INSTANCE_ORGANIZATION_ID_ATTRIBUTE="ou" \ DM_TWAKE_INSTANCE_ORGANIZATION_DOMAIN_ATTRIBUTE="twakeDomain" \ DM_TWAKE_INSTANCE_ORGANIZATION_NAME_ATTRIBUTE="description" \ DM_TWAKE_INSTANCE_ORGANIZATION_FQDN_ATTRIBUTE= \ DM_RABBITMQ_URL= \ DM_APPLICATIVE_ACCOUNT_BASE= \ DM_MAX_APP_ACCOUNTS="5" \ DM_APP_ACCOUNTS_USER_ATTRIBUTE= \ DM_APPLICATIVE_ACCOUNT_ATTRIBUTES=objectClass,cn,sn,givenName,displayName,description \ DM_LDAP_OPERATIONAL_ATTRIBUTES=dn,controls,structuralObjectClass,entryUUID,entryDN,subschemaSubentry,modifyTimestamp,modifiersName,createTimestamp,creatorsName,userPassword \ DM_TRASH_BASE= \ DM_TRASH_WATCHED_BASES= \ DM_TRASH_ADD_METADATA="true" \ DM_TRASH_AUTO_CREATE="true" \ DM_LLNG_INI="/etc/lemonldap-ng/lemonldap-ng.ini" \ DM_LLNG_USERNAME_HEADER= \ DM_AUTH_PATH_PREFIX= \ DM_AUTH_TOKENS= \ DM_AUTH_FAKE_USER= \ DM_AUTH_TOTP= \ DM_AUTH_TOTP_WINDOW="1" \ DM_AUTH_TOTP_STEP="30" \ DM_AUTH_HMAC= \ DM_AUTH_HMAC_WINDOW="120000" \ DM_AUTHZ_PER_ROUTES= \ DM_AUTHZ_UNRESOLVED_USER="deny" \ DM_AUTHZ_IDENTITY="req.user" \ DM_AUTHZ_FOR= \ DM_AUTHZ_COMBINE=false \ DM_AUTHZ_SCOPE_SOURCE= \ DM_AUTHZ_FILTER_ATTACHED_ENTRIES=false \ DM_AUTHZ_TRANSIT_BRANCH= \ DM_AUTHZ_TRANSIT_ORIGIN_ATTRIBUTE= \ DM_AUTHZ_PER_BRANCH_CONFIG="{\"default\":{\"read\":true,\"write\":false,\"delete\":false}}" \ DM_AUTHZ_PER_BRANCH_CACHE_TTL="60" \ DM_LDAP_GROUP_MEMBER_ATTRIBUTE="member" \ DM_AUTHZ_DYNAMIC_BYPASS= \ DM_AUTHZ_DYNAMIC_BASE= \ DM_AUTHZ_DYNAMIC_CACHE_TTL="60" \ DM_AUTHZ_DYNAMIC_TOKEN_ATTRIBUTE="userPassword" \ DM_AUTHZ_DYNAMIC_CONFIG_ATTRIBUTE="description" \ DM_AUTHZ_DYNAMIC_TENANT_ATTRIBUTE="cn" \ DM_AUTHZ_DYNAMIC_RELOAD_ENDPOINT=false \ DM_AUTHZ_LOCAL_ADMIN_ATTRIBUTE="twakeLocalAdminLink" \ DM_AUTHZ_GLOBAL_BRANCHES= \ DM_AUTHZ_GLOBAL_WRITE_BRANCHES= \ DM_AUTHZ_GLOBAL_ORGANIZATION= \ DM_OIDC_SERVER= \ DM_OIDC_USERNAME_CLAIM="sub" \ DM_OIDC_CLIENT_ID= \ DM_OIDC_CLIENT_SECRET= \ DM_BASE_URL= \ DM_OIDC_LOGOUT_ORIGINS= \ DM_RATE_LIMIT_WINDOW_MS="900000" \ DM_RATE_LIMIT_MAX="100" \ DM_CROWDSEC_URL="http://localhost:8080/v1/decisions" \ DM_CROWDSEC_API_KEY= \ DM_CROWDSEC_CACHE_TTL="60" \ DM_TRUSTED_PROXIES= \ DM_TRUSTED_PROXY_AUTH_HEADER="Auth-User" \ DM_PPOLICY_DEFAULT_DN= \ DM_PPOLICY_BASE= \ DM_PPOLICY_WARN_DAYS="14" \ DM_PPOLICY_VALIDATE_COMPLEXITY=false \ DM_PPOLICY_MIN_LENGTH="12" \ DM_PPOLICY_REQUIRE_UPPERCASE=true \ DM_PPOLICY_REQUIRE_LOWERCASE=true \ DM_PPOLICY_REQUIRE_DIGIT=true \ DM_PPOLICY_REQUIRE_SPECIAL=true \ DM_LDAP_USERS_BASE= \ DM_SCIM_PREFIX="/scim/v2" \ DM_SCIM_USER_BASE= \ DM_SCIM_GROUP_BASE= \ DM_SCIM_USER_BASE_TEMPLATE= \ DM_SCIM_GROUP_BASE_TEMPLATE= \ DM_SCIM_BASE_MAP= \ DM_SCIM_USER_BASE_HEADER= \ DM_SCIM_GROUP_BASE_HEADER= \ DM_SCIM_BASE_HEADER_ROOT= \ DM_SCIM_USER_OBJECT_CLASSES=top,inetOrgPerson,organizationalPerson,person \ DM_SCIM_USER_RDN_ATTRIBUTE="uid" \ DM_SCIM_USER_LOCK_ATTRIBUTE="pwdAccountLockedTime" \ DM_SCIM_USER_LOCK_VALUE= \ DM_SCIM_GROUP_OBJECT_CLASSES=top,groupOfNames \ DM_SCIM_GROUP_RDN_ATTRIBUTE="cn" \ DM_SCIM_GROUP_EXTERNAL_ID_ATTRIBUTE= \ DM_SCIM_ID_ATTRIBUTE="rdn" \ DM_SCIM_USER_MAPPING= \ DM_SCIM_GROUP_MAPPING= \ DM_SCIM_MAX_RESULTS="200" \ DM_SCIM_MAX_SCANNED="10000" \ DM_SCIM_BULK_MAX_OPERATIONS="100" \ DM_SCIM_BULK_MAX_PAYLOAD_SIZE="1048576" \ DM_SCIM_ETAG=false \ DM_SCIM_BASE_URL= USER node EXPOSE 8081 9464 HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ CMD ["node", "-e", "fetch('http://127.0.0.1:' + (process.env.DM_PORT ?? 8081) + '/healthz').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))"] CMD ["npx", "ldap-rest"] linagora-ldap-rest-a4bb78e/LICENSE000066400000000000000000001033321527331160600167100ustar00rootroot00000000000000 GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see .linagora-ldap-rest-a4bb78e/README.md000066400000000000000000000104311527331160600171570ustar00rootroot00000000000000[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/linagora/ldap-rest) # LDAP-Rest **ldap-rest** is a lightweight directory manager that provides LDAP integration through a plugin-based architecture. This system enables directory management operations with configurable authentication, extensible functionality through events/hooks, and extensible REST API. Core plugins also provide plugins that ensure LDAP data consistency. ## Key Features - **Robust Error Handling** - Server stays online even when plugins encounter errors - **Plugin Architecture** - Extensible through a powerful plugin system - **Flexible Authentication** - Support for multiple authentication methods - **REST API** - Complete LDAP operations through REST endpoints - **Event Hooks** - Intercept and customize LDAP operations - **Browser Libraries** - Ready-to-use JavaScript components ## How it works All configuration is done via command-line arguments and/or environment variables. Example: ```shell npx ldap-rest --ldap-base 'dc=example,dc=com' \ --ldap-dn 'cn=admin,dc=example,dc=com' --ldap-pwd admin \ --ldap-url ldap://localhost \ --log-level notice \ --plugin core/ldap/groups --ldap-group-base 'ou=groups,dc=example,dc=com' \ --plugin core/ldap/externalUsersInGroups ``` ### LDAP Failover Multiple LDAP servers are supported for high availability: ```shell --ldap-url ldap://ldap1.example.com,ldap://ldap2.example.com,ldap://ldap3.example.com ``` The system automatically tries each URL in order and fails over if a connection fails. ### Log Levels LDAP-Rest uses syslog-style log levels: - `error` - Only errors - `warn` - Warnings and errors - `notice` - Web access logs (recommended for production) - `info` - General info + web logs - `debug` - All debug output Use `--log-level notice` for production to see web access logs without general info messages. ## Deployment ### Docker Multi-arch (amd64/arm64) images are published on each release: ```shell docker run -p 8081:8081 ghcr.io/linagora/ldap-rest:latest # also available on Docker Hub as yadd/ldap-rest ``` ### Helm A Helm chart is published as an OCI artifact on each release: ```shell helm install ldap-rest oci://ghcr.io/linagora/charts/ldap-rest ``` See the **[Helm chart README](./helm/ldap-rest/README.md)** for configuration (image, `env`/`secrets`, ingress, probes, …). ## Configuration See **[Configuration Guide](./docs/usage/configuration.md)** for all CLI options and environment variables. ## Documentation Documentation is organized into 4 categories: ### [Usage](./docs/usage/README.md) Installation, configuration and plugin usage. - **[Getting Started](./docs/usage/README.md)** - Quick start guide - **[Configuration](./docs/usage/configuration.md)** - CLI options and environment variables - **[Helm chart](./helm/ldap-rest/README.md)** - Kubernetes deployment - **[Plugins](./docs/usage/plugins/README.md)** - Plugin documentation - **[Authentication](./docs/usage/plugins/auth/README.md)** - Token, TOTP, HMAC, LemonLDAP::NG, OpenID Connect ### [Plugin Development](./docs/plugin-development/README.md) Create and extend LDAP-Rest with custom plugins. - **[Development Guide](./docs/plugin-development/README.md)** - Architecture and plugin creation - **[Hooks](./docs/plugin-development/hooks.md)** - LDAP hooks system - **[Contributing](./CONTRIBUTING.md)** - Contribution guide ### [Client Application Development](./docs/client-development/README.md) Integrate LDAP-Rest into your web applications. - **[REST API](./docs/client-development/api/rest-api.md)** - Complete API reference - **[Browser Libraries](./docs/client-development/browser/libraries.md)** - Web components - **[Examples](./docs/client-development/examples/README.md)** - React, Vue.js, Vanilla JS - **[Browser Demos](./examples/web/)** - Interactive demos (TOTP client, LDAP tree viewer) ### [Architecture](./docs/architecture/README.md) How the server is put together and why. - **[Overview](./docs/architecture/README.md)** - Module map, life of a request, life of a write - **[Decision records](./docs/architecture/adr/)** - The decisions the code embodies ## Copyright and license [![Powered by LINAGORA](./docs/linagora.png)](https://linagora.com) Copyright 2025-present [LINAGORA](https://linagora.com) Licensed under [GNU AGPL-3.0](./LICENSE) linagora-ldap-rest-a4bb78e/SECURITY.md000066400000000000000000000104531527331160600174750ustar00rootroot00000000000000# Security Policy ## Supported Versions We provide security updates for the following versions: | Version | Supported | | ------- | ------------------ | | 0.1.x | :white_check_mark: | ## Reporting a Vulnerability **Please do not report security vulnerabilities through public GitHub issues.** ### How to Report If you discover a security vulnerability, please report it by sending an email to: **hosting@linagora.com** Please include the following information: - Type of vulnerability (e.g., SQL injection, XSS, authentication bypass) - Full paths of source file(s) related to the vulnerability - Location of the affected source code (tag/branch/commit or direct URL) - Step-by-step instructions to reproduce the issue - Proof-of-concept or exploit code (if possible) - Impact of the vulnerability (what an attacker could achieve) ## Security Measures ### Authentication & Authorization This project supports multiple authentication/authorization mechanisms: - Token-based authentication - TOTP (Time-based One-Time Password) - HMAC authentication - LemonLDAP::NG integration - OpenID Connect - Authorization based on branch or fixed configuration file Also: - Trusted proxy validation for X-Forwarded-For headers - Rate limiting - CrowdSec integration for IP blocking See [Authentication Guide](./docs/authentication.md) for configuration. ### Best Practices When deploying LDAP-Rest in production: 1. **Use LDAPS** (LDAP over TLS) instead of plain LDAP: 2. **Enable authentication** 3. **Enable authorization** 4. **Enable rate limiting** to prevent brute force: 5. **Use CrowdSec** for IP reputation: 6. **Set appropriate log level**: ```bash --log-level notice # Recommended for production ``` 7. **Use environment variables** for secrets (never commit credentials): ```bash export DM_LDAP_PWD="secret-password" export DM_AUTH_TOKENS="token1,token2" ``` 8. **Configure LDAP failover** for high availability: ```bash --ldap-url ldaps://ldap1.example.com,ldaps://ldap2.example.com ``` 9. **Run behind a reverse proxy** (nginx, Apache) with: - TLS termination - Request size limits - Additional rate limiting - WAF (Web Application Firewall) 10. **Keep dependencies updated**: ```bash npm audit npm update ``` ## Vulnerability Disclosure Policy We follow responsible disclosure: 1. **Private Disclosure**: Report vulnerabilities privately first 2. **Fix Period**: Allow time for fix development and deployment (typically 90 days) 3. **Coordinated Disclosure**: Publish advisory after fix is available 4. **CVE Assignment**: We will request CVE numbers for confirmed vulnerabilities ## Security Updates Security updates are published as: - **GitHub Security Advisories**: https://github.com/linagora/ldap-rest/security/advisories - **NPM Security Advisories**: For published npm packages - **Release Notes**: Security fixes are clearly marked in CHANGELOG Subscribe to GitHub notifications to receive security alerts. ## Compliance ### Cyber Resilience Act (CRA) This project aims to comply with the EU Cyber Resilience Act: - ✅ Security by design approach - ✅ Vulnerability disclosure process - ✅ Regular security updates - ✅ Documentation of security measures - ✅ SBOM (Software Bill of Materials) via package.json and package-lock.json ### Open Source Exemption This software is provided as **open source, non-commercial software**. Organizations that deploy this software commercially are responsible for ensuring their own compliance with applicable regulations including the Cyber Resilience Act. ## Security Audits We welcome security audits and penetration testing: - Please notify us before conducting security testing - Respect rate limits and avoid disrupting services - Focus on security issues, not denial-of-service vulnerabilities - Report findings through our responsible disclosure process ## Contact - **Security Issues**: hosting@linagora.com - **General Issues**: https://github.com/linagora/ldap-rest/issues - **Website**: https://linagora.com ## Acknowledgments We thank the security researchers who have responsibly disclosed vulnerabilities to us. A list of acknowledgments will be maintained here as vulnerabilities are disclosed and fixed. --- **Last Updated**: 2025-01-03 [![Powered by LINAGORA](./docs/linagora.png)](https://linagora.com) linagora-ldap-rest-a4bb78e/UPGRADE-NOTES.md000077700000000000000000000000001527331160600245312docs/usage/upgrading.mdustar00rootroot00000000000000linagora-ldap-rest-a4bb78e/bin/000077500000000000000000000000001527331160600164515ustar00rootroot00000000000000linagora-ldap-rest-a4bb78e/bin/cleanup-external-users.mjs000077500000000000000000000133271527331160600236030ustar00rootroot00000000000000#!/usr/bin/env node /** * Cleanup script for external users that are no longer referenced in any group * * Usage: * cleanup-external-users.mjs [--dry-run] [--verbose] [--quiet] * * Options: * --dry-run Show what would be deleted without actually deleting * --verbose Show detailed information about each user checked * --quiet Only show errors and deletions (WOULD DELETE in dry-run mode) */ import { parseConfig } from '../dist/lib/parseConfig.js'; import configArgs from '../dist/config/args.js'; import ldapActions from '../dist/lib/ldapActions.js'; import { buildLogger } from '../dist/logger/winston.js'; // Parse CLI arguments const args = process.argv.slice(2); if (args.includes('--help') || args.includes('-h')) { console.log(` cleanup-external-users - Remove orphaned external users from LDAP Usage: cleanup-external-users [OPTIONS] Description: Removes external users from the contacts branch that are no longer referenced in any group. Useful for maintaining a clean LDAP directory. Options: --dry-run Show what would be deleted without actually deleting --verbose Show detailed information about each user checked --quiet Only show errors and deletions (WOULD DELETE in dry-run mode) --help, -h Show this help message Examples: # Preview what would be deleted cleanup-external-users --dry-run # Delete orphaned users with detailed output cleanup-external-users --verbose # Delete orphaned users silently (for cron) cleanup-external-users --quiet # Preview in quiet mode (only show what would be deleted) cleanup-external-users --dry-run --quiet `); process.exit(0); } const dryRun = args.includes('--dry-run'); const verbose = args.includes('--verbose'); const quiet = args.includes('--quiet'); // Parse config const config = parseConfig(configArgs); const logger = buildLogger(config); // Simple LDAP wrapper to match what DM provides const server = { config, logger, hooks: {}, loadedPlugins: {}, operationSequence: 0, }; const ldap = new ldapActions(server); async function main() { try { if (!quiet) { logger.info('Starting cleanup of external users'); } if (!config.external_members_branch) { logger.error('external_members_branch not configured'); process.exit(1); } if (!config.ldap_group_base) { logger.error('ldap_group_base not configured'); process.exit(1); } if (dryRun && !quiet) { logger.info('DRY RUN MODE - no changes will be made'); } // Get all external users if (!quiet) { logger.info( `Searching for external users in ${config.external_members_branch}` ); } const externalUsersResult = await ldap.search( { paged: false, scope: 'one', attributes: ['dn', 'mail'], }, config.external_members_branch ); const externalUsers = externalUsersResult.searchEntries; if (!quiet) { logger.info(`Found ${externalUsers.length} external users`); } if (externalUsers.length === 0) { if (!quiet) { logger.info('No external users to check'); } return; } // Get all groups if (!quiet) { logger.info(`Searching for groups in ${config.ldap_group_base}`); } const groupsResult = await ldap.search( { paged: false, scope: 'sub', attributes: ['dn', config.ldap_group_member_attribute || 'member'], }, config.ldap_group_base ); const groups = groupsResult.searchEntries; if (!quiet) { logger.info(`Found ${groups.length} groups`); } // Build a set of all referenced member DNs const memberAttr = config.ldap_group_member_attribute || 'member'; const referencedMembers = new Set(); for (const group of groups) { const members = group[memberAttr]; if (members) { const memberList = Array.isArray(members) ? members : [members]; memberList.forEach(member => { // Normalize DN (remove spaces) referencedMembers.add(member.replace(/\s/g, '')); }); } } if (!quiet) { logger.info( `Found ${referencedMembers.size} unique member references across all groups` ); } // Check each external user let toDelete = 0; let kept = 0; for (const user of externalUsers) { const userDn = user.dn.replace(/\s/g, ''); const isReferenced = referencedMembers.has(userDn); if (isReferenced) { kept++; if (verbose) { logger.info( `KEEP: ${user.dn} (${user.mail || 'no mail'}) - still referenced` ); } } else { toDelete++; if (dryRun) { logger.info( `WOULD DELETE: ${user.dn} (${user.mail || 'no mail'}) - not referenced` ); } else { if (!quiet) { logger.info(`DELETING: ${user.dn} (${user.mail || 'no mail'})`); } try { await ldap.del(user.dn); } catch (err) { logger.error(`Failed to delete ${user.dn}: ${err}`); } } } } // Summary if (!quiet) { logger.info(''); logger.info('=== CLEANUP SUMMARY ==='); logger.info(`Total external users: ${externalUsers.length}`); logger.info(`Kept (still referenced): ${kept}`); logger.info(`${dryRun ? 'Would delete' : 'Deleted'}: ${toDelete}`); if (dryRun && toDelete > 0) { logger.info(''); logger.info('Run without --dry-run to actually delete these users'); } } } catch (err) { logger.error(`Error during cleanup: ${err}`); process.exit(1); } finally { process.exit(0); } } main().catch(err => { console.error('Fatal error:', err); process.exit(1); }); linagora-ldap-rest-a4bb78e/bin/index.mjs000077500000000000000000000031761527331160600203050ustar00rootroot00000000000000#!/usr/bin/env node import { DM } from '../dist/bin/index.js'; // Parse CLI arguments const args = process.argv.slice(2); if (args.includes('--help') || args.includes('-h')) { console.log(` ldap-rest - Lightweight LDAP directory manager Usage: ldap-rest [OPTIONS] Description: Starts the ldap-rest server which provides a REST API and web interface for managing LDAP directories. Configuration is done through environment variables or command-line arguments. Main options: --help, -h Show this help message --port PORT Server port (default: 8080) --ldap-url URL LDAP server URL --ldap-dn DN LDAP bind DN (required) --ldap-pwd PASSWORD LDAP bind password (required) --ldap-base BASE LDAP base DN (required) --plugin PLUGIN Load plugin (can be specified multiple times) --api-prefix PREFIX API prefix (default: /api) --log-level LEVEL Log level (error, warn, info, debug) See plugin documentation for additional options. Environment Variables: All command-line options can also be set via environment variables by prefixing with DM_ and using uppercase with underscores. Example: --ldap-url becomes DM_LDAP_URL Examples: # Start against a directory npx ldap-rest --ldap-url ldap://localhost:389 \\ --ldap-base dc=example,dc=com \\ --ldap-dn cn=admin,dc=example,dc=com --ldap-pwd secret # Load specific plugins npx ldap-rest --plugin ldapGroups --plugin ldapOrganization `); process.exit(0); } const server = new DM(); await server.ready; await server.run(); server.shutdownOnSignals(); server.exitOnCrash(); linagora-ldap-rest-a4bb78e/bin/sync-app-accounts.mjs000066400000000000000000000233331527331160600225370ustar00rootroot00000000000000#!/usr/bin/env node /** * Sync utility to verify and fix consistency between users and applicative accounts * Ensures principal accounts exist for all users with mail * Removes orphaned applicative accounts * @author Generated with Claude Code */ import { DM } from '../dist/bin/index.js'; // Parse command line arguments const args = process.argv.slice(2); const quiet = args.includes('--quiet') || args.includes('-q'); const dryRun = args.includes('--dry-run') || args.includes('-n'); if (args.includes('--help') || args.includes('-h')) { console.log(` Usage: sync-app-accounts [options] Synchronizes applicative accounts with LDAP users. Ensures consistency between ou=users and ou=applicative branches. Operations performed: 1. Create missing principal accounts (uid=mail) for users with mail 2. Delete orphaned applicative accounts (user_cXXXXXXXX without parent user) 3. Delete orphaned principal accounts (uid=mail without matching user) Options: --quiet, -q Only show summary and errors --dry-run, -n Show what would be changed without making changes --help, -h Show this help message Environment variables: DM_LDAP_BASE LDAP search base (required) DM_APPLICATIVE_ACCOUNT_BASE Applicative accounts base (required) DM_MAIL_ATTRIBUTE Mail attribute name (default: mail) `); process.exit(0); } // A base-scoped LDAP search on a non-existent entry returns noSuchObject (32) // rather than an empty result set, so callers must treat it as "absent". const isNoSuchObject = err => err?.code === 32 || /no such object|0x20/i.test(err?.message || ''); async function syncAppAccounts() { const dm = new DM(); await dm.ready; const mailAttr = dm.config.mail_attribute || 'mail'; const applicativeBase = dm.config.applicative_account_base; const userBase = dm.config.ldap_base; if (!applicativeBase) { dm.logger.error('DM_APPLICATIVE_ACCOUNT_BASE is not configured'); process.exit(1); } if (!userBase) { dm.logger.error('DM_LDAP_BASE is not configured'); process.exit(1); } // Fail fast with a clear message if the applicative branch is missing, // instead of letting the orphan scan abort with a raw NoSuchObjectError. try { await dm.ldap.search({ scope: 'base', paged: false }, applicativeBase); } catch (err) { if (isNoSuchObject(err)) { dm.logger.error( `Applicative base ${applicativeBase} does not exist. Create it first (e.g. an organizationalUnit entry) before running this sync.` ); process.exit(1); } throw err; } if (!quiet) { dm.logger.info('Starting applicative accounts synchronization...'); dm.logger.info(`User base: ${userBase}`); dm.logger.info(`Applicative base: ${applicativeBase}`); if (dryRun) { dm.logger.info('DRY RUN MODE: No changes will be made'); } dm.logger.info(''); } let principalCreated = 0; let appAccountsDeleted = 0; let principalDeleted = 0; let errors = 0; try { // Step 1: Check users and create missing principal accounts if (!quiet) { dm.logger.info( 'Step 1: Checking users and creating missing principal accounts...' ); } const usersResult = await dm.ldap.search( { paged: false, filter: `(${mailAttr}=*)`, attributes: [mailAttr, 'uid', 'cn', 'sn', 'givenName', 'displayName'], }, userBase ); const users = usersResult.searchEntries || []; if (!quiet) { dm.logger.info(`Found ${users.length} users with mail attribute`); } for (const user of users) { const mail = Array.isArray(user[mailAttr]) ? String(user[mailAttr][0]) : String(user[mailAttr]); if (!mail) continue; try { // Check if principal account exists. A base search on a missing entry // throws noSuchObject rather than returning an empty set, so we treat // that exception as "absent" and fall through to creation. const principalDn = `uid=${mail},${applicativeBase}`; let principalResult; try { principalResult = await dm.ldap.search( { scope: 'base', paged: false, }, principalDn ); } catch (err) { if (isNoSuchObject(err)) { principalResult = { searchEntries: [] }; } else { throw err; } } if ( !principalResult.searchEntries || principalResult.searchEntries.length === 0 ) { dm.logger.warn(`Missing principal account for ${mail}`); if (dryRun) { dm.logger.info(` Would create: ${principalDn}`); principalCreated++; } else { // Create principal account const attrs = { objectClass: ['inetOrgPerson'], uid: mail, }; // Copy attributes const attrsToCopy = [ 'cn', 'sn', 'givenName', mailAttr, 'displayName', ]; for (const attr of attrsToCopy) { const value = user[attr]; // Skip absent values. A requested-but-missing attribute comes // back as an empty array (truthy in JS), which would otherwise // produce an `add` with no values → ProtocolError "no values for // attribute type". if (value === undefined || value === null || value === '') { continue; } if (Array.isArray(value)) { if (value.length === 0) continue; attrs[attr] = value.map(v => Buffer.isBuffer(v) ? v.toString() : String(v) ); } else { attrs[attr] = Buffer.isBuffer(value) ? value.toString() : String(value); } } await dm.ldap.add(principalDn, attrs); dm.logger.info(` Created: ${principalDn}`); principalCreated++; } } } catch (err) { dm.logger.error(`Error processing user ${mail}: ${err.message}`); errors++; } } // Step 2: Check applicative accounts and delete orphans if (!quiet) { dm.logger.info('\nStep 2: Checking applicative accounts for orphans...'); } const appAccountsResult = await dm.ldap.search( { paged: false, filter: '(uid=*)', }, applicativeBase ); const appAccounts = appAccountsResult.searchEntries || []; if (!quiet) { dm.logger.info(`Found ${appAccounts.length} entries in applicative base`); } // Build user map for quick lookup const userMap = new Map(); const mailMap = new Map(); for (const user of users) { const uid = Array.isArray(user.uid) ? String(user.uid[0]) : String(user.uid); const mail = Array.isArray(user[mailAttr]) ? String(user[mailAttr][0]) : String(user[mailAttr]); userMap.set(uid, true); if (mail) mailMap.set(mail, true); } for (const account of appAccounts) { const uid = Array.isArray(account.uid) ? String(account.uid[0]) : String(account.uid); const dn = account.dn; try { // Check if it's an applicative account (format: username_cXXXXXXXX) if (uid.includes('_c')) { const username = uid.split('_')[0]; if (!userMap.has(username)) { dm.logger.warn( `Orphaned applicative account: ${uid} (user ${username} not found)` ); if (dryRun) { dm.logger.info(` Would delete: ${dn}`); appAccountsDeleted++; } else { await dm.ldap.delete(dn); dm.logger.info(` Deleted: ${dn}`); appAccountsDeleted++; } } } else { // Principal account (uid=mail) - check if mail exists in users const mail = uid; if (!mailMap.has(mail)) { dm.logger.warn( `Orphaned principal account: ${mail} (no user with this mail)` ); if (dryRun) { dm.logger.info(` Would delete: ${dn}`); principalDeleted++; } else { await dm.ldap.delete(dn); dm.logger.info(` Deleted: ${dn}`); principalDeleted++; } } } } catch (err) { dm.logger.error(`Error processing account ${uid}: ${err.message}`); errors++; } } // Summary dm.logger.info('\n' + '='.repeat(60)); dm.logger.info('Synchronization summary:'); dm.logger.info( ` Principal accounts ${dryRun ? 'to create' : 'created'}: ${principalCreated}` ); dm.logger.info( ` Applicative accounts ${dryRun ? 'to delete' : 'deleted'}: ${appAccountsDeleted}` ); dm.logger.info( ` Principal accounts ${dryRun ? 'to delete' : 'deleted'}: ${principalDeleted}` ); dm.logger.info(` Errors: ${errors}`); dm.logger.info('='.repeat(60)); if (errors > 0) { process.exit(1); } } catch (err) { dm.logger.error('Error during synchronization:', err); process.exit(1); } finally { // ldapActions exposes no public unbind; pooled connections are released on // process exit. Guard so this teardown never throws if the method is absent. if (typeof dm.ldap.unbind === 'function') { await dm.ldap.unbind(); } } } // Run the sync. Pooled LDAP connections keep the event loop alive, so exit // explicitly once the sync resolves instead of hanging. syncAppAccounts() .then(() => process.exit(0)) .catch(err => { console.error('Fatal error:', err); process.exit(1); }); linagora-ldap-rest-a4bb78e/bin/sync-james.mjs000077500000000000000000000266421527331160600212520ustar00rootroot00000000000000#!/usr/bin/env node /** * Sync utility to verify and fix consistency between LDAP and James * Ensures James quotas match LDAP quota values (--quota-attribute) and that James aliases * match the LDAP alias attribute (catch-up in case the event-based sync failed) * @author Generated with Claude Code */ import { DM } from '../dist/bin/index.js'; // Parse command line arguments const args = process.argv.slice(2); const quiet = args.includes('--quiet') || args.includes('-q'); const dryRun = args.includes('--dry-run') || args.includes('-n'); const noAliasDelete = args.includes('--no-alias-delete'); if (args.includes('--help') || args.includes('-h')) { console.log(` Usage: sync-james [options] Synchronizes James with LDAP. LDAP is considered the source of truth. For every user it reconciles: - the mailbox quota (James quota <- LDAP --quota-attribute) - the mail aliases (James aliases <- LDAP alias attribute) The alias reconciliation is a catch-up mechanism: it repairs aliases that were not propagated to James (e.g. when the event-based sync failed). Options: --quiet, -q Only show summary and errors --dry-run, -n Show what would be changed without making changes --no-alias-delete Do not remove James aliases that are absent from LDAP (only add missing ones) --help, -h Show this help message Environment variables: DM_JAMES_WEBADMIN_URL James WebAdmin URL (required) DM_JAMES_WEBADMIN_TOKEN James WebAdmin authentication token DM_LDAP_BASE LDAP search base DM_MAIL_ATTRIBUTE Mail attribute name (default: mail) DM_QUOTA_ATTRIBUTE Quota attribute name (default: mailQuotaSize) DM_ALIAS_ATTRIBUTE Alias attribute name (default: mailAlternateAddress) `); process.exit(0); } /** * Normalize an email alias - handle AD format (smtp:alias@domain.com). * Mirrors the behaviour of TwakePlugin.normalizeAlias in the main codebase. * @param {string} alias * @returns {string} */ function normalizeAlias(alias) { if (alias.toLowerCase().startsWith('smtp:')) { return alias.substring(5); } return alias; } /** * What to log about a failure: a refused connection says `fetch failed`, * and the host and port are in its cause. * @param {unknown} err * @returns {string} */ function errorMessage(err) { if (!(err instanceof Error)) return String(err); const cause = /** @type {{ message?: string }} */ (err.cause); return cause?.message ? `${err.message}: ${cause.message}` : err.message; } /** * Extract normalized aliases from an LDAP attribute value. * @param {string|string[]|Buffer|Buffer[]|undefined} value * @returns {string[]} */ function getAliases(value) { if (!value) return []; const aliases = Array.isArray(value) ? value : [value]; return aliases .map(a => (Buffer.isBuffer(a) ? a.toString('utf-8') : String(a))) .map(a => normalizeAlias(a)) .filter(a => a.length > 0); } async function syncJames() { const dm = new DM(); await dm.ready; const mailAttr = dm.config.mail_attribute || 'mail'; const quotaAttr = dm.config.quota_attribute || 'mailQuotaSize'; const aliasAttr = dm.config.alias_attribute || 'mailAlternateAddress'; const jamesUrl = dm.config.james_webadmin_url; const jamesToken = dm.config.james_webadmin_token; if (!jamesUrl) { dm.logger.error('DM_JAMES_WEBADMIN_URL is not configured'); process.exit(1); } const headers = {}; if (jamesToken) { headers.Authorization = `Bearer ${jamesToken}`; } if (!quiet) { dm.logger.info('Starting LDAP to James synchronization...'); dm.logger.info(`LDAP base: ${dm.config.ldap_base}`); dm.logger.info(`James URL: ${jamesUrl}`); if (dryRun) { dm.logger.info('DRY RUN MODE: No changes will be made'); } dm.logger.info(''); } const stats = { checked: 0, quotaSynced: 0, aliasesAdded: 0, aliasesDeleted: 0, errors: 0, }; /** * Reconcile the mailbox quota for a single user. */ async function syncQuota(dn, mail, ldapQuota) { if (isNaN(ldapQuota)) return; const url = `${jamesUrl}/quota/users/${mail}/size`; const getRes = await fetch(url, { method: 'GET', headers }); if (!getRes.ok) { if (getRes.status === 404) { dm.logger.warn( `User ${mail} not found in James, skipping quota (DN: ${dn})` ); } else { dm.logger.error( `Error getting quota for ${mail}: ${getRes.status} ${getRes.statusText}` ); stats.errors++; } return; } const jamesQuota = Number(await getRes.text()); if (jamesQuota === ldapQuota) { if (!quiet) { dm.logger.info(`${mail}: quota OK (${ldapQuota})`); } return; } dm.logger.warn( `${mail}: quota mismatch - LDAP: ${ldapQuota}, James: ${jamesQuota}` ); if (dryRun) { dm.logger.info(` Would update James quota to ${ldapQuota}`); stats.quotaSynced++; return; } if (!quiet) { dm.logger.info(` Updating James quota to ${ldapQuota}...`); } const putRes = await fetch(url, { method: 'PUT', headers, body: ldapQuota.toString(), }); if (putRes.ok) { if (!quiet) { dm.logger.info(` Updated successfully`); } stats.quotaSynced++; } else { dm.logger.error( ` Failed to update quota: ${putRes.status} ${putRes.statusText}` ); stats.errors++; } } /** * Fetch the aliases currently declared in James for a destination mail. * @returns {Promise} list of source addresses, or null on error */ async function fetchJamesAliases(mail) { const res = await fetch(`${jamesUrl}/address/aliases/${mail}`, { method: 'GET', headers, }); // James returns 404 when the destination has no alias at all if (res.status === 404) { return []; } if (!res.ok) { dm.logger.error( `Error getting aliases for ${mail}: ${res.status} ${res.statusText}` ); return null; } const body = await res.json(); if (!Array.isArray(body)) { return []; } // James returns [{ source: 'alias@domain' }, ...] return body .map(e => (e && typeof e.source === 'string' ? e.source : null)) .filter(Boolean); } /** * Reconcile the aliases for a single user (catch-up). * LDAP is the source of truth: missing aliases are added, extra aliases in * James are removed (unless --no-alias-delete is set). */ async function syncAliases(dn, mail, ldapAliasesRaw) { const ldapAliases = getAliases(ldapAliasesRaw); const jamesAliases = await fetchJamesAliases(mail); if (jamesAliases === null) { stats.errors++; return; } // Case-insensitive comparison to avoid spurious add/delete churn const ldapSet = new Set(ldapAliases.map(a => a.toLowerCase())); const jamesSet = new Set(jamesAliases.map(a => a.toLowerCase())); const toAdd = ldapAliases.filter(a => !jamesSet.has(a.toLowerCase())); const toDelete = noAliasDelete ? [] : jamesAliases.filter(a => !ldapSet.has(a.toLowerCase())); if (toAdd.length === 0 && toDelete.length === 0) { if (!quiet && (ldapAliases.length > 0 || jamesAliases.length > 0)) { dm.logger.info(`${mail}: aliases OK (${ldapAliases.length})`); } return; } for (const alias of toAdd) { dm.logger.warn(`${mail}: missing alias in James - ${alias}`); if (dryRun) { dm.logger.info(` Would add alias ${alias}`); stats.aliasesAdded++; continue; } const res = await fetch( `${jamesUrl}/address/aliases/${mail}/sources/${alias}`, { method: 'PUT', headers } ); // 409 = alias already exists, treat as success (mirrors plugin behaviour) if (res.ok || res.status === 409) { if (!quiet) { dm.logger.info(` Added alias ${alias}`); } stats.aliasesAdded++; } else { dm.logger.error( ` Failed to add alias ${alias}: ${res.status} ${res.statusText}` ); stats.errors++; } } for (const alias of toDelete) { dm.logger.warn(`${mail}: stale alias in James - ${alias}`); if (dryRun) { dm.logger.info(` Would delete alias ${alias}`); stats.aliasesDeleted++; continue; } const res = await fetch( `${jamesUrl}/address/aliases/${mail}/sources/${alias}`, { method: 'DELETE', headers } ); if (res.ok) { if (!quiet) { dm.logger.info(` Deleted alias ${alias}`); } stats.aliasesDeleted++; } else { dm.logger.error( ` Failed to delete alias ${alias}: ${res.status} ${res.statusText}` ); stats.errors++; } } } try { // Search for every user that has a mail (paginated for large directories). // We enumerate all mailboxes, not only those carrying a quota/alias, so // that a user whose alias attribute was cleared still gets its stale James // aliases removed (LDAP is the source of truth). Destinations whose mail is // absent from LDAP are intentionally left alone: James refuses delivery for // an alias pointing to a non-existent mailbox, so they are harmless. const resultGenerator = await dm.ldap.search({ paged: true, filter: `(${mailAttr}=*)`, attributes: [mailAttr, quotaAttr, aliasAttr, 'dn'], }); // Process results page by page for await (const result of resultGenerator) { if (!result.searchEntries || result.searchEntries.length === 0) { continue; } if (!quiet) { dm.logger.info( `Processing batch of ${result.searchEntries.length} users...` ); } for (const entry of result.searchEntries) { const dn = entry.dn; const mail = Array.isArray(entry[mailAttr]) ? entry[mailAttr][0] : entry[mailAttr]; if (!mail) { dm.logger.warn(`Skipping ${dn}: invalid mail`); continue; } stats.checked++; try { // Reconcile quota only when the user has a quota attribute if (entry[quotaAttr] !== undefined) { const ldapQuota = Array.isArray(entry[quotaAttr]) ? Number(entry[quotaAttr][0]) : Number(entry[quotaAttr]); await syncQuota(dn, mail, ldapQuota); } // Reconcile aliases (catch-up). Always run so that stale aliases // are removed even when the LDAP attribute has been cleared. await syncAliases(dn, mail, entry[aliasAttr]); } catch (err) { dm.logger.error(`Error processing ${mail}: ${errorMessage(err)}`); stats.errors++; } } } dm.logger.info('\n' + '='.repeat(60)); dm.logger.info('Synchronization summary:'); dm.logger.info(` Users checked: ${stats.checked}`); dm.logger.info( ` Quotas ${dryRun ? 'needing sync' : 'synced'}: ${stats.quotaSynced}` ); dm.logger.info( ` Aliases ${dryRun ? 'to add' : 'added'}: ${stats.aliasesAdded}` ); dm.logger.info( ` Aliases ${dryRun ? 'to delete' : 'deleted'}: ${stats.aliasesDeleted}` ); dm.logger.info(` Errors: ${stats.errors}`); dm.logger.info('='.repeat(60)); } catch (err) { dm.logger.error('Error during synchronization:', err); process.exit(1); } finally { await dm.ldap.unbind(); } } // Run the sync syncJames().catch(err => { console.error('Fatal error:', err); process.exit(1); }); linagora-ldap-rest-a4bb78e/docker-compose.swarm.example.yml000066400000000000000000000036031527331160600241220ustar00rootroot00000000000000version: '3.8' services: mini-dm: image: mini-dm:latest deploy: replicas: 2 update_config: parallelism: 1 delay: 10s order: start-first restart_policy: condition: on-failure delay: 5s max_attempts: 3 placement: max_replicas_per_node: 1 networks: - mini-dm-network ports: - '8081:8081' environment: # LDAP Configuration - Pointant vers les 2 VM DM_LDAP_URL: 'ldap://192.168.1.10:389,ldap://192.168.1.11:389' DM_LDAP_BASE: 'dc=example,dc=com' DM_LDAP_DN: 'cn=admin,dc=example,dc=com' DM_LDAP_PWD: 'changeme' DM_LDAP_USER_ATTRIBUTE: 'uid' DM_MAIL_DOMAIN: 'example.com' # Caching LDAP. DM_LDAP_CACHE_TTL is the lifetime in seconds of the # base-scope search cache; 0 turns it off, and off is the default. # Leave it off when anything else writes this directory — another # replica of this service included: each one caches on its own, and # only its own writes drop what it cached. DM_LDAP_CACHE_MAX: '1000' DM_LDAP_CACHE_TTL: '0' DM_LDAP_POOL_SIZE: '5' # Application DM_PORT: '8081' DM_LOG_LEVEL: 'info' DM_PLUGINS: 'core/static,core/helloworld' DM_API_PREFIX: '/api' # Optional: James integration # DM_JAMES_WEBADMIN_URL: "http://james:8000" # DM_JAMES_WEBADMIN_TOKEN: "secret" # Optional: Calendar integration # DM_CALENDAR_WEBADMIN_URL: "http://calendar:8080" # DM_CALENDAR_WEBADMIN_TOKEN: "secret" healthcheck: test: [ 'CMD', 'wget', '--quiet', '--tries=1', '--spider', 'http://localhost:8081/api/health', ] interval: 30s timeout: 10s retries: 3 start_period: 40s networks: mini-dm-network: driver: overlay attachable: true linagora-ldap-rest-a4bb78e/docs/000077500000000000000000000000001527331160600166315ustar00rootroot00000000000000linagora-ldap-rest-a4bb78e/docs/README.md000066400000000000000000000106001527331160600201050ustar00rootroot00000000000000# LDAP-Rest Documentation Complete documentation for LDAP-Rest, organized by use case. ## Documentation Categories ### [Usage](usage/README.md) Installation, configuration and plugin usage. - **[Getting Started](usage/README.md)** - Quick start guide - **[Configuration](usage/configuration.md)** - CLI options and environment variables - **[Upgrading](usage/upgrading.md)** - What to check before deploying a new version - **[Directory audit](usage/directory-audit.md)** - What a schema would refuse in an existing directory - **[Plugins](usage/plugins/README.md)** - Documentation for all available plugins ### [Plugin Development](plugin-development/README.md) Create and extend LDAP-Rest with custom plugins. - **[Development Guide](plugin-development/README.md)** - Architecture and plugin creation - **[Hooks](plugin-development/hooks.md)** - LDAP hooks system - **[Services](plugin-development/services.md)** - Typed interfaces between plugins - **[Routes](plugin-development/routes.md)** - Declaring routes and their input schemas - **[Testing](plugin-development/testing.md)** - Testing with embedded LDAP - **[Dependencies](plugin-development/dependencies.md)** - Plugin dependencies matrix ### [Client Application Development](client-development/README.md) Integrate LDAP-Rest into your web applications. - **[REST API](client-development/api/rest-api.md)** - Complete API reference - **[Browser Libraries](client-development/browser/libraries.md)** - Web components (LdapTreeViewer, LdapUserEditor, etc.) - **[JSON Schemas](client-development/schemas/README.md)** - Schema structure and validation - **[Examples](client-development/examples/README.md)** - React, Vue.js, Vanilla JS integration ### [Architecture](architecture/README.md) How the server is put together. - **[Overview](architecture/README.md)** - Module map, life of a request, life of a write - **[Decision records](architecture/adr/)** - Why the code is the way it is ### [Development](development/contracts.md) Work on ldap-rest itself. - **[Published contracts](development/contracts.md)** - What breaks a contract, the snapshots that detect it and the deprecation window. - **[Modularization plan](development/modularization-plan.md)** - How the `@ldap-rest/*` split and the remaining code-quality issues are sequenced. ## API stability From 1.0.0, ldap-rest follows semantic versioning. These are its public API, which a minor or a patch release does not break: - **Configuration**: the options and environment variables of [Configuration](usage/configuration.md) and of each plugin's page. - **Routes**: the paths, bodies and answers of the [REST API](client-development/api/rest-api.md) and the plugin pages, their statuses and their error `code`s. - **Plugin API**: what `ldap-rest/plugin` exports — `DmPlugin` and its roles (`auth`, `authz`, …), `serviceToken`, `ServiceToken` and `ServiceProvider`, `asyncHandler`, the DN and filter escaping helpers and the error classes —, the `DM` type of `ldap-rest` and the `Hooks` of `ldap-rest/hooks`, whose arguments are those typed in `src/hooks.ts` ([hooks](plugin-development/hooks.md)), and the directory operations of [the development guide](plugin-development/README.md): `ldap.search`, `add`, `modify`, `rename`, `delete`, `ldap.forRequest`, `ldap.system`, `ldap.authorize`, `ldap.readableBy`, `ldap.canonicalAttributes`, `ldap.canonicalChanges` and `ldap.knowAttributeNames`. - **Schemas**: the JSON schema format and the shipped schemas' attributes. Everything else — other exports, internal helpers, log messages — may change in any release. The options and the REST API are checked mechanically: committed snapshots are compared with the base branch of each pull request, and a non-additive change needs the `breaking` label. The plugin types are still reviewed by hand. See [Published contracts](development/contracts.md). ## Presentations Interactive presentations (using [presenterm](https://github.com/mfontanini/presenterm)): - **[English](presentations/en.md)** - `presenterm docs/presentations/en.md` - **[French](presentations/fr.md)** - `presenterm docs/presentations/fr.md` ## Support - **Issues**: https://github.com/linagora/ldap-rest/issues - **Documentation**: https://github.com/linagora/ldap-rest/tree/master/docs ## License [![Powered by LINAGORA](./linagora.png)](https://linagora.com) License: [AGPL-3.0](../LICENSE), copyright 2025-present LINAGORA. linagora-ldap-rest-a4bb78e/docs/architecture/000077500000000000000000000000001527331160600213135ustar00rootroot00000000000000linagora-ldap-rest-a4bb78e/docs/architecture/README.md000066400000000000000000000302041527331160600225710ustar00rootroot00000000000000# Architecture How the server is put together, and the two paths that carry most of the work. Every design decision the code already embodies is recorded once, in [`adr/`](adr/); this page is the map and the tour. The plan that will split the repository into packages is [Modularization plan](../development/modularization-plan.md). That plan is a roadmap; this page describes the code as it is. ## Module map | Path | Role | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `src/bin/index.ts` | `DM`: the Express app, plugin loading, the authentication dispatcher, `ready` and `stop`, `withConfig` | | `src/config/` | Command-line and environment parsing (`args.ts`), option specs (`options.ts`), the entity-schema model (`schema.ts`; the directory's own schema is `src/lib/ldap/schema.ts`) | | `src/abstract/` | `DmPlugin` base class; `LdapFlat`, the entity CRUD built on it; `TwakePlugin` | | `src/hooks.ts` | The hook type map: one entry per hook, with its arguments and its answer | | `src/lib/` | Core services: `ldapActions` and `lib/ldap/` (pool, search cache, schema), `lib/auth/`, `lib/authz/`, `lib/services/`, `lib/storage/`, `hookBus`, `requestContext`, errors, routes | | `src/plugins/auth/` | Authentication plugins, authorization plugins, and the guards that sit before them | | `src/plugins/ldap/` | Directory plugins: groups, organizations, entities, change detection, trash, password policy | | `src/plugins/scim/` | The SCIM 2.0 API | | `src/plugins/storage/` | Storage backends (`file`, `ldap`, `postgres`, `valkey`), each a `Store` | | `src/plugins/twake/` | Twake integrations: mail, calendar, spaces, drives, tombstones, lifecycle events | | `src/plugins/*.ts` | Standalone plugins: `storage` (`core/storage`, over the backends above), `static`, `weblogs`, `configApi`, `metrics`, `bcl`, and `rabbitmq` (a shared broker connection, not a store) | | `src/plugins/demo/` | Example plugins | | `src/browser/` | Browser libraries shipped with the package | | `src/logger/` | Log formatting and levels | | `src/types/` | Shared TypeScript declarations | The plugin families depend on `src/lib/` and on each other as documented in [dependencies](../plugin-development/dependencies.md). The dependency rules that the modularization will enforce are in the [plan](../development/modularization-plan.md#target-architecture). ## Life of a request ```mermaid flowchart TD A[HTTP request] --> B[request id and log context] B --> C[body parsers] C --> D[protect plugins: rate limit, CrowdSec, trusted proxy] D --> E[authentication dispatcher] E --> F{most specific prefix} F -->|one authenticator| G[vouched] F -->|several on the same prefix| H[all of them, an AND] H --> G G --> I[runForRequest] I --> J[route: checked params, query, body] J --> K[ldap.forRequest or ldap.system] K --> L[request hooks, authorization first] L --> M[(directory)] M --> N[done hooks of a write, then end hooks] N --> O[onLdap* notifications] J --> P[error middleware] ``` 1. `DM` mounts, in this order: the request id and log context (`runWithLogContext`), the media-type guard, the parsers a plugin registered for its own prefix, the global JSON and form parsers (they keep the raw bytes: HMAC authentication hashes them as the client signed them), the health probes, the protect and logging plugins, the authentication dispatcher, the plugin routes, and the error middleware, kept last. 2. The dispatcher picks the authentication whose path prefix is the most specific. Several plugins sharing that winning prefix all run, in registration order, and every one must let the request through: two credentials on one branch are an AND. A path nobody claims is guarded by the unscoped authenticators (no prefix), if any are loaded; with none, the request passes through unauthenticated, and `warnUnauthenticatedRoutes` names those routes at startup. 3. A plugin's `afterChain` runs after the whole selected chain and before any route: it is the only place where a verdict can depend on several authenticators without registration order deciding the answer. 4. `runForRequest(req, next)` wraps everything downstream of a request an authenticator let through — not the pass-through path, which has no caller. A directory operation made there, by whatever plugin, is judged as that caller even if the request object was not handed down. Hooks are the exception: they run as the server (see below), and a hook acting as the caller names the request it received. `ldap.system` is the explicit escape anywhere else. 5. A route declares its input schemas with `defineRoute`; the handler receives checked `params`, `query` and `body`, and reaches the directory through `ldap.forRequest(req)`. Work the server does for itself goes through `ldap.system`. 6. `ldapActions` raises the request hooks (authorization plugins first), makes the operation, then raises the "done" and "end" hooks of a write. The next section follows one. The decisions behind these steps: [authentication dispatch](adr/0002-authentication-dispatch.md), [authorization composition](adr/0003-authorization-composition.md), [fail-closed request context](adr/0004-fail-closed-request-context.md). ## Life of a write ```mermaid sequenceDiagram participant R as route participant L as ldapActions participant H as plugins participant D as directory R->>L: add / modify / rename / delete L->>H: request hook, chained (authorization first) H-->>L: value, changed or refused L->>L: drop cached reads the write changes L->>D: LDAP add / modify / modifyDN / delete D-->>L: result L->>L: drop them again L->>H: done hooks, notified L->>H: end hooks, once the done hooks returned H->>H: onLdapEntryChange, onLdapChange, onLdapMailChange… ``` - **Before the write.** A request hook is chained: each subscriber gets what the previous one answered, and a refusal thrown by any of them stops the operation. The request hooks of authorization plugins run before every other subscriber's, so a caller who may not make the operation is refused before a plugin judges its content. [trash](adr/0007-trash-and-tombstone.md) and [tombstone](adr/0007-trash-and-tombstone.md) use this point to replace a delete. - **The write.** `ldapActions` drops the cached reads of what it is about to change before the directory call and again once it returned, so a read landing during the round trip cannot serve what the write replaced. [Search cache](adr/0006-search-cache.md). - **After the write.** `ldapadddone`, `ldapmodifydone`, `ldapdeletedone` and `ldaprenamedone` are notified, not awaited. `ldapaddafter` is awaited within the request, for what must follow an add, such as provisioning the account the entry describes. Once the done subscribers returned, the matching `ldapmodifyend`, `ldapdeleteend` or `ldaprenameend` fires, whether the write landed, was refused or was taken out of the request. - **Derived notifications.** `core/ldap/onChange` reads the entry before and after the write and raises `onLdapEntryChange`, then the attribute-specific ones (`onLdapMailChange`, `onLdapQuotaChange`, …). A delete fires `onLdapEntryChange`, `onLdapChange` and `onLdapMailChange` only. - **Contract.** [Hook contract](adr/0005-hook-contract.md), and the reference of every hook in [hooks](../plugin-development/hooks.md). Subscribers run as the server, outside the request that raised the hook; one that acts as the caller names the request in its arguments. ## Startup The configuration is parsed from the command line and `DM_*` variables, then `DM` loads the plugins: `src/plugins/priority.json` first, one at a time in its order, then the rest in parallel. Once every load settled, the server checks the authorization composition, learns the directory's attribute types when an authorization plugin is loaded, reports unknown options and runs `afterLoad`. `DM.ready` resolves then, and rejects if a plugin failed to load; the CLI opens the port after it. An embedding host may register plugins after `ready`, and each registration is checked against the composition too. `warnUnauthenticatedRoutes` stays silent when no authentication plugin is loaded; the enforcement check says once whether that is an open server or a configuration to refuse. [Plugin load order](adr/0001-plugin-load-order.md), [server views](adr/0008-withconfig-views.md). ## Decision records | Record | Decision | | ----------------------------------------------- | --------------------------------------------------------------------------------------- | | [0001](adr/0001-plugin-load-order.md) | Plugins load priority-first, then in parallel; dependencies load on demand | | [0002](adr/0002-authentication-dispatch.md) | The most specific authentication prefix wins; several on one prefix are an AND | | [0003](adr/0003-authorization-composition.md) | Two authorization models judging the same requests are refused unless made deliberate | | [0004](adr/0004-fail-closed-request-context.md) | Directory operations inherit the request being served; `ldap.system` is the escape | | [0005](adr/0005-hook-contract.md) | Chained hooks transform and refuse; notified hooks observe, outside the request | | [0006](adr/0006-search-cache.md) | The search cache is off by default and only the process's own writes invalidate it | | [0007](adr/0007-trash-and-tombstone.md) | A delete becomes a move to trash or a tombstone, never a loss | | [0008](adr/0008-withconfig-views.md) | A plugin with overrides sees a server view that shares everything but the configuration | ## Checking the comments `npm run check:comments` (`scripts/check-comments.sh`) refuses new comments in `src/` that cite an issue number, narrate history ("used to", "since 1.2") or otherwise carry rationale. The rationale belongs in a decision record; the comment points at it. The accepted set is `scripts/comment-baseline.txt`, and it may only shrink: removing a comment is fine, adding one fails the check. `sh scripts/check-comments.sh --update` rewrites the baseline from the current tree when a change removes one. linagora-ldap-rest-a4bb78e/docs/architecture/adr/000077500000000000000000000000001527331160600220615ustar00rootroot00000000000000linagora-ldap-rest-a4bb78e/docs/architecture/adr/0001-plugin-load-order.md000066400000000000000000000050101527331160600263010ustar00rootroot00000000000000# 0001 — Plugin load order ## Context Plugins are listed with `--plugin`, in the order the administrator writes them, and a plugin may declare `dependencies` naming modules that must be registered before it. Some plugins cannot simply be imported in parallel with the rest: a guard whose Express middleware must sit ahead of the routes it protects, an access log that must see every request, an authenticator or URL gate other plugins must find registered when they register. Import order in the parallel batch is the order the imports happen to complete, which is not a configuration. ## Decision `src/plugins/priority.json` lists the modules loaded first. That pass is sequential, in the file's order, and every instance of a priority module is loaded there, in configuration order — matching is on the module, not on the whole `--plugin` entry, so a named instance (`module:name:{…}`) keeps its rank. The remaining entries load in parallel. A declared dependency is loaded on demand when the plugin that needs it registers. A module requested twice under the same name without overrides is loaded once; a second instance needs its own name. Two plugins with the same instance name are refused, and the second is dropped with a warning. `configApi` loads last. The list holds four kinds of plugin: the guards whose middleware must run before the routes (`trustedProxy`, `crowdsec`, `rateLimit`), the access log that must see every request (`weblogs`), the authenticators the dispatcher and the authorization plugins naming them have to find registered (`token`, `fake`, `llng`, `openidconnect`), and the URL gate whose rules must exist before the routes they judge (`authzPerRoute`). `core/storage` is there because others must find it registered when they register. Hooks run in load order, except the request hooks of authorization plugins, which run before every other subscriber's. ## Consequences - A plugin whose middleware must precede routes has to be in `priority.json`; being written first in the configuration is not enough once the parallel batch starts. - The relative order of two plugins that are neither priority nor linked by a dependency is not defined, and no plugin may rely on it. - Adding a module to the priority list changes the order for every deployment, so the list stays short. - An instance named with overrides loads at the rank of its module but keeps its own configuration through a [server view](0008-withconfig-views.md). See [dependencies](../../plugin-development/dependencies.md) and `src/bin/index.ts`. linagora-ldap-rest-a4bb78e/docs/architecture/adr/0002-authentication-dispatch.md000066400000000000000000000041171527331160600276010ustar00rootroot00000000000000# 0002 — Authentication dispatch ## Context One server can host populations that authenticate differently: administrators behind a token, users behind OpenID Connect, machine clients behind HMAC. An authentication plugin declares the path prefixes it claims. A request must run the authentication that owns its path, and only that one: running two unrelated authenticators on one request composes as an AND that no credential can satisfy. A request no plugin claims must not silently become open without saying so. ## Decision `DM` mounts one dispatcher layer, once, at the boundary between the protection plugins and the routes. For each request it selects the plugins whose prefix is the longest match. Several plugins sharing that winning prefix all run, in registration order, and every one must let the request through. When no plugin claims the path, the unscoped plugins (no prefix) run; when there is none, the request passes through unauthenticated, outside any request context. A plugin's `afterChain` runs after the whole chain and before the route. `runForRequest` then wraps everything downstream of a request an authenticator let through, and the dispatcher records which plugins vouched for the request in `req.authenticators`. `warnUnauthenticatedRoutes` lists, at startup, the routes that no authentication plugin covers. An unscoped authenticator guards everything and silences the warning. ## Consequences - Two authenticators on one prefix require both credentials. That is legitimate — a session and a token — but it fails closed when accidental. - Scoping authentication to a prefix opens every route outside it; the startup warning is the only signal, and it names the routes. - `afterChain` is the only hook that sees all the selected authenticators' verdicts, so an answer that depends on several of them belongs there, not in a plugin's own middleware. - An authorization plugin can tell whose request it judges from `req.authenticators`; see [authorization composition](0003-authorization-composition.md). See `src/bin/index.ts` (`dispatchAuth`) and `src/lib/auth/base.ts`. linagora-ldap-rest-a4bb78e/docs/architecture/adr/0003-authorization-composition.md000066400000000000000000000056611527331160600302340ustar00rootroot00000000000000# 0003 — Authorization composition ## Context Every plugin that judges directory operations registers the same request hooks (`ldapsearchrequest`, `ldapaddrequest`, `ldapmodifyrequest`, `ldapdeleterequest`, `ldaprenamerequest`). Chained hooks run their subscribers in order and stop at the first refusal, so two loaded authorization plugins compose as an AND: the first to refuse answers. That rule is sound when it is meant. It is a refusal nobody can explain when it is not: `authzPerBranch` next to `authzDynamic` judges a machine token by a branch configuration written for administrators and refuses a write the token's ACLs grant. ## Decision A plugin with the `authz` role that registers LDAP hooks is a _judge_. It judges the requests authenticated by the plugins named in `--authz-for`, or everyone by default. A plugin that authenticates as well judges only its own requests: its hooks read the credential it verified. `assertAuthzComposition` runs once every configured plugin is loaded, and again for each plugin registered later. It refuses: - an `authz_for` naming a plugin that is not a loaded authenticator — the plugin would judge nobody. A server-wide `--authz-for` that no loaded plugin reads only warns, as does an `authz_for` on a plugin that also authenticates; - two judges whose populations overlap on an authenticator — the same one, or one of them judging everyone — unless `--authz-combine` makes the AND deliberate. Two judges whose authenticators can run on the same prefix, without sharing an authenticator name, are reported, not refused: asking for two credentials is already a decision, and a request carrying both is judged by both. A judge on `authzPerRoute` gates URLs and registers no LDAP hook, so it is not part of this AND — route plus branch is a documented combination. The request hooks of judges run before every other plugin's request hook and `ldapsearchfilter` keeps the load order. All the LDAP hooks compose as an AND, including the filters: two filters narrow the result twice. ## Consequences - A deployment with two branch models on the same population no longer starts unless it says `--authz-combine`; the error names both plugins. - Naming authenticators in `--authz-for` requires stable instance names, so the configuration that scopes authentication and the one that scopes authorization must agree. - A plugin registered by an embedding host is checked before its routes and hooks exist, so a refused plugin leaves nothing behind. - Judges run first, in their load order. The chain stops at the first refusal, so a judge after a refusing one does not run; the refusal is the answer. No judge is skipped while the request is still allowed. - A judge that also authenticates judges only its own requests; a request it did not vouch for passes its hooks untouched, because the absence of its credential is not a refusal it makes. See `src/lib/authz/composition.ts` and `src/config/args.ts`. linagora-ldap-rest-a4bb78e/docs/architecture/adr/0004-fail-closed-request-context.md000066400000000000000000000043021527331160600303150ustar00rootroot00000000000000# 0004 — Fail-closed request context ## Context An authorization judge decides from the request (`req.user`, its authenticators, its path). But a directory operation is often made deep in a plugin that never received the request: a provisioning step incremented from a hook, a consistency repair, a route helper that searches before it writes. Passing the request down every call is easy to forget, and a forgotten request used to mean an operation nobody judged — a hole that is invisible until someone writes through it. ## Decision The authentication dispatcher runs everything downstream of an authenticated request inside `runForRequest(req, next)`, an `AsyncLocalStorage`. Every directory operation defaults to `currentRequest()`, so a plugin that forgets the request still gets the caller's identity and is judged as them. Operations the server makes for itself run inside `outsideRequest()`, which `ldap.system` uses: it is the explicit escape from judgement, not a default. The log context is a second `AsyncLocalStorage`, kept apart: a line written as the server, outside the request, still names the request that made the server act. `outsideAnyRequest()` leaves both, for work that outlives a request (a pooled connection, a process-wide client). Hooks are the one place that deliberately leaves the context: the bus runs every subscriber inside `outsideRequest()`, so a hook's own writes are the server's and not the caller's. A hook that judges or acts for the caller has the request in its arguments and names it. ## Consequences - A plugin that means to act as the server must say so with `ldap.system`; forgetting the request fails closed instead of opening a hole. - A test that calls a plugin's directory operations directly must wrap them in `runForRequest` to reproduce what the server does, or get the unjudged behaviour of `ldap.system`. - Asynchronous work started inside the request inherits it, so a background task that should not act as the caller has to leave the context first. - Errors and logging inherit the request id, which makes a write traceable to the request that caused it. See `src/lib/requestContext.ts` and `src/lib/ldapActions.ts` (`forRequest`, `system`, `currentRequest`). linagora-ldap-rest-a4bb78e/docs/architecture/adr/0005-hook-contract.md000066400000000000000000000051731527331160600255460ustar00rootroot00000000000000# 0005 — Hook contract ## Context Plugins have to talk to each other without importing each other. A refusal has to be able to stop a write; a notification has to be able to run after the write, without holding the request open or turning a subscriber's failure into the caller's failure. One kind of hook cannot do both. ## Decision There are two kinds, told apart by what a subscriber answers, and a third notification mode: - **Chained** (`chain(name, value)`). The value goes from one subscriber to the next; the last answer is the result. A subscriber that throws stops the chain and the error travels to the caller: a refusal is a refusal. Several arguments are passed as one tuple. - **Notified** (`notify(name, ...args)`). Subscribers run one after the other, outside the request that caused the write, and are not awaited. A failing subscriber is logged with its plugin and hook, counted in the hook failure metrics, and the next one still runs. - **Awaited notification** (`notifyAndWait`). As `notify`, but the caller waits for the subscribers. A failure is still logged and swallowed. - **Strict notification** (`notifyStrict`) for the rare failure that is part of the answer. Every subscriber runs; the first failure is raised once they all returned. Every hook is declared in `src/hooks.ts` with its arguments and its answer. A plugin subscribing to an undeclared hook does not compile, and `npm run check:hooks` keeps the documentation in step. Notified hooks take their arguments one by one; chained hooks take one value, a tuple when there are several. The request hooks of authorization plugins run first, in load order. The other hooks run in plugin load order. ## Consequences - A notified hook cannot refuse a write; a plugin that needs to refuse subscribes to the request hook instead. - A chained subscriber must answer the value it was given, changed or not: an `undefined` answer becomes the next subscriber's input. - A subscriber of a notified hook written `([dn, entry], ctx) => …` compiles without annotation — its parameter takes the declared type, a `string`, and a string can be destructured — and then reads the first character of the DN. The documentation repeats the shape of each hook for this reason. - A "done" hook fires once the operation is over, including a modify that had nothing to apply; a write refused by a request hook or by the directory never reaches it. The "end" hook fires all the same, once the done subscribers returned. Keying state by the operation number is what tells two writes of one DN apart. See [hooks](../../plugin-development/hooks.md) and `src/lib/hookBus.ts`. linagora-ldap-rest-a4bb78e/docs/architecture/adr/0006-search-cache.md000066400000000000000000000043511527331160600252770ustar00rootroot00000000000000# 0006 — Search cache ## Context Reading one entry by DN is the most frequent operation a route makes: before a write, before an authorization decision, to render a page. A directory round trip per read is measurable, and a cache in front of it is easy to add and hard to keep honest. Serving a stale entry after a write is worse than the round trip. ## Decision `SearchCache` caches non-paged, base-scope searches — single-entry lookups by DN, with whatever filter — keyed by base, scope, filter, attributes, options and values mode, in an LRU bounded by `--ldap-cache-max`. It is **off by default** (`--ldap-cache-ttl=0`): no deployment has run with it on, and turning it on for everyone would be a silent behaviour change. `ldapActions` drops, around every write it makes through this process: - the reads of the entry written and of its subtree (a rename or a move re-parents a whole branch); - the `memberOf` of the members a group change adds or removes; - the reads of entries naming the entry renamed or deleted, because the directory's `memberof` and `refint` overlays rewrite them. Entries are dropped before the directory call as well as after it, and a generation counter stops a read that started before the write from storing its stale answer afterwards. DNs are compared the way the directory does: case, spacing, escapes, aliases and multi-valued RDN order do not matter. Writes made elsewhere — another replica, an LSC synchronisation, a hand-run `ldapmodify` — do not invalidate anything. Each process caches on its own. ## Consequences - Enable the cache only when this service is the only writer, or when serving an entry up to `--ldap-cache-ttl` seconds old is acceptable. - A subtree or one-level read, or a paged search, is never cached; the cache does not accelerate listings. A base-scope read with a filter is cached, keyed by that filter. - The cache does not lift a miss: a read of an absent DN throws and caches nothing. - The invalidation rules follow the overlays this project documents (`memberof`, `refint`); a deployment with an overlay that rewrites other entries must accept a miss or leave the cache off. See [configuration](../../usage/configuration.md#the-search-cache) and `src/lib/ldap/searchCache.ts`. linagora-ldap-rest-a4bb78e/docs/architecture/adr/0007-trash-and-tombstone.md000066400000000000000000000070601527331160600266630ustar00rootroot00000000000000# 0007 — Trash and tombstone ## Context Deleting an entry destroys the identifiers it held: a mail address, a login, a DN. An administrator who deletes the wrong account cannot undo it, and a deleted account's address can be taken by a new one immediately — which, for a mail system, means the new account receives the old one's mail. The directory has no undelete of its own. ## Decision Two plugins answer a delete at the `ldapdeleterequest` hook, and a deployment picks one per branch: - **`core/ldap/trash`** moves the entry, by an atomic `modifyDN`, to a trash branch under `--trash-base`, overwriting an earlier entry with the same RDN there; unless `--trash-add-metadata=false`, it then replaces `description` with the deletion date and the original DN, a failure there being logged and not fatal. The native delete is cancelled. `--trash-watched-bases` selects the branches; an empty list watches every branch. - **`core/twake/tombstone`** keeps the entry in place: one modify sets the deleted attribute and the lock, the deletion date and the reason when their attributes are configured, and removes the attributes the deployment names. The identifier is reserved until an operator erases the tombstone, once the deletion is old enough or immediately when forced. Erasing removes the entry and its group memberships and announces nothing. The tombstone moves itself last in the `ldapdeleterequest` and `ldapaddrequest` chains (`hookBus.toLast`), so every plugin that may refuse has judged. The trash does not: it runs in load order, after the authorization judges but possibly before another plugin that can refuse a delete (`organizations`, `enterpriseRules`, `james`). A plugin that refuses a delete and loads after the trash refuses one that has already been moved. The tombstone is written by the server, not as the caller: the caller's delete right is what was judged. SCIM treats a tombstone as gone: its reads carry a not-deleted filter, so reads answer 404 and lists omit it; a create with the same identity erases the tombstone and creates a new entry. A non-SCIM delete of an existing tombstone writes nothing and fires `twakedeletionreplay`, so lifecycle events can replay a lost deletion. A SCIM DELETE never gets there: its existence check hides the tombstone and answers 404. The server refuses to start when the tombstone is loaded beside the trash and any watched branch overlaps a `--twake-tombstone-dn` pattern: the trash would move the entry before the tombstone saw the delete, and no tombstone would be written. An empty `--trash-watched-bases` watches every branch, so it overlaps any pattern and refuses too. ## Consequences - A delete is no longer a delete. Code that lists a branch sees what the trash or the tombstone left there, filtered by the plugin that owns the branch. - Trash and tombstone cannot watch the same branch; the startup check names the overlap. - A deployment that loads the trash must load it before the plugins that can refuse a delete, or accept that a refusal can come after the entry moved. - A tombstone cannot be restored: clearing its deleted attribute by hand is not a supported operation. - Trash moves the entry, so its DN changes and references to it are not rewritten by the move beyond what the directory's `refint` overlay does. - Tombstones are erased by an explicit operator action, so a retention policy has to run it. See [tombstone](../../usage/plugins/integrations/tombstone.md), the plugin list in [plugins](../../usage/plugins/README.md), and `src/plugins/ldap/trash.ts`, `src/plugins/twake/tombstone.ts`. linagora-ldap-rest-a4bb78e/docs/architecture/adr/0008-withconfig-views.md000066400000000000000000000055751527331160600263000ustar00rootroot00000000000000# 0008 — Server views for plugins with overrides ## Context The same plugin module can be loaded several times with different settings: `--plugin 'core/auth/token:admins:{…}'` and `--plugin 'core/auth/token:users:{…}'`. The plugin constructor receives the server, and reads its options from `this.server.config` and `this.config`. Those instances must see their own configuration while acting on the one server: the same Express app, the same hooks, the same LDAP connection and the same plugin registry. A second `DM` would be a second server. ## Decision `DM.withConfig(config)` returns a view of the server built with `Object.create(Object.getPrototypeOf(this))` and `Object.assign(…, this, { config })`. The own properties that hold objects — the app, the hooks, the LDAP actions, the plugin registry, the authenticators — are shared by reference. The configuration is `{ ...this.config, ...overrides }`, a shallow copy: overridden top-level keys are the plugin's alone, nested objects and arrays are still shared with the server. The loader constructs a plugin with overrides on such a view. A primitive field would be copied by value into every view, so state the views must share lives in an object: the operation counter (`_operations`, read through the `operationSequence` accessor) and the composition flag (`_compositionState`), as the dispatcher's own state does (`_authState`). A view created while the plugins load therefore sees the composition once the server has checked it. The copy is built on the prototype rather than by spreading `{...this}`, which copies own properties and leaves the methods behind: a plugin calling `this.server.anything()` would throw, and only when configured with overrides. ## Consequences - A plugin loaded with overrides cannot change the server's configuration for anyone else; mutating `this.config` affects its view alone, at the top level. - Primitive fields are copied by value, not shared: what must be shared is held in an object, as the operation counter and the composition flag are. A plugin registered after `ready` through a view created while loading is therefore checked against the composition like any other (before [#625] and [#626], the counter and the flag were primitives, and neither held). - An own property added to the server after the view was created is not visible to that view. State that must be shared has to live in an object that already exists when the view is made. - A plugin that closes over `this.server` keeps the view for its lifetime, which is the point: its options stay its own. - Tests may call `withConfig` to exercise option-dependent behaviour without building a second server. See `src/bin/index.ts` (`withConfig`, `importPlugin`) and [plugin options](../../plugin-development/options.md). [#625]: https://github.com/linagora/ldap-rest/issues/625 [#626]: https://github.com/linagora/ldap-rest/issues/626 linagora-ldap-rest-a4bb78e/docs/client-development/000077500000000000000000000000001527331160600224275ustar00rootroot00000000000000linagora-ldap-rest-a4bb78e/docs/client-development/README.md000066400000000000000000000212351527331160600237110ustar00rootroot00000000000000# Developer Guide - LDAP-Rest Welcome to the LDAP-Rest Developer Guide! This guide helps you build web applications using LDAP-Rest's APIs and libraries. ## Quick Links ### 🚀 Getting Started - [Introduction & Architecture](#introduction) - [Quick Start Guide](#quick-start) ### 📚 Core Documentation - **[REST API Documentation](./api/rest-api.md)** - Complete API reference (Config, Users, Groups, Organizations) - **[Browser Libraries](./browser/libraries.md)** - LdapTreeViewer and LdapUserEditor - **[JSON Schemas](./schemas/README.md)** - Schema structure and validation - **[Integration Examples](./examples/README.md)** - React, Vue.js, Vanilla JavaScript examples - **[Plugin Development](../plugin-development/README.md)** - Create your own plugins ### 🔧 Reference - **[API Reference](./api/reference.md)** - Complete API reference with all endpoints - [Authentication](./api/rest-api.md#authentication) - OpenID, Token, LemonLDAP::NG - [Troubleshooting](./api/reference.md#troubleshooting) - Common issues and solutions --- ## Introduction LDAP-Rest is a lightweight LDAP directory manager that provides: - **Complete REST API** for managing LDAP users, groups, and organizations - **Ready-to-use browser libraries** in JavaScript/TypeScript - **Schema-driven architecture** to adapt to different directory types (Twake, Active Directory, standard LDAP) - **Dynamic configuration** exposed via API - **Plugin-based extensibility** for custom functionality - **Robust error handling** ensuring server stability even when plugins encounter errors ### Architecture Overview ``` ┌─────────────────────────────────────┐ │ Your Web Application │ │ │ │ ┌───────────────────────────────┐ │ │ │ Browser Libraries │ │ │ │ - LdapTreeViewer │ │ │ │ - LdapUserEditor │ │ │ └───────────────────────────────┘ │ │ ↓ HTTP │ └─────────────────────────────────────┘ ↓ ┌─────────────────────────────────────┐ │ LDAP-Rest Server │ │ │ │ ┌───────────────────────────────┐ │ │ │ REST API │ │ │ │ - /api/v1/config │ │ │ │ - /api/v1/ldap/users │ │ │ │ - /api/v1/ldap/groups │ │ │ │ - /api/v1/ldap/organizations │ │ │ └───────────────────────────────┘ │ │ ↓ │ │ ┌───────────────────────────────┐ │ │ │ Plugins │ │ │ │ - configApi │ │ │ │ - ldapFlatGeneric │ │ │ │ - ldapGroups │ │ │ │ - ldapOrganizations │ │ │ └───────────────────────────────┘ │ │ ↓ │ └─────────────────────────────────────┘ ↓ ┌─────────────────────────────────────┐ │ LDAP Directory Server │ │ (OpenLDAP, AD, 389 Directory...) │ └─────────────────────────────────────┘ ``` --- ## Quick Start ### 1. Start LDAP-Rest Server ```bash npx ldap-rest \ --ldap-base 'dc=example,dc=com' \ --ldap-url 'ldap://localhost:389' \ --ldap-dn 'cn=admin,dc=example,dc=com' \ --ldap-pwd 'admin' \ --plugin core/configApi \ --plugin core/ldap/flatGeneric \ --ldap-flat-schema ./static/schemas/standard/users.json \ --plugin core/static \ --static-path ./static ``` ### 2. Access the Configuration API ```javascript const config = await fetch('http://localhost:8081/api/v1/config').then(r => r.json() ); console.log(config); // { // "apiPrefix": "/api", // "ldapBase": "dc=example,dc=com", // "features": { ... } // } ``` ### 3. Use the Browser Libraries ```html
``` --- ## Documentation Structure ### API Documentation - **[REST API Guide](./api/rest-api.md)** - Configuration API - Organizations API - Users API - Groups API - Authentication - **[Complete API Reference](./api/reference.md)** - All endpoints with examples - Request/response formats - Error codes - LDAP modify format ### Browser Libraries - **[Browser Libraries Guide](./browser/libraries.md)** - LdapTreeViewer - Interactive LDAP tree - LdapUserEditor - Complete user editor - Configuration options - API methods - Customization ### Schemas - **[JSON Schemas Guide](./schemas/README.md)** - Schema structure - Entity metadata - Attribute types - Semantic roles - Validation rules - Predefined schemas ### Integration - **[Integration Examples](./examples/README.md)** - React application - Vue.js application - Vanilla JavaScript - Group management - Custom components ### Plugin Development - **[Plugin Development Guide](../plugin-development/README.md)** - Creating plugins - Custom configuration - Hooks system - LDAP operations - API endpoints - Testing --- ## Key Concepts ### Schema-Driven Architecture LDAP-Rest uses JSON schemas to define LDAP entity structure: ```json { "entity": { "name": "users", "mainAttribute": "uid", "objectClass": ["top", "person", "inetOrgPerson"], "base": "ou=users,{ldap_base}" }, "attributes": { "uid": { "type": "string", "required": true, "role": "identifier" }, "cn": { "type": "string", "required": true, "role": "displayName" } } } ``` See [JSON Schemas Guide](./schemas/README.md) for details. ### Dynamic Configuration The `/api/v1/config` endpoint exposes all available features, endpoints, and schemas. Your application discovers capabilities at runtime: ```javascript const config = await fetch('/api/v1/config').then(r => r.json()); // Find users endpoint const usersEndpoint = config.features.ldapFlatGeneric.flatResources.find( r => r.pluralName === 'users' )?.endpoints.list; // Use it const users = await fetch(usersEndpoint).then(r => r.json()); ``` See [REST API Guide](./api/rest-api.md) for details. ### Plugin-Based Extensibility Extend LDAP-Rest with custom plugins that: - Add REST API endpoints - Hook into LDAP operations - Integrate external systems - Implement custom logic See [Plugin Development Guide](../plugin-development/README.md) for details. --- ## Next Steps 1. **Learn the API** - Read the [REST API Guide](./api/rest-api.md) 2. **Try the Libraries** - Check the [Browser Libraries Guide](./browser/libraries.md) 3. **See Examples** - Explore [Integration Examples](./examples/README.md) 4. **Build a Plugin** - Follow the [Plugin Development Guide](../plugin-development/README.md) --- ## Resources - [GitHub Repository](https://github.com/linagora/ldap-rest) - [Contributing Guide](../../CONTRIBUTING.md) - [Plugin Development](../plugin-development/README.md) - [Hooks Reference](../plugin-development/hooks.md) --- ## Support - **Issues**: https://github.com/linagora/ldap-rest/issues - **Documentation**: https://github.com/linagora/ldap-rest/tree/master/docs --- ## License [![Powered by LINAGORA](./linagora.png)](https://linagora.com) [AGPL-3.0](../LICENSE) - Copyright 2025-present LINAGORA linagora-ldap-rest-a4bb78e/docs/client-development/api/000077500000000000000000000000001527331160600232005ustar00rootroot00000000000000linagora-ldap-rest-a4bb78e/docs/client-development/api/README.md000066400000000000000000000034151527331160600244620ustar00rootroot00000000000000# REST API LDAP-Rest REST API documentation. ## Overview The LDAP-Rest REST API exposes endpoints for managing LDAP entities. ## Documentation - **[rest-api.md](rest-api.md)** - Complete API usage guide - **[reference.md](reference.md)** - Technical reference (HTTP codes, headers, URL encoding) - **[openapi.md](openapi.md)** - OpenAPI specification ## Authentication All API requests require authentication. See the [authentication documentation](../../usage/plugins/auth/README.md). ### Token Example ```bash curl -H "Authorization: Bearer your-token" \ http://localhost:8081/api/v1/ldap/users ``` ## Main Endpoints ### Entities (ldapFlatGeneric) ``` GET /api/v1/ldap/{pluralName} # List GET /api/v1/ldap/{pluralName}/{id} # Get POST /api/v1/ldap/{pluralName} # Create PUT /api/v1/ldap/{pluralName}/{id} # Modify DELETE /api/v1/ldap/{pluralName}/{id} # Delete ``` ### Groups ``` GET /api/v1/ldap/groups GET /api/v1/ldap/groups/{cn} POST /api/v1/ldap/groups PUT /api/v1/ldap/groups/{cn} DELETE /api/v1/ldap/groups/{cn} ``` ### Organizations ``` GET /api/v1/ldap/organizations/top GET /api/v1/ldap/organizations/{dn}/subnodes POST /api/v1/ldap/organizations PUT /api/v1/ldap/organizations/{dn} DELETE /api/v1/ldap/organizations/{dn} ``` ### Configuration ``` GET /api/v1/config # Available capabilities and schemas ``` ## Response Codes | Code | Description | | ---- | ------------------------- | | 200 | Success | | 201 | Created | | 400 | Invalid request | | 401 | Not authenticated | | 403 | Access forbidden | | 404 | Not found | | 409 | Conflict (existing entry) | | 500 | Server error | linagora-ldap-rest-a4bb78e/docs/client-development/api/openapi.md000066400000000000000000000235331527331160600251630ustar00rootroot00000000000000# OpenAPI Documentation Generation LDAP-Rest includes an automatic OpenAPI 3.0 specification generator that analyzes TypeScript source code to generate API documentation without modifying production code. ## Features - **Zero runtime overhead**: Generator runs as a build tool, not in production - **TypeScript AST analysis**: Parses TypeScript directly to extract routes - **No code annotations required**: Works with existing code structure - **Automatic route discovery**: Finds all `app.get()`, `app.post()`, etc. calls - **Template variable resolution**: Converts `${this.config.api_prefix}` to `/api` - **Express to OpenAPI conversion**: Translates `:param` to `{param}` format - **Plugin categorization**: Groups routes by plugin with proper tags ## Quick Start ### Generate OpenAPI Specification ```bash npm run generate:openapi ``` This creates `openapi.json` in the project root containing the complete API specification for all loaded plugins. ### View the Specification You can use the generated `openapi.json` with various tools: **Swagger UI** (online viewer): ```bash # Serve the spec with a simple HTTP server npx http-server -p 8080 # Visit: https://editor.swagger.io/ # File → Import URL → http://localhost:8080/openapi.json ``` **Redoc** (documentation generator): ```bash npx @redocly/cli preview-docs openapi.json ``` **Postman**: Import `openapi.json` directly **VS Code**: Use "OpenAPI (Swagger) Editor" extension ## How It Works ### 1. TypeScript AST Parsing The generator uses the TypeScript Compiler API to parse all plugin files: ```typescript // Creates a TypeScript program const program = ts.createProgram(fileNames, options); const checker = program.getTypeChecker(); ``` ### 2. Plugin Discovery Searches for classes extending `DmPlugin`: ```typescript class LdapGroups extends DmPlugin { name = 'ldapGroups'; // Extracted as plugin name api(app: Express): void { // Routes extracted from here } } ``` ### 3. Route Extraction Finds all Express route definitions: ```typescript // Input (in plugin code): app.get(`${this.config.api_prefix}/v1/ldap/groups/:cn`, (req, res) => {...}); // Output (in openapi.json): { "/api/v1/ldap/groups/{cn}": { "get": { "summary": "Get groups", "tags": ["Groups"], "parameters": [ { "name": "cn", "in": "path", "required": true, "schema": { "type": "string" } } ] } } } ``` ### 4. Template Variable Resolution Replaces template variables with actual values: | Template Variable | Replacement | | ---------------------------- | ------------ | | `${this.config.api_prefix}` | `/api` | | `${apiPrefix}` | `/api` | | `${this.config.static_name}` | `static` | | `${resourceName}` | `{resource}` | ### 5. Special Handling **Multipart Form Data** (Bulk Import): ```typescript // Detected from path pattern if (pathTemplate.includes('bulk-import')) { requestBody.content = { 'multipart/form-data': { schema: { properties: { file: { type: 'string', format: 'binary' }, dryRun: { type: 'boolean' }, // ... }, }, }, }; } ``` **CSV Templates**: ```typescript // Detected from path pattern if (method === 'get' && pathTemplate.includes('template.csv')) { responses = { '200': { description: 'CSV template', content: { 'text/csv': { schema: { type: 'string' } }, }, }, }; } ``` ## Generated Structure The `openapi.json` file contains: ```json { "openapi": "3.0.0", "info": { "title": "LDAP-Rest API", "version": "1.0.0", "description": "RESTful API for LDAP management with LDAP-Rest" }, "servers": [ { "url": "http://localhost:8081", "description": "Development server" } ], "paths": { "/api/v1/ldap/groups": { ... }, "/api/v1/ldap/organizations": { ... } }, "tags": [ { "name": "Groups", "description": "Groups operations" }, { "name": "Organizations", "description": "Organizations operations" } ] } ``` ## Supported Plugins The generator automatically discovers routes from all plugins: | Plugin | Tag | Routes | | ------------------- | ------------- | ----------- | | `ldapGroups` | Groups | 8 endpoints | | `ldapOrganizations` | Organizations | 7 endpoints | | `ldapBulkImport` | Bulk Import | 2 endpoints | | `configApi` | Configuration | 1 endpoint | | `static` | Static Files | 2 endpoints | _Note: Number of routes may vary based on configuration and loaded plugins_ ## Customizing Summaries While the generator provides default summaries, you can add JSDoc comments to improve them: ```typescript /** * @openapi summary: List all groups with optional filtering * @openapi description: Returns all LDAP groups, optionally filtered by match query */ app.get(`${this.config.api_prefix}/v1/ldap/groups`, async (req, res) => { // ... }); ``` The generator will extract these annotations: - `@openapi summary:` → `summary` field - `@openapi description:` → `description` field ## Plugin Tag Mapping The generator maps plugin names to human-readable tags: ```typescript private pluginTags: Map = new Map([ ['ldapGroups', 'Groups'], ['ldapOrganizations', 'Organizations'], ['ldapFlatGeneric', 'Entities'], ['ldapBulkImport', 'Bulk Import'], ['james', 'Apache James Integration'], ['calendar', 'Twake Calendar'], ['configApi', 'Configuration'], ['static', 'Static Files'], ]); ``` To add mappings for custom plugins, edit `scripts/generate-openapi.ts`. ## Integration with CI/CD ### Verify Spec is Up-to-Date Add to your CI pipeline: ```bash # Generate fresh spec npm run generate:openapi # Check if it differs from committed version git diff --exit-code openapi.json ``` ### Generate on Pre-Commit Add to `.git/hooks/pre-commit`: ```bash #!/bin/sh npm run generate:openapi git add openapi.json ``` ### Publish to API Documentation Platform ```bash # After generation, upload to your docs platform npm run generate:openapi npx @redocly/cli push openapi.json ``` ## Limitations ### Current Limitations 1. **No type inference**: Schemas are generic `{ type: 'object' }` 2. **No JSDoc parsing**: Only basic `@openapi` annotations supported 3. **Static analysis only**: Cannot analyze dynamic routes 4. **No response schemas**: All responses use generic object type ### Why These Limitations? The generator uses **static analysis** to avoid runtime dependencies. This means: - ✅ Zero impact on production code - ✅ No decorators or annotations required - ✅ Fast generation - ❌ Limited type information ### Future Enhancements Planned improvements: - [ ] Extract TypeScript interfaces for request/response bodies - [ ] Parse JSDoc `@param` and `@returns` annotations - [ ] Generate schema definitions from TypeScript types - [ ] Support for custom response status codes - [ ] Query parameter detection from `req.query` ## Troubleshooting ### "tsconfig.json not found" The generator needs a valid TypeScript configuration: ```bash # Ensure tsconfig.json exists ls tsconfig.json ``` ### "Found 0 plugins" Check that: 1. Plugins extend `DmPlugin` 2. Plugins have an `api(app: Express)` method 3. TypeScript compilation succeeds: `npm run check:ts` ### Wrong paths generated Template variables not replaced correctly? Check: ```typescript // Supported: `${this.config.api_prefix}/v1/...``${apiPrefix}/v1/...``${this.config.static_name}/...` // Not supported: `${someOtherVariable}/...`; ``` Add custom replacements in `scripts/generate-openapi.ts`: ```typescript pathTemplate = text.replace(/\$\{myVariable\}/g, 'replacement'); // ... ``` ### Routes missing The generator only finds routes in `api()` methods of plugin classes. Routes defined elsewhere won't be detected. ## Examples ### Example 1: View API in Swagger Editor ```bash # 1. Generate spec npm run generate:openapi # 2. Start local server npx http-server -p 8080 # 3. Visit https://editor.swagger.io/ # 4. File → Import URL → http://localhost:8080/openapi.json ``` ### Example 2: Generate TypeScript Client ```bash # Generate spec npm run generate:openapi # Generate TypeScript axios client npx @openapitools/openapi-generator-cli generate \ -i openapi.json \ -g typescript-axios \ -o ./generated-client ``` ### Example 3: Validate API Responses ```bash # Generate spec npm run generate:openapi # Install validator npm install --save-dev @seriousme/openapi-schema-validator # Use in tests import { Validator } from '@seriousme/openapi-schema-validator'; const validator = new Validator(); await validator.validate('./openapi.json'); ``` ## Comparison with Alternatives ### tsoa (TypeScript OpenAPI) **Pros**: - Full type inference - Automatic validation - Schema generation **Cons**: - ❌ Requires decorators in production code - ❌ Changes code structure - ❌ Runtime overhead ### swagger-jsdoc **Pros**: - JSDoc annotations - Familiar syntax **Cons**: - ❌ Requires extensive annotations - ❌ Pollutes code with comments - ❌ Manual maintenance ### LDAP-Rest Generator **Pros**: - ✅ No production code changes - ✅ Zero runtime overhead - ✅ Automatic route discovery - ✅ Works with existing code **Cons**: - ⚠️ Limited type information - ⚠️ Generic schemas ## Contributing To improve the generator: 1. Edit `scripts/generate-openapi.ts` 2. Test with `npm run generate:openapi` 3. Verify output in `openapi.json` 4. Submit PR with changes Common improvements: - Add plugin tag mappings - Improve summary generation - Add more template variable replacements - Extract TypeScript types for schemas ## See Also - [Plugin Development Guide](DEVELOPER_GUIDE.md) - [Plugin Dependencies](PLUGIN_DEPENDENCIES.md) - [OpenAPI 3.0 Specification](https://swagger.io/specification/) - [TypeScript Compiler API](https://github.com/Microsoft/TypeScript/wiki/Using-the-Compiler-API) linagora-ldap-rest-a4bb78e/docs/client-development/api/reference.md000066400000000000000000000540501527331160600254640ustar00rootroot00000000000000# API Reference Complete technical reference for the LDAP-Rest REST API including detailed specifications, data formats, and troubleshooting. ## Table of Contents - [HTTP Status Codes](#http-status-codes) - [Request Headers](#request-headers) - [Response Headers](#response-headers) - [LDAP Modify Format](#ldap-modify-format) - [LDAP Attributes Format](#ldap-attributes-format) - [URL Encoding](#url-encoding) - [Authentication Methods](#authentication-methods) - [Troubleshooting](#troubleshooting) --- ## HTTP Status Codes ### Success Codes | Code | Status | Description | Use Case | | ---- | ------- | ----------------------------- | --------------------------- | | 200 | OK | Request succeeded | GET, PUT, DELETE operations | | 201 | Created | Resource created successfully | POST operations | **Example 200 Response:** ```json { "dn": "uid=john.doe,ou=users,dc=example,dc=com", "uid": "john.doe", "cn": "John Doe" } ``` **Example 201 Response:** ```json { "success": true, "dn": "uid=jane.smith,ou=users,dc=example,dc=com" } ``` ### Client Error Codes | Code | Status | Description | Common Causes | | ---- | ---------------------- | ----------------------------------------- | ----------------------------------------------------------------- | | 400 | Bad Request | Invalid request format or data | Missing required fields, invalid attribute values, malformed JSON | | 401 | Unauthorized | Authentication required or failed | Missing/invalid token, expired credentials | | 403 | Forbidden | Insufficient permissions | User lacks required LDAP permissions | | 404 | Not Found | Resource doesn't exist | Invalid DN, deleted resource | | 409 | Conflict | Resource already exists or state conflict | Duplicate entry, non-empty organization deletion | | 415 | Unsupported Media Type | Wrong Content-Type header | Missing `application/json` header | **Example 400 Response:** ```json { "error": "Attribute \"sn\" is required" } ``` **Example 401 Response:** ```json { "error": "Unauthorized" } ``` **Example 404 Response:** ```json { "error": "User not found" } ``` **Example 409 Response:** ```json { "error": "Failed to add user uid=john.doe,ou=users,dc=example,dc=com: Entry Already Exists" } ``` ### Server Error Codes | Code | Status | Description | Common Causes | | ---- | --------------------- | ------------------------------ | -------------------------------------------------------- | | 500 | Internal Server Error | Server-side failure | LDAP connection issues, database errors, plugin failures | | 503 | Service Unavailable | Server temporarily unavailable | LDAP server down, maintenance mode | **Example 500 Response** (the failure is in the server's logs; `message` names it only when the server runs with `--debug`): ```json { "error": "Internal Server Error", "message": "An error occurred" } ``` --- ## Request Headers ### Required Headers All API requests must include: ```http Accept: application/json ``` ### Content-Type Header For POST and PUT requests with a body: ```http Content-Type: application/json ``` ### Authentication Headers #### Token Bearer Authentication ```http Authorization: Bearer your-secret-token ``` #### OpenID Connect ```http Authorization: Bearer oidc-access-token ``` #### LemonLDAP::NG LemonLDAP::NG sets headers automatically. No manual authentication header needed. ### Example Request with All Headers ```bash curl -X POST \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer secret-token-1" \ -d '{"uid": "test", "cn": "Test User", "sn": "User"}' \ http://localhost:8081/api/v1/ldap/users ``` --- ## Response Headers ### Standard Response Headers All API responses include: ```http Content-Type: application/json; charset=utf-8 ``` ### CORS Headers When CORS is enabled (default), responses include: ```http Access-Control-Allow-Origin: * Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS Access-Control-Allow-Headers: Content-Type, Authorization, Accept ``` To restrict CORS origins, use the `--cors-origin` option: ```bash --cors-origin "https://app.example.com" ``` --- ## LDAP Modify Format The LDAP modify format is used for PUT operations to update existing entries. It follows the LDAP protocol's modify operation structure. ### Format Structure ```json { "add": { "attribute": "value" }, "replace": { "attribute": "value" }, "delete": ["attribute1", "attribute2"] } ``` Or to delete specific values: ```json { "delete": { "attribute": "specific-value" } } ``` ### Operation Types #### 1. Replace Replaces the entire attribute value. Creates the attribute if it doesn't exist. **Use Case:** Update single-valued attributes or completely replace multi-valued attributes. **Example:** ```json { "replace": { "mail": "newemail@example.com", "telephoneNumber": "+1-555-0199" } } ``` **LDAP Effect:** - Old value: `mail: oldemail@example.com` - New value: `mail: newemail@example.com` #### 2. Add Adds new values to an attribute. For multi-valued attributes, appends to existing values. Fails if the value already exists. **Use Case:** Add additional values to multi-valued attributes like `mailAlternateAddress`. **Example:** ```json { "add": { "mailAlternateAddress": "alias@example.com", "telephoneNumber": "+1-555-0200" } } ``` **LDAP Effect:** - Before: `mailAlternateAddress: [primary@example.com]` - After: `mailAlternateAddress: [primary@example.com, alias@example.com]` #### 3. Delete Removes attributes or specific values. **Delete Entire Attributes (Array Format):** ```json { "delete": ["mobile", "faxNumber"] } ``` **Delete Specific Values (Object Format):** ```json { "delete": { "mailAlternateAddress": "old-alias@example.com" } } ``` **LDAP Effect (entire attribute):** - Before: `mobile: +1-555-0123` - After: `mobile: ` **LDAP Effect (specific value):** - Before: `mailAlternateAddress: [alias1@example.com, alias2@example.com]` - After: `mailAlternateAddress: [alias1@example.com]` ### Combined Operations Example You can combine multiple operations in a single request: ```json { "replace": { "cn": "John A. Doe", "displayName": "John Doe" }, "add": { "mailAlternateAddress": "jdoe@example.com", "telephoneNumber": "+1-555-0201" }, "delete": ["mobile", "faxNumber"] } ``` ### Complete Example **Request:** ```bash curl -X PUT \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "replace": { "mail": "john.doe@example.com", "telephoneNumber": "+1-555-0100" }, "add": { "mailAlternateAddress": "j.doe@example.com" }, "delete": ["mobile"] }' \ http://localhost:8081/api/v1/ldap/users/john.doe ``` **Response:** ```json { "success": true } ``` ### Special Cases #### Group Members Do **not** use modify operations for group members. Use dedicated endpoints: - Add member: `POST /api/v1/ldap/groups/:cn/members` - Remove member: `DELETE /api/v1/ldap/groups/:cn/members/:memberId` **Wrong:** ```json { "add": { "member": "uid=john.doe,ou=users,dc=example,dc=com" } } ``` **Correct:** ```bash curl -X POST \ -H "Content-Type: application/json" \ -d '{"member": "uid=john.doe,ou=users,dc=example,dc=com"}' \ http://localhost:8081/api/v1/ldap/groups/admins/members ``` #### Main Identifier Attribute You cannot modify the main identifier attribute (e.g., `uid` for users, `cn` for groups) using modify operations. Use LDAP rename operations instead (not currently exposed via REST API). **Error:** ```json { "replace": { "uid": "new.uid" } } ``` **Result:** ```json { "error": "Cannot modify identifier attribute" } ``` --- ## LDAP Attributes Format ### Attribute Value Types LDAP attributes can be single-valued or multi-valued. The API handles both formats flexibly. #### Single-Valued Attributes **LDAP Storage:** Always stored as arrays internally **API Input:** Accepts both string and array formats ```json { "uid": "john.doe" } ``` Or: ```json { "uid": ["john.doe"] } ``` **API Output:** Returns as string for single values ```json { "uid": "john.doe", "cn": "John Doe", "sn": "Doe" } ``` #### Multi-Valued Attributes **LDAP Storage:** Stored as arrays **API Input:** Must use array format ```json { "mailAlternateAddress": [ "john@example.com", "jdoe@example.com", "john.doe@example.com" ] } ``` **API Output:** Always returns as arrays ```json { "objectClass": ["top", "inetOrgPerson", "organizationalPerson", "person"], "mailAlternateAddress": ["john@example.com", "jdoe@example.com"] } ``` ### Common Attributes Reference #### User Attributes | Attribute | Type | Required | Format | Description | | -------------------- | ------ | -------- | ------------------------- | ------------------------------- | | uid | string | Yes | `^[a-zA-Z0-9._-]{1,255}$` | User identifier | | cn | string | Yes | Any | Common name (full name) | | sn | string | Yes | Any | Surname (last name) | | givenName | string | No | Any | First name | | mail | string | No | Email format | Primary email address | | mailAlternateAddress | array | No | Email format | Additional email addresses | | telephoneNumber | string | No | Any | Primary phone number | | mobile | string | No | Any | Mobile phone number | | displayName | string | No | Any | Display name | | userPassword | string | No | Hashed | User password (SSHA, MD5, etc.) | | departmentNumber | string | No | DN format | Department/organization link | | objectClass | array | Yes | Fixed | LDAP object classes | #### Group Attributes | Attribute | Type | Required | Format | Description | | ----------- | ------ | -------- | --------- | ------------------- | | cn | string | Yes | Any | Group name | | description | string | No | Any | Group description | | member | array | Auto | DN format | Group members (DNs) | | objectClass | array | Yes | Fixed | LDAP object classes | **Note:** The `member` attribute requires at least one value. If not provided, a dummy member is automatically added. #### Organization Attributes | Attribute | Type | Required | Format | Description | | ----------- | ------ | -------- | ------ | ------------------------ | | ou | string | Yes | Any | Organizational unit name | | description | string | No | Any | Organization description | | objectClass | array | Yes | Fixed | LDAP object classes | ### Distinguished Names (DN) DNs uniquely identify LDAP entries and follow this format: ``` attribute=value,parent-dn ``` **Examples:** ``` uid=john.doe,ou=users,dc=example,dc=com cn=admins,ou=groups,dc=example,dc=com ou=it,ou=organization,dc=example,dc=com ``` **DN Components:** - **RDN (Relative DN):** First component (`uid=john.doe`) - **Base DN:** Remaining components (`ou=users,dc=example,dc=com`) **DN Rules:** 1. Case-insensitive for attribute names 2. Case-sensitive for attribute values 3. Must be unique within the LDAP tree 4. Cannot contain certain special characters without escaping --- ## URL Encoding ### When to Encode Always URL-encode DNs and special characters when using them in URL paths or query parameters. ### Characters Requiring Encoding | Character | Encoding | Usage | | ----------------- | -------- | ------------------------- | | Space | `%20` | Names with spaces | | Comma `,` | `%2C` | DN separators | | Equals `=` | `%3D` | DN attribute assignments | | Plus `+` | `%2B` | Multi-valued RDNs | | Forward slash `/` | `%2F` | Path separators | | Colon `:` | `%3A` | Port separators | | Question mark `?` | `%3F` | Query string start | | Ampersand `&` | `%26` | Query parameter separator | | Hash `#` | `%23` | Fragment identifier | ### Encoding Examples #### Simple UID ```javascript const uid = 'john.doe'; const encoded = encodeURIComponent(uid); // Result: john.doe (no encoding needed) ``` #### Full DN ```javascript const dn = 'uid=john.doe,ou=users,dc=example,dc=com'; const encoded = encodeURIComponent(dn); // Result: uid%3Djohn.doe%2Cou%3Dusers%2Cdc%3Dexample%2Cdc%3Dcom ``` #### DN with Spaces ```javascript const dn = 'cn=John Doe,ou=users,dc=example,dc=com'; const encoded = encodeURIComponent(dn); // Result: cn%3DJohn%20Doe%2Cou%3Dusers%2Cdc%3Dexample%2Cdc%3Dcom ``` ### Complete Request Examples #### JavaScript ```javascript // Get user by DN const dn = 'uid=john.doe,ou=users,dc=example,dc=com'; const url = `http://localhost:8081/api/v1/ldap/users/${encodeURIComponent(dn)}`; const response = await fetch(url, { headers: { Accept: 'application/json', }, }); const user = await response.json(); ``` #### Python ```python import urllib.parse import requests # Get user by DN dn = "uid=john.doe,ou=users,dc=example,dc=com" encoded_dn = urllib.parse.quote(dn, safe='') url = f"http://localhost:8081/api/v1/ldap/users/{encoded_dn}" response = requests.get(url, headers={'Accept': 'application/json'}) user = response.json() ``` #### Bash ```bash # Get user by DN DN="uid=john.doe,ou=users,dc=example,dc=com" ENCODED=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$DN', safe=''))") curl -H "Accept: application/json" \ "http://localhost:8081/api/v1/ldap/users/$ENCODED" ``` --- ## Authentication Methods ### Overview | Method | Security | Use Case | Session | User Identification | | -------------- | -------- | ---------------- | ------- | ------------------- | | None | Low | Development only | No | None | | Token | Medium | Services, APIs | No | Token index | | OpenID Connect | High | Web applications | Yes | User claims | | LemonLDAP::NG | High | Enterprise SSO | Yes | LDAP user | ### Configuration Comparison #### No Authentication ```bash npx ldap-rest \ --ldap-url ldap://localhost:389 \ --ldap-dn cn=admin,dc=example,dc=com \ --ldap-pwd admin \ --ldap-base dc=example,dc=com ``` **Pros:** Simple, fast setup **Cons:** No security, development only #### Token Authentication ```bash npx ldap-rest \ --ldap-url ldap://localhost:389 \ --ldap-dn cn=admin,dc=example,dc=com \ --ldap-pwd admin \ --ldap-base dc=example,dc=com \ --plugin core/auth/token \ --auth-token "token-1" \ --auth-token "token-2" ``` **Pros:** Simple, stateless, good for services **Cons:** Shared tokens, manual rotation #### OpenID Connect ```bash npx ldap-rest \ --ldap-url ldap://localhost:389 \ --ldap-dn cn=admin,dc=example,dc=com \ --ldap-pwd admin \ --ldap-base dc=example,dc=com \ --plugin core/auth/openidconnect \ --oidc-issuer https://auth.example.com \ --oidc-client-id ldap-rest \ --oidc-client-secret your-secret ``` **Pros:** Standards-based, user-specific tokens, automatic expiration **Cons:** Requires OIDC provider setup #### LemonLDAP::NG ```bash npx ldap-rest \ --ldap-url ldap://localhost:389 \ --ldap-dn cn=admin,dc=example,dc=com \ --ldap-pwd admin \ --ldap-base dc=example,dc=com \ --plugin core/auth/llng \ --llng-ini /etc/lemonldap-ng/lemonldap-ng.ini ``` **Pros:** Enterprise SSO, centralized authentication **Cons:** Requires LemonLDAP::NG infrastructure --- ## Troubleshooting ### CORS Issues #### Problem: Browser Blocks API Requests **Error in Browser Console:** ``` Access to fetch at 'http://localhost:8081/api/v1/config' from origin 'http://localhost:3000' has been blocked by CORS policy ``` **Solution 1: Allow All Origins (Development)** ```bash --cors-origin "*" ``` **Solution 2: Restrict to Specific Origin (Production)** ```bash --cors-origin "https://app.example.com" ``` **Solution 3: Multiple Origins** Use a reverse proxy (nginx, Apache) to handle CORS headers. #### Problem: Preflight OPTIONS Request Fails **Error:** ``` OPTIONS request returns 403 Forbidden ``` **Solution:** Ensure your authentication plugin allows OPTIONS requests without authentication: ```javascript if (req.method === 'OPTIONS') { return next(); } ``` ### Authentication Issues #### Problem: 401 Unauthorized **Error:** ```json { "error": "Unauthorized" } ``` **Checklist:** 1. **Check Authorization Header:** ```bash curl -v -H "Authorization: Bearer your-token" \ http://localhost:8081/api/v1/config ``` 2. **Verify Token Configuration:** ```bash echo $DM_AUTH_TOKENS # Should include your token ``` 3. **Check Token Format:** - Must start with "Bearer " - Token must match exactly (case-sensitive) 4. **Test Without Authentication:** ```bash # Temporarily disable auth plugin to verify API works npx ldap-rest --ldap-url ldap://localhost:389 ... # (without --plugin core/auth/token) ``` ### Connection Issues #### Problem: Cannot Connect to LDAP-Rest **Error:** ``` Failed to fetch ``` **Checklist:** 1. **Verify Server is Running:** ```bash curl http://localhost:8081/api/v1/config ``` 2. **Check Port Configuration:** ```bash --port 8081 # Default ``` 3. **Check Firewall:** ```bash sudo ufw allow 8081 ``` 4. **Check Server Logs:** ```bash npx ldap-rest --log-level debug ``` #### Problem: LDAP Connection Failed **Error:** a 500 answer, with `LDAP bind error` in the server's logs. **Checklist:** 1. **Verify LDAP Server:** ```bash ldapsearch -x -H ldap://localhost:389 -D "cn=admin,dc=example,dc=com" -w admin -b "dc=example,dc=com" ``` 2. **Check LDAP Credentials:** ```bash --ldap-dn "cn=admin,dc=example,dc=com" \ --ldap-pwd "admin" ``` 3. **Check LDAP URL:** ```bash --ldap-url "ldap://localhost:389" # Not ldaps:// if using plain LDAP ``` 4. **Check Base DN:** ```bash --ldap-base "dc=example,dc=com" ``` ### Data Format Issues #### Problem: Missing Required Field **Error:** ```json { "error": "Attribute \"sn\" is required" } ``` **Solution:** Check schema via Configuration API: ```bash curl http://localhost:8081/api/v1/config | jq '.features.ldapFlatGeneric.flatResources[0].schema.attributes' ``` Ensure all required fields are included: ```json { "uid": "john.doe", "cn": "John Doe", "sn": "Doe" // Required! } ``` #### Problem: Invalid Attribute Format **Error:** ```json { "error": "Invalid value for attribute \"mail\": Expected an email address, e.g. jane.doe@example.org" } ``` **Solution:** Follow the attribute format specified in the schema: ```json { "mail": "valid.email@example.com" // Not "invalid-email" } ``` #### Problem: Cannot Modify Identifier **Error:** ```json { "error": "Cannot modify identifier attribute" } ``` **Solution:** You cannot change the main identifier (uid, cn) using modify operations. Create a new entry instead. ### Debug Mode Enable detailed logging: ```bash npx ldap-rest \ --ldap-url ldap://localhost:389 \ --ldap-dn cn=admin,dc=example,dc=com \ --ldap-pwd admin \ --ldap-base dc=example,dc=com \ --log-level debug ``` **Log Levels:** - `error` - Errors only - `warn` - Warnings and errors - `info` - General information (default) - `debug` - Detailed debugging information - `silly` - Everything including LDAP queries **Example Debug Output:** ``` [2025-10-07 10:30:45] DEBUG: LDAP search: base=ou=users,dc=example,dc=com, filter=(uid=john.doe) [2025-10-07 10:30:45] DEBUG: LDAP result: 1 entries found [2025-10-07 10:30:45] INFO: User john.doe retrieved successfully ``` ### Common Issues Summary | Issue | Symptom | Solution | | ---------------- | ----------------------- | ------------------------- | | CORS blocked | Browser console error | Configure `--cors-origin` | | 401 Unauthorized | Authentication failed | Check token/credentials | | 404 Not Found | Entry doesn't exist | Verify DN is correct | | 409 Conflict | Entry already exists | Use unique identifier | | 500 Server Error | LDAP connection failed | Check LDAP server status | | Invalid format | Validation error | Check schema requirements | | Cannot modify DN | Identifier change error | Create new entry instead | ### Getting Help 1. **Check Logs:** ```bash npx ldap-rest --log-level debug ``` 2. **Test LDAP Connection:** ```bash ldapsearch -x -H ldap://localhost:389 -D "cn=admin,dc=example,dc=com" -w admin ``` 3. **Verify Configuration:** ```bash curl http://localhost:8081/api/v1/config ``` 4. **Check GitHub Issues:** https://github.com/linagora/ldap-rest/issues 5. **Review Documentation:** - [REST API Guide](./REST_API.md) - [Browser Libraries](../browser/LIBRARIES.md) - [Examples](../examples/EXAMPLES.md) --- ## Additional Resources - **[REST API Guide](./REST_API.md)** - Complete API guide with examples - **[Developer Guide](../DEVELOPER_GUIDE.md)** - Getting started guide - **[Browser Libraries](../browser/LIBRARIES.md)** - JavaScript/TypeScript clients - **[JSON Schemas](../schemas/SCHEMAS.md)** - Schema structure and validation - **[Plugin Development](../plugins/DEVELOPMENT.md)** - Creating custom plugins linagora-ldap-rest-a4bb78e/docs/client-development/api/rest-api.md000066400000000000000000001012411527331160600252450ustar00rootroot00000000000000# REST API Guide This guide covers the LDAP-Rest REST API for managing LDAP users, groups, and organizations. The API follows REST conventions and returns JSON responses. ## Table of Contents - [Getting Started](#getting-started) - [Authentication](#authentication) - [Configuration API](#configuration-api) - [Organizations API](#organizations-api) - [Users API](#users-api) - [Groups API](#groups-api) - [Error Handling](#error-handling) --- ## Getting Started ### Base URL All API endpoints are prefixed with `/api/v1` by default: ``` http://localhost:8081/api/v1 ``` ### Content Type All requests and responses use JSON: ``` Content-Type: application/json ``` ### Request Headers Required headers for all requests: ```http Accept: application/json ``` For authenticated endpoints, include the authentication token: ```http Authorization: Bearer your-token-here ``` --- ## Authentication LDAP-Rest supports multiple authentication methods. Choose the one that fits your infrastructure. ### 1. No Authentication (Development Only) For development and testing, you can run LDAP-Rest without authentication: ```bash npx ldap-rest \ --ldap-url ldap://localhost:389 \ --ldap-dn cn=admin,dc=example,dc=com \ --ldap-pwd admin \ --ldap-base dc=example,dc=com ``` **Warning:** Never use this in production environments. ### 2. Token Bearer Authentication Simple stateless authentication using bearer tokens. **Configuration:** ```bash --plugin core/auth/token \ --auth-token "secret-token-1" \ --auth-token "secret-token-2" ``` **Usage:** ```bash curl -H "Authorization: Bearer secret-token-1" \ -H "Accept: application/json" \ http://localhost:8081/api/v1/ldap/users ``` **Use Cases:** - Development and testing - Service-to-service communication - CI/CD pipelines - Simple deployments without SSO ### 3. OpenID Connect OAuth 2.0 / OpenID Connect authentication for modern web applications. **Configuration:** ```bash --plugin core/auth/openidconnect \ --oidc-issuer https://auth.example.com \ --oidc-client-id ldap-rest \ --oidc-client-secret your-secret ``` > **NB**: the `redirect_uri` is `https://ldap-rest-server/callback` **Usage:** After authentication you can access to the whole API: ```bash curl -H "Authorization: Bearer oidc-access-token" \ -H "Accept: application/json" \ http://localhost:8081/api/v1/ldap/users ``` ### 4. LemonLDAP::NG _(Native)_ Integration with LemonLDAP::NG Web SSO solution if not using OpenID-Connect. **Configuration:** ```bash --plugin core/auth/llng \ --llng-ini /etc/lemonldap-ng/lemonldap-ng.ini ``` The plugin reads user information from the `Lm-Remote-User` header set by LemonLDAP::NG. --- ## Configuration API The Configuration API exposes available features, endpoints, and schemas. Your application can discover capabilities at runtime. ### Get Configuration Retrieve the complete API configuration including all available resources and their endpoints. **Endpoint:** `GET /api/v1/config` **Example Request:** ```bash curl -H "Accept: application/json" \ http://localhost:8081/api/v1/config ``` **Example Response:** ```json { "apiPrefix": "/api", "ldapBase": "dc=example,dc=com", "version": "1.2.1", "features": { "flatResources": [ { "name": "standardUser", "singularName": "user", "pluralName": "users", "mainAttribute": "uid", "objectClass": [ "top", "inetOrgPerson", "organizationalPerson", "person" ], "base": "ou=users,dc=example,dc=com", "schema": { "strict": true, "attributes": { "uid": { "type": "string", "required": true, "role": "identifier" }, "cn": { "type": "string", "required": true }, "sn": { "type": "string", "required": true }, "mail": { "type": "string", "required": false, "role": "primaryEmail" } } }, "schemaUrl": "/static/schemas/standard/users.json", "endpoints": { "list": "/api/v1/ldap/users", "get": "/api/v1/ldap/users/:id", "create": "/api/v1/ldap/users", "update": "/api/v1/ldap/users/:id", "delete": "/api/v1/ldap/users/:id" } } ], "groups": { "enabled": true, "base": "ou=groups,dc=example,dc=com", "mainAttribute": "cn", "objectClass": ["top", "groupOfNames"], "endpoints": { "list": "/api/v1/ldap/groups", "get": "/api/v1/ldap/groups/:id", "create": "/api/v1/ldap/groups", "update": "/api/v1/ldap/groups/:id", "delete": "/api/v1/ldap/groups/:id", "addMember": "/api/v1/ldap/groups/:id/members", "removeMember": "/api/v1/ldap/groups/:id/members/:memberId" } }, "organizations": { "enabled": true, "topOrganization": "ou=organization,dc=example,dc=com", "organizationClass": ["top", "organizationalUnit"], "linkAttribute": "departmentNumber", "pathAttribute": "displayName", "pathSeparator": " / ", "maxSubnodes": 50, "endpoints": { "getTop": "/api/v1/ldap/organizations", "get": "/api/v1/ldap/organizations/:dn", "getSubnodes": "/api/v1/ldap/organizations/:dn/subnodes", "searchSubnodes": "/api/v1/ldap/organizations/:dn/subnodes/search", "search": "/api/v1/ldap/organizations/:dn/search" } } } } ``` **Response Fields:** - `apiPrefix`: API URL prefix (default: `/api`) - `ldapBase`: LDAP base DN - `version`: the server's version, as its `package.json` declares it. A client compares it with the range it supports and tells an administrator when the server is older. Optional: an installation that moved that file leaves it out, and a client must then work without it - `features.ldapFlatGeneric.flatResources`: Array of flat LDAP resources (users, positions, etc.) - `features.ldapGroups`: Group management configuration (if enabled) - `features.ldapOrganizations`: Organization tree configuration (if enabled) - `features.externalUsersInGroups`: where to send an external group member (if enabled), [details](../../usage/plugins/ldap/external-users.md#config-api) - `features.openidconnect.endpoints.logout`: the logout route, when OpenID Connect guards the server ([details](../../usage/plugins/auth/oidc.md#logout)) --- ## Organizations API Manage hierarchical organizational units (OUs) in your LDAP directory. ### Get Top Organization Retrieve the top-level organization entry. **Endpoint:** `GET /api/v1/ldap/organizations/top` **Example Request:** ```bash curl -H "Accept: application/json" \ http://localhost:8081/api/v1/ldap/organizations/top ``` **Example Response:** ```json { "dn": "ou=organization,dc=example,dc=com", "objectClass": ["organizationalUnit", "top"], "ou": "organization", "description": "Root organization" } ``` ### Get Organization by DN Retrieve a specific organization by its Distinguished Name. **Endpoint:** `GET /api/v1/ldap/organizations/:dn` **Parameters:** - `:dn` - URL-encoded Distinguished Name **Example Request:** ```bash curl -H "Accept: application/json" \ http://localhost:8081/api/v1/ldap/organizations/ou%3Dit%2Cou%3Dorganization%2Cdc%3Dexample%2Cdc%3Dcom ``` **Example Response:** ```json { "dn": "ou=it,ou=organization,dc=example,dc=com", "objectClass": ["organizationalUnit", "top"], "ou": "it", "description": "IT Department" } ``` ### Get Organization Subnodes Retrieve all child organizational units and linked entities (users/groups) for an organization. **Endpoint:** `GET /api/v1/ldap/organizations/:dn/subnodes?objectClass={class}` **Parameters:** - `:dn` - URL-encoded Distinguished Name - `objectClass` (optional) - Filter results by LDAP objectClass (e.g., `twakeAccount`, `groupOfNames`, `organizationalUnit`) **Response Limit:** Returns up to 50 linked entities by default (configurable via `--ldap-organization-max-subnodes`) **Example Requests:** ```bash # Get all subnodes (users, groups, and sub-OUs) curl -H "Accept: application/json" \ http://localhost:8081/api/v1/ldap/organizations/ou%3Dit%2Cou%3Dorganization%2Cdc%3Dexample%2Cdc%3Dcom/subnodes # Get only users curl -H "Accept: application/json" \ "http://localhost:8081/api/v1/ldap/organizations/ou%3Dit%2Cou%3Dorganization%2Cdc%3Dexample%2Cdc%3Dcom/subnodes?objectClass=twakeAccount" # Get only groups curl -H "Accept: application/json" \ "http://localhost:8081/api/v1/ldap/organizations/ou%3Dit%2Cou%3Dorganization%2Cdc%3Dexample%2Cdc%3Dcom/subnodes?objectClass=groupOfNames" ``` **Example Response:** ```json [ { "dn": "ou=engineering,ou=it,ou=organization,dc=example,dc=com", "objectClass": ["organizationalUnit", "top"], "ou": "engineering", "description": "Engineering Team" }, { "dn": "uid=john.doe,ou=users,dc=example,dc=com", "objectClass": ["inetOrgPerson", "top"], "uid": "john.doe", "cn": "John Doe", "departmentNumber": "ou=it,ou=organization,dc=example,dc=com" } ] ``` ### Search Organization Subnodes Search for subnodes matching a query string within an organization. **Endpoint:** `GET /api/v1/ldap/organizations/:dn/subnodes/search?q=query` **Parameters:** - `:dn` - URL-encoded Distinguished Name - `q` - Search query (required) **Example Request:** ```bash curl -H "Accept: application/json" \ "http://localhost:8081/api/v1/ldap/organizations/ou%3Dit%2Cou%3Dorganization%2Cdc%3Dexample%2Cdc%3Dcom/subnodes/search?q=john" ``` **Example Response:** ```json [ { "dn": "uid=john.doe,ou=users,dc=example,dc=com", "objectClass": ["inetOrgPerson", "top"], "uid": "john.doe", "cn": "John Doe", "mail": "john.doe@example.com", "departmentNumber": "ou=it,ou=organization,dc=example,dc=com" } ] ``` ### Search Organizations in a Subtree Search the organizations under a node, at any depth, the node itself included. Where the subnodes search only looks at the direct children and adds the linked users and groups, this one answers organizations only, which makes it the call behind a department autocomplete over a wide tree. **Endpoint:** `GET /api/v1/ldap/organizations/:dn/search?q=query` **Parameters:** - `:dn` - URL-encoded Distinguished Name of the node to search under - `q` - Search query (required), matched against `ou`, `description` and, when `--ldap-organization-path-attribute` is set, the path attribute, so `Government / lin` matches by path **Limit:** at most `--ldap-organization-max-subnodes` (default 50) organizations are returned. A partial answer ends with a `moreIndicator` entry at `more-organizations-` (`_isMoreIndicator: "true"`, `_displayedCount` holding the number of organizations kept), which a client must drop before using the rest. It says which limit cut the answer: - **the cap**: more organizations matched than it allows. The entry also carries `_totalCount`, and a narrower query is the way to the rest. - **the directory**: it refused to list every match in one answer. There is no `_totalCount`, since nothing counted the rest, and ldap-rest logs a warning: the fix is the size limit of the account ldap-rest binds as (`olcLimits`, or `olcSizeLimit`). **Example Request:** ```bash curl -H "Accept: application/json" \ "http://localhost:8081/api/v1/ldap/organizations/ou%3Dorganization%2Cdc%3Dexample%2Cdc%3Dcom/search?q=lin" ``` **Example Response:** ```json [ { "dn": "ou=linagora.com,ou=Government,ou=organization,dc=example,dc=com", "objectClass": ["organizationalUnit", "top"], "ou": "linagora.com" }, { "dn": "more-organizations-ou=organization,dc=example,dc=com", "cn": ["... 73 more organizations, narrow the search"], "objectClass": ["moreIndicator"], "_isMoreIndicator": "true", "_totalCount": "123", "_displayedCount": "50" } ] ``` The `search` entry of the `endpoints` advertised by the config API tells a client that the server has this route. ### Create Organization Create a new organizational unit. **Endpoint:** `POST /api/v1/ldap/organizations` **Request Body:** ```json { "ou": "marketing", "parentDn": "ou=organization,dc=example,dc=com", "description": "Marketing Department" } ``` **Required Fields:** - `ou` - Organizational unit name **Optional Fields:** - `parentDn` - Parent organization DN (defaults to top organization) - Additional LDAP attributes as needed **Example Request:** ```bash curl -X POST \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "ou": "marketing", "parentDn": "ou=organization,dc=example,dc=com", "description": "Marketing Department" }' \ http://localhost:8081/api/v1/ldap/organizations ``` **Example Response:** ```json { "success": true, "dn": "ou=marketing,ou=organization,dc=example,dc=com" } ``` ### Update Organization Modify an existing organization using LDAP modify operations. **Endpoint:** `PUT /api/v1/ldap/organizations/:dn` **Parameters:** - `:dn` - URL-encoded Distinguished Name **Request Body Format:** ```json { "replace": { "description": "Updated Marketing Department" }, "add": { "telephoneNumber": "+1-555-0100" }, "delete": ["postalCode"] } ``` **Operations:** - `replace` - Replace attribute values - `add` - Add new attribute values - `delete` - Remove attributes (array of attribute names) or specific values (object) **Example Request:** ```bash curl -X PUT \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "replace": { "description": "Marketing and Communications" } }' \ http://localhost:8081/api/v1/ldap/organizations/ou%3Dmarketing%2Cou%3Dorganization%2Cdc%3Dexample%2Cdc%3Dcom ``` **Example Response:** ```json { "success": true } ``` ### Delete Organization Delete an organizational unit. The organization must be empty (no linked users/groups). **Endpoint:** `DELETE /api/v1/ldap/organizations/:dn` **Parameters:** - `:dn` - URL-encoded Distinguished Name **Example Request:** ```bash curl -X DELETE \ -H "Accept: application/json" \ http://localhost:8081/api/v1/ldap/organizations/ou%3Dmarketing%2Cou%3Dorganization%2Cdc%3Dexample%2Cdc%3Dcom ``` **Example Response:** ```json { "success": true } ``` **Error Response (non-empty organization):** ```json { "error": "Organization ou=marketing,ou=organization,dc=example,dc=com is not empty" } ``` --- ## Users API Manage user entries in your LDAP directory. User endpoints are dynamically configured based on loaded schemas. ### List Users Retrieve all users or filter by attributes. **Endpoint:** `GET /api/v1/ldap/users` **Query Parameters:** - `match` - Filter value (partial match with wildcards) - `attribute` - Attribute name to filter on - `attributes` - Comma-separated list of attributes to return - `limit` - Return at most this many users; `X-Result-Truncated: true` then says others were left out. Without it, a list longer than the directory's size limit is answered `422` ([details](../../usage/plugins/ldap/flat-generic.md#long-lists)). Only the flat entities' lists (users and the other schema-driven entities) take `limit` for now; the [groups list](#list-groups) does not. **Example Request (all users):** ```bash curl -H "Accept: application/json" \ http://localhost:8081/api/v1/ldap/users ``` **Example Request (filtered):** ```bash curl -H "Accept: application/json" \ "http://localhost:8081/api/v1/ldap/users?match=john&attribute=cn" ``` **Example Response:** ```json [ { "dn": "uid=john.doe,ou=users,dc=example,dc=com", "uid": "john.doe", "cn": "John Doe", "sn": "Doe", "givenName": "John", "mail": "john.doe@example.com", "telephoneNumber": "+1-555-0123" }, { "dn": "uid=jane.smith,ou=users,dc=example,dc=com", "uid": "jane.smith", "cn": "Jane Smith", "sn": "Smith", "givenName": "Jane", "mail": "jane.smith@example.com" } ] ``` ### Get User Retrieve a specific user by UID or DN. **Endpoint:** `GET /api/v1/ldap/users/:id` **Parameters:** - `:id` - User UID or URL-encoded DN **Example Request (by UID):** ```bash curl -H "Accept: application/json" \ http://localhost:8081/api/v1/ldap/users/john.doe ``` **Example Request (by DN):** ```bash curl -H "Accept: application/json" \ http://localhost:8081/api/v1/ldap/users/uid%3Djohn.doe%2Cou%3Dusers%2Cdc%3Dexample%2Cdc%3Dcom ``` **Example Response:** ```json { "dn": "uid=john.doe,ou=users,dc=example,dc=com", "objectClass": ["inetOrgPerson", "organizationalPerson", "person", "top"], "uid": "john.doe", "cn": "John Doe", "sn": "Doe", "givenName": "John", "mail": "john.doe@example.com", "telephoneNumber": "+1-555-0123", "departmentNumber": "ou=it,ou=organization,dc=example,dc=com" } ``` ### Create User Add a new user to the LDAP directory. **Endpoint:** `POST /api/v1/ldap/users` **Request Body:** ```json { "uid": "alice.johnson", "cn": "Alice Johnson", "sn": "Johnson", "givenName": "Alice", "mail": "alice.johnson@example.com", "telephoneNumber": "+1-555-0124", "userPassword": "{SSHA}hashedpassword" } ``` **Required Fields:** Depends on your schema. Typically: - `uid` - User identifier - `cn` - Common name - `sn` - Surname **Example Request:** ```bash curl -X POST \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "uid": "alice.johnson", "cn": "Alice Johnson", "sn": "Johnson", "givenName": "Alice", "mail": "alice.johnson@example.com" }' \ http://localhost:8081/api/v1/ldap/users ``` **Example Response:** ```json { "success": true, "dn": "uid=alice.johnson,ou=users,dc=example,dc=com" } ``` ### Update User Modify an existing user using LDAP modify operations. **Endpoint:** `PUT /api/v1/ldap/users/:id` **Parameters:** - `:id` - User UID or URL-encoded DN **Request Body Format:** ```json { "replace": { "mail": "alice.j@example.com", "telephoneNumber": "+1-555-0199" }, "add": { "mailAlternateAddress": ["alice.johnson@example.com"] }, "delete": ["mobile"] } ``` **Operations:** - `replace` - Replace attribute values - `add` - Add new attribute values (for multi-valued attributes) - `delete` - Remove attributes (array) or specific values (object) **Example Request:** ```bash curl -X PUT \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "replace": { "telephoneNumber": "+1-555-0200" } }' \ http://localhost:8081/api/v1/ldap/users/alice.johnson ``` **Example Response:** ```json { "success": true } ``` ### Delete User Remove a user from the LDAP directory. The user is automatically removed from all groups. **Endpoint:** `DELETE /api/v1/ldap/users/:id` **Parameters:** - `:id` - User UID or URL-encoded DN **Example Request:** ```bash curl -X DELETE \ -H "Accept: application/json" \ http://localhost:8081/api/v1/ldap/users/alice.johnson ``` **Example Response:** ```json { "success": true } ``` --- ## Groups API Manage LDAP groups and their members. ### List Groups Retrieve all groups or filter by name. **Endpoint:** `GET /api/v1/ldap/groups` **Query Parameters:** - `match` - Filter groups by CN (supports LDAP filter format or simple string) - `attributes` - Comma-separated list of attributes to return This list does not take `limit` yet: `?limit=` is ignored, no `X-Result-Truncated` header is sent, and a list longer than the directory's size limit fails with `500` rather than the `422` of the [users list](#list-users). **Example Request (all groups):** ```bash curl -H "Accept: application/json" \ http://localhost:8081/api/v1/ldap/groups ``` **Example Request (filtered):** ```bash curl -H "Accept: application/json" \ "http://localhost:8081/api/v1/ldap/groups?match=admin*" ``` **Example Response:** ```json { "admins": { "dn": "cn=admins,ou=groups,dc=example,dc=com", "cn": "admins", "member": [ "uid=john.doe,ou=users,dc=example,dc=com", "uid=jane.smith,ou=users,dc=example,dc=com" ] }, "developers": { "dn": "cn=developers,ou=groups,dc=example,dc=com", "cn": "developers", "member": ["uid=alice.johnson,ou=users,dc=example,dc=com"] } } ``` ### Get Group Retrieve a specific group by CN or DN. **Endpoint:** `GET /api/v1/ldap/groups/:cn` **Parameters:** - `:cn` - Group CN or URL-encoded DN **Example Request:** ```bash curl -H "Accept: application/json" \ http://localhost:8081/api/v1/ldap/groups/admins ``` **Example Response:** ```json { "dn": "cn=admins,ou=groups,dc=example,dc=com", "objectClass": ["groupOfNames", "top"], "cn": "admins", "description": "System Administrators", "member": [ "uid=john.doe,ou=users,dc=example,dc=com", "uid=jane.smith,ou=users,dc=example,dc=com" ] } ``` ### Create Group Add a new group to the LDAP directory. **Endpoint:** `POST /api/v1/ldap/groups` **Request Body:** ```json { "cn": "developers", "description": "Development Team", "member": ["uid=alice.johnson,ou=users,dc=example,dc=com"] } ``` **Required Fields:** - `cn` - Group common name **Optional Fields:** - `member` - Array of member DNs - `description` - Group description - Additional LDAP attributes as needed **Note:** Groups require at least one member. If no members are provided, a dummy member is automatically added. **Example Request:** ```bash curl -X POST \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "cn": "developers", "description": "Development Team", "member": ["uid=alice.johnson,ou=users,dc=example,dc=com"] }' \ http://localhost:8081/api/v1/ldap/groups ``` **Example Response:** ```json { "success": true, "dn": "cn=developers,ou=groups,dc=example,dc=com" } ``` ### Update Group Modify an existing group using LDAP modify operations. **Endpoint:** `PUT /api/v1/ldap/groups/:cn` **Parameters:** - `:cn` - Group CN or URL-encoded DN **Request Body Format:** ```json { "replace": { "description": "Software Development Team" }, "add": { "businessCategory": "Engineering" }, "delete": ["seeAlso"] } ``` **Important:** Do not use this endpoint to add/remove members. Use the dedicated member management endpoints instead. **Example Request:** ```bash curl -X PUT \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "replace": { "description": "Software Development Team" } }' \ http://localhost:8081/api/v1/ldap/groups/developers ``` **Example Response:** ```json { "success": true } ``` ### Delete Group Remove a group from the LDAP directory. **Endpoint:** `DELETE /api/v1/ldap/groups/:cn` **Parameters:** - `:cn` - Group CN or URL-encoded DN **Example Request:** ```bash curl -X DELETE \ -H "Accept: application/json" \ http://localhost:8081/api/v1/ldap/groups/developers ``` **Example Response:** ```json { "success": true } ``` ### Add Group Member Add one or more users to a group. **Endpoint:** `POST /api/v1/ldap/groups/:cn/members` **Parameters:** - `:cn` - Group CN or URL-encoded DN **Request Body:** ```json { "member": "uid=bob.wilson,ou=users,dc=example,dc=com" } ``` Or multiple members: ```json { "member": [ "uid=bob.wilson,ou=users,dc=example,dc=com", "uid=carol.white,ou=users,dc=example,dc=com" ] } ``` **Example Request:** ```bash curl -X POST \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "member": "uid=bob.wilson,ou=users,dc=example,dc=com" }' \ http://localhost:8081/api/v1/ldap/groups/developers/members ``` **Example Response:** ```json { "success": true } ``` A member that does not exist answers 400 naming it, and none of the members is added (unless `--groups-allow-unexistent-members` is set). Group creation checks its `member` list the same way: ```json { "error": "Member uid=nobody,ou=users,dc=example,dc=com not found" } ``` ### Remove Group Member Remove a user from a group. **Endpoint:** `DELETE /api/v1/ldap/groups/:cn/members/:memberId` **Parameters:** - `:cn` - Group CN or URL-encoded DN - `:memberId` - URL-encoded member DN **Example Request:** ```bash curl -X DELETE \ -H "Accept: application/json" \ http://localhost:8081/api/v1/ldap/groups/developers/members/uid%3Dbob.wilson%2Cou%3Dusers%2Cdc%3Dexample%2Cdc%3Dcom ``` **Example Response:** ```json { "success": true } ``` --- ## Error Handling ### HTTP Status Codes | Status Code | Description | | ----------- | -------------------------------------- | | 200 | Success | | 201 | Created | | 400 | Bad Request - Invalid input | | 401 | Unauthorized - Authentication required | | 403 | Forbidden - Insufficient permissions | | 404 | Not Found - Resource doesn't exist | | 409 | Conflict - Resource already exists | | 422 | Unprocessable - see below | | 500 | Internal Server Error | ### Error Response Format All errors return a JSON object with an error message: ```json { "error": "Description of the error" } ``` The message is English, for a log or a developer. The refusals a client is expected to explain to its users carry a `code` too, and what was refused, for the client to say it in its own words and next to the field at fault. `error` is always there; read the other fields only when `code` is: | `code` | Status | Refusal | Other fields | | ------------ | ------ | ------------------------------------------------------------ | ------------------------------- | | `unique` | 409 | A value another entry already holds | `attribute`, `value` | | `mailDomain` | 409 | An address outside the mail domains its organization may use | `attribute`, `value`, `domains` | | `forbidden` | 403 | The caller may not act on this branch | none: the branch is not named | Any other refusal has no `code`, and only its message says what it is. The bulk import reports the same fields on each refused row. ### Common Errors #### 400 Bad Request **Missing Required Fields:** ```json { "error": "Attribute \"sn\" is required" } ``` **Invalid Attribute Format:** ```json { "error": "Invalid value for attribute \"uid\"" } ``` **Invalid LDAP Filter:** ```json { "error": "Invalid match query" } ``` **Refused by the directory schema** — an attribute the directory does not define, a value of the wrong syntax, a missing mandatory attribute, a broken constraint or naming rule. Nothing of the write is applied. These are the LDAP result codes 17, 18, 19, 21, 34, 64, 67 and 69 on any write, 65 on a creation, a rename or a move, and 20 (a value given twice) on a creation; a rename or a move names both DNs: ```json { "error": "The directory refused this write to uid=john.doe,ou=users,dc=example,dc=com: UndefinedTypeError: mailQuota: attribute type undefined Code: 0x11" } ``` **DN that cannot be parsed** — an organization read or a move target that is not a DN (LDAP result code 34): ```json { "error": "garbage is not a valid DN" } ``` #### 401 Unauthorized **Missing Authentication:** ```json { "error": "Unauthorized" } ``` **Invalid Token:** ```json { "error": "Unauthorized" } ``` #### 403 Forbidden **Refused by authorization** — the caller may not read, write or delete in this branch. The branch is not named, the caller may not know of it: ```json { "error": "Token does not have permission on this branch", "code": "forbidden" } ``` #### 404 Not Found **User Not Found:** ```json { "error": "User not found" } ``` **Group Not Found:** ```json { "error": "Group not found" } ``` **Organization Not Found:** ```json { "error": "Organization ou=marketing,ou=organization,dc=example,dc=com not found" } ``` **Entry or value the directory does not hold** — a modify or a delete of an entry that is not there, or the removal of a value the entry lacks (LDAP result codes 32 and 16). Removing a group member that is not one names both: ```json { "error": "uid=alice,ou=users,dc=example,dc=com is not a member of cn=backend-guild,ou=groups,dc=example,dc=com" } ``` #### 409 Conflict **Duplicate Entry:** ```json { "error": "Failed to add user uid=john.doe,ou=users,dc=example,dc=com: Entry Already Exists" } ``` A rename or a move onto a DN already taken answers the same way, naming that DN (LDAP result code 68). **Value already used** — a value the schema says is unique ([`unique`](../../usage/plugins/ldap/enterprise-rules.md)), held by another entry, which is not named: ```json { "error": "user: \"alice@example.com\" is already used (mail must be unique)", "code": "unique", "attribute": "mail", "value": "alice@example.com" } ``` **Mail domain not authorised** — an address outside the domains the entry's organization, or one of its ancestors, links to: ```json { "error": "The mail domain of \"x.user@other.org\" is not one of the authorised domains (example.com)", "code": "mailDomain", "attribute": "mail", "value": "x.user@other.org", "domains": ["example.com"] } ``` **Entry that cannot hold a change** — a modify adding an attribute the classes of the entry do not allow, or a value it already holds (LDAP result codes 65 and 20 on a modify): ```json { "error": "Entry uid=john.doe,ou=users,dc=example,dc=com cannot hold this change: TypeOrValueExistsError: modify/add: cn: value #0 already exists Code: 0x14" } ``` #### 422 Unprocessable Entity **List longer than the directory's size limit** (flat entities' lists, when no `limit` was given — [details](../../usage/plugins/ldap/flat-generic.md#long-lists)): ```json { "error": "Too many entries to list at once: the directory's size limit was exceeded. Narrow the search, or pass `limit`." } ``` #### 500 Internal Server Error Every 5xx answers this body, whether the route threw or reported the failure through the server's own helper. The failure is in the server's logs, not in the response; the message names it only when the server runs with `--debug`: ```json { "error": "Internal Server Error", "message": "An error occurred" } ``` --- ## Tips and Best Practices ### URL Encoding Always URL-encode DNs when using them in URL paths: ```javascript const dn = 'uid=john.doe,ou=users,dc=example,dc=com'; const encoded = encodeURIComponent(dn); // Result: uid%3Djohn.doe%2Cou%3Dusers%2Cdc%3Dexample%2Cdc%3Dcom ``` A path parameter is percent-decoded once, as RFC 3986 spells it: an identifier holding `%` is sent as `%25`, so `100%` is addressed as `/api/v1/ldap/users/100%25`. ### LDAP Attributes are Arrays LDAP attributes are always returned as arrays, even for single-valued attributes: ```json { "uid": "john.doe", // Actually stored as ["john.doe"] "cn": "John Doe", // Actually stored as ["John Doe"] "mail": "john@example.com" // Actually stored as ["john@example.com"] } ``` When sending data, you can use either format: ```json { "uid": "john.doe", "cn": ["John Doe"], "mail": ["john@example.com", "jdoe@example.com"] } ``` ### Modify Operations Use the correct operation type: - **replace**: Change an existing value (creates if doesn't exist) - **add**: Add values to multi-valued attributes (error if already exists) - **delete**: Remove attributes or specific values ### Long Lists The API has no pagination. A flat entity's list (users and the other schema-driven entities) takes `limit` to bound its answer, and says with `X-Result-Truncated: true` that entries were left out: narrow the search with `match` and `attribute` to reach them. Without `limit`, a list longer than the directory's size limit is answered `422`. See [long lists](../../usage/plugins/ldap/flat-generic.md#long-lists); the groups list does not take `limit` yet. ### Schema Validation Always check the schema via `/api/v1/config` to understand: - Required attributes - Attribute types and formats - Allowed values and patterns ### Testing Use the Configuration API to discover available endpoints before making requests: ```bash # 1. Get configuration CONFIG=$(curl -s http://localhost:8081/api/v1/config) # 2. Extract users endpoint USERS_ENDPOINT=$(echo $CONFIG | jq -r '.features.ldapFlatGeneric.flatResources[0].endpoints.list') # 3. Use endpoint curl -s $USERS_ENDPOINT ``` --- ## Next Steps - **[API Reference](./REFERENCE.md)** - Complete reference with all HTTP status codes and formats - **[Browser Libraries](../browser/LIBRARIES.md)** - Pre-built JavaScript clients - **[Examples](../examples/EXAMPLES.md)** - Integration examples for React, Vue, and Vanilla JS - **[Schemas](../schemas/SCHEMAS.md)** - Understanding and creating JSON schemas linagora-ldap-rest-a4bb78e/docs/client-development/browser/000077500000000000000000000000001527331160600241125ustar00rootroot00000000000000linagora-ldap-rest-a4bb78e/docs/client-development/browser/README.md000066400000000000000000000034321527331160600253730ustar00rootroot00000000000000# Browser Libraries Web components for integrating LDAP-Rest into your applications. ## Overview LDAP-Rest provides reusable web components for LDAP management. ## Documentation - **[libraries.md](libraries.md)** - Complete component documentation ## Available Components | Component | Description | | -------------------- | ------------------------------ | | `LdapTreeViewer` | LDAP tree navigation | | `LdapUserEditor` | User editing | | `LdapGroupEditor` | Group management | | `LdapUnitEditor` | Organizational unit management | | `LdapResourceEditor` | Resource management | ## Utilities | Module | Description | | ----------------------------- | ------------------------------ | | `browser-shared-utils-totp` | TOTP client for authentication | | `browser-shared-utils-hmac` | HMAC client for authentication | | `browser-shared-utils-dom` | DOM utilities | | `browser-shared-utils-form` | Form utilities | | `browser-shared-utils-schema` | JSON schema utilities | ## Installation ```bash npm install ldap-rest ``` ## Import ```typescript // Components import { LdapTreeViewer, LdapUserEditor } from 'ldap-rest/browser'; // Utilities import { TotpAuthClient } from 'ldap-rest/browser-shared-utils-totp'; import { HmacAuthClient } from 'ldap-rest/browser-shared-utils-hmac'; ``` ## Quick Example ```typescript import { LdapTreeViewer } from 'ldap-rest/browser'; const viewer = new LdapTreeViewer({ apiUrl: 'http://localhost:8081/api/v1', container: document.getElementById('tree'), }); viewer.render(); ``` ## Demos Interactive examples are available in the [examples](../examples/README.md) folder. linagora-ldap-rest-a4bb78e/docs/client-development/browser/libraries.md000066400000000000000000001047171527331160600264220ustar00rootroot00000000000000# Browser Libraries - LDAP-Rest This guide covers LDAP-Rest's browser libraries for building LDAP management interfaces in web applications. ## Table of Contents - [Overview](#overview) - [LdapTreeViewer](#ldaptreeviewer) - [Installation](#ldaptreeviewer-installation) - [Basic Usage](#ldaptreeviewer-basic-usage) - [Configuration Options](#ldaptreeviewer-configuration-options) - [Public Methods](#ldaptreeviewer-public-methods) - [TreeNode Structure](#treenode-structure) - [Advanced Examples](#ldaptreeviewer-advanced-examples) - [LdapUserEditor](#ldapusereditor) - [Installation](#ldapusereditor-installation) - [Basic Usage](#ldapusereditor-basic-usage) - [Configuration Options](#ldapusereditor-configuration-options) - [Public Methods](#ldapusereditor-public-methods) - [Features](#ldapusereditor-features) - [CSS Customization](#ldapusereditor-css-customization) - [LdapUnitEditor](#ldapuniteditor) - [Installation](#ldapuniteditor-installation) - [Basic Usage](#ldapuniteditor-basic-usage) - [Features](#ldapuniteditor-features) - [LdapBrowser](#ldapbrowser) - [Installation](#ldapbrowser-installation) - [Basic Usage](#ldapbrowser-basic-usage) - [Configuration Options](#ldapbrowser-configuration-options) - [Public Methods](#ldapbrowser-public-methods) - [RawApiClient and SchemaView](#rawapiclient-and-schemaview) --- ## Overview LDAP-Rest provides ready-to-use browser libraries for building LDAP management interfaces: - **LdapTreeViewer** - Interactive hierarchical tree view of LDAP organizations - **LdapUserEditor** - Complete user management interface with organization tree, user list, and edit form - **LdapUnitEditor** - Organization management interface with create, move, edit, and delete operations - **LdapBrowser** - Low-level, read-only directory browser (tree, raw entries, schema), in the spirit of phpLDAPadmin All libraries are: - **Framework-agnostic** - Work with vanilla JavaScript or any framework - **TypeScript-first** - Full type definitions included - **Material Design** - Clean, modern UI following Material Design principles - **Customizable** - CSS variables and configuration options ### Usage Modes LDAP-Rest browser libraries can be used in two ways: #### 1. ES Module Imports (Recommended for bundlers) When using a bundler (Webpack, Vite, Rollup, etc.) or in modern Node.js applications, import the libraries using the package exports: ```typescript // LDAP Tree Viewer - Main component import LdapTreeViewer from 'ldap-rest/browser-ldap-tree-viewer-index'; // LDAP User Editor - Main component import LdapUserEditor from 'ldap-rest/browser-ldap-user-editor-index'; // Individual components (if needed) import LdapApiClient from 'ldap-rest/browser-ldap-tree-viewer-api-ldapapiclient'; import TreeNode from 'ldap-rest/browser-ldap-tree-viewer-components-treenode'; import UserApiClient from 'ldap-rest/browser-ldap-user-editor-api-userapiclient'; ``` **Available exports:** - **LdapTreeViewer:** - `browser-ldap-tree-viewer-index` - Main entry point - `browser-ldap-tree-viewer-ldaptreeviewer` - LdapTreeViewer class - `browser-ldap-tree-viewer-api-ldapapiclient` - API client - `browser-ldap-tree-viewer-components-treenode` - TreeNode component - `browser-ldap-tree-viewer-components-treeroot` - TreeRoot component - `browser-ldap-tree-viewer-store-store` - State store - `browser-ldap-tree-viewer-store-actions` - Store actions - `browser-ldap-tree-viewer-store-reducers` - Store reducers - `browser-ldap-tree-viewer-types` - TypeScript types - **LdapUserEditor:** - `browser-ldap-user-editor-index` - Main entry point - `browser-ldap-user-editor-ldapusereditor` - LdapUserEditor class - `browser-ldap-user-editor-api-userapiclient` - API client - `browser-ldap-user-editor-components-usereditor` - UserEditor component - `browser-ldap-user-editor-components-userlist` - UserList component - `browser-ldap-user-editor-components-usertree` - UserTree component - `browser-ldap-user-editor-components-pointerfield` - PointerField component - `browser-ldap-user-editor-types` - TypeScript types #### 2. UMD/Direct Browser Usage For direct browser usage without a bundler, include the files via ` ``` ### Basic Usage #### With ES Modules ```typescript import LdapTreeViewer from 'ldap-rest/browser-ldap-tree-viewer-index'; // Create and initialize the tree viewer const viewer = new LdapTreeViewer({ containerId: 'tree-container', apiBaseUrl: 'http://localhost:8081', onNodeClick: node => { console.log('Node clicked:', node.dn); }, onNodeExpand: node => { console.log('Node expanded:', node.dn); }, }); // Initialize the viewer await viewer.init(); ``` #### With UMD (Direct Browser) ```javascript // Access the library from window const { LdapTreeViewer } = window.LdapTreeViewer; // Create and initialize the tree viewer const viewer = new LdapTreeViewer({ containerId: 'tree-container', apiBaseUrl: 'http://localhost:8081', onNodeClick: node => { console.log('Node clicked:', node.dn); }, onNodeExpand: node => { console.log('Node expanded:', node.dn); }, }); // Initialize the viewer await viewer.init(); ``` ### Configuration Options The `ViewerOptions` interface defines all available configuration options: ```typescript interface ViewerOptions { // Required: ID of the HTML container element containerId: string; // Required: Base URL of the LDAP-Rest API apiBaseUrl: string; // Optional: Authentication token for API requests authToken?: string; // Optional: Root DN to start from (defaults to top organization) rootDn?: string; // Optional: Theme ('light' | 'dark') theme?: 'light' | 'dark'; // Optional: Callback when a node is clicked onNodeClick?: (node: TreeNode) => void; // Optional: Callback when a node is expanded onNodeExpand?: (node: TreeNode) => void; } ``` **Example with all options:** ```javascript const viewer = new LdapTreeViewer({ containerId: 'tree-container', apiBaseUrl: 'http://localhost:8081', authToken: 'Bearer your-token-here', rootDn: 'ou=divisions,dc=example,dc=com', theme: 'dark', onNodeClick: node => { document.getElementById('selected-dn').textContent = node.dn; }, onNodeExpand: async node => { console.log( `Expanded ${node.displayName} with ${node.childrenDns.length} children` ); }, }); ``` ### Public Methods #### `async init(): Promise` Initializes the tree viewer and loads the root organization. ```javascript const viewer = new LdapTreeViewer({ containerId: 'tree', apiBaseUrl: 'http://localhost:8081', }); await viewer.init(); ``` #### `async refresh(): Promise` Reloads the entire tree from the API. ```javascript await viewer.refresh(); ``` #### `async expandNode(dn: string): Promise` Programmatically expands a node by its DN. ```javascript await viewer.expandNode('ou=sales,dc=example,dc=com'); ``` #### `async collapseNode(dn: string): Promise` Programmatically collapses a node by its DN. ```javascript await viewer.collapseNode('ou=sales,dc=example,dc=com'); ``` #### `selectNode(dn: string | null): void` Programmatically selects a node (or deselects if `null`). ```javascript viewer.selectNode('ou=engineering,dc=example,dc=com'); ``` #### `getState(): TreeState` Returns the current state of the tree. ```javascript const state = viewer.getState(); console.log('Selected node:', state.selectedNode); console.log('Expanded nodes:', Array.from(state.expandedNodes)); console.log('Total nodes:', state.nodes.size); ``` #### `destroy(): void` Cleans up the viewer and removes it from the DOM. ```javascript viewer.destroy(); ``` ### TreeNode Structure Each node in the tree follows this TypeScript interface: ```typescript interface TreeNode { // Distinguished Name (unique identifier) dn: string; // Display name shown in the tree displayName: string; // Node type: 'organization' | 'user' | 'group' | 'more' type: 'organization' | 'user' | 'group' | 'more'; // Parent DN (null for root) parentDn: string | null; // Array of child DNs childrenDns: string[]; // Whether children have been loaded from API hasLoadedChildren: boolean; // Whether this node can have children hasChildren: boolean; // Raw LDAP attributes from the API attributes?: Record; } ``` **Example TreeNode:** ```javascript { dn: "ou=engineering,dc=example,dc=com", displayName: "Engineering", type: "organization", parentDn: "dc=example,dc=com", childrenDns: [ "ou=backend,ou=engineering,dc=example,dc=com", "ou=frontend,ou=engineering,dc=example,dc=com" ], hasLoadedChildren: true, hasChildren: true, attributes: { ou: "engineering", objectClass: ["top", "organizationalUnit"], description: "Engineering department" } } ``` ### Advanced Examples #### Expand All Nodes Recursively ```javascript async function expandAll(viewer) { const state = viewer.getState(); const nodesToExpand = []; // Find all unexpanded nodes with children state.nodes.forEach((node, dn) => { if (node.hasChildren && !state.expandedNodes.has(dn)) { nodesToExpand.push(dn); } }); // Expand each node for (const dn of nodesToExpand) { await viewer.expandNode(dn); // Recursively expand any new children await expandAll(viewer); } } // Usage const viewer = new LdapTreeViewer({ containerId: 'tree-container', apiBaseUrl: 'http://localhost:8081', }); await viewer.init(); await expandAll(viewer); ``` #### Search and Highlight Nodes ```javascript function highlightNodes(viewer, searchTerm) { const state = viewer.getState(); const matches = []; state.nodes.forEach((node, dn) => { if (node.displayName.toLowerCase().includes(searchTerm.toLowerCase())) { matches.push(node); } }); return matches; } // Usage const viewer = new LdapTreeViewer({ containerId: 'tree-container', apiBaseUrl: 'http://localhost:8081', onNodeClick: node => { document.getElementById('details').innerHTML = `

${node.displayName}

DN: ${node.dn}

Type: ${node.type}

Children: ${node.childrenDns.length}

`; }, }); await viewer.init(); // Search functionality document.getElementById('search').addEventListener('input', e => { const matches = highlightNodes(viewer, e.target.value); console.log(`Found ${matches.length} matches`); }); ``` #### Integration with Custom UI ```javascript const viewer = new LdapTreeViewer({ containerId: 'tree-container', apiBaseUrl: 'http://localhost:8081', onNodeClick: async node => { // Show loading state document.getElementById('sidebar').classList.add('loading'); // Fetch additional data const response = await fetch( `${viewer.options.apiBaseUrl}/api/v1/ldap/organizations/${encodeURIComponent(node.dn)}` ); const data = await response.json(); // Update custom UI updateSidebar(data); // Hide loading state document.getElementById('sidebar').classList.remove('loading'); }, onNodeExpand: node => { // Track analytics analytics.track('Organization Expanded', { dn: node.dn, name: node.displayName, }); }, }); await viewer.init(); ``` --- ## LdapUserEditor A complete user management interface with organization tree, user list, and edit form. ### Installation #### With a Bundler (Webpack, Vite, Rollup, etc.) ```bash npm install ldap-rest ``` ```typescript import LdapUserEditor from 'ldap-rest/browser-ldap-user-editor-index'; // Don't forget to include Roboto font and Material Icons in your HTML ``` #### Direct Browser Usage Include the required dependencies in your HTML: ```html
``` ### Basic Usage #### With ES Modules ```typescript import LdapUserEditor from 'ldap-rest/browser-ldap-user-editor-index'; // Create and initialize the editor const editor = new LdapUserEditor({ containerId: 'editor-container', apiBaseUrl: 'http://localhost:8081', onUserSaved: userDn => { console.log('User saved:', userDn); }, onError: error => { console.error('Editor error:', error); }, }); // Initialize the editor await editor.init(); ``` #### With UMD (Direct Browser) ```javascript // Access the library from window const { LdapUserEditor } = window.LdapUserEditor; // Create and initialize the editor const editor = new LdapUserEditor({ containerId: 'editor-container', apiBaseUrl: 'http://localhost:8081', onUserSaved: userDn => { console.log('User saved:', userDn); }, onError: error => { console.error('Editor error:', error); }, }); // Initialize the editor await editor.init(); ``` ### Configuration Options The `EditorOptions` interface defines all available configuration options: ```typescript interface EditorOptions { // Required: ID of the HTML container element containerId: string; // Optional: Base URL of the LDAP-Rest API (defaults to current origin) apiBaseUrl?: string; // Optional: Callback when a user is saved onUserSaved?: (userDn: string) => void; // Optional: Callback for error handling onError?: (error: Error) => void; } ``` **Example with all options:** ```javascript const editor = new LdapUserEditor({ containerId: 'editor-container', apiBaseUrl: 'http://localhost:8081', onUserSaved: userDn => { // Show success notification showNotification('User saved successfully!', 'success'); // Refresh external data refreshDashboard(); // Log activity logActivity('user_updated', userDn); }, onError: error => { // Show error notification showNotification(`Error: ${error.message}`, 'error'); // Log error to monitoring service errorTracker.captureException(error); }, }); await editor.init(); ``` ### Public Methods #### `async init(): Promise` Initializes the editor and loads the organization tree. ```javascript const editor = new LdapUserEditor({ containerId: 'editor-container', apiBaseUrl: 'http://localhost:8081', }); await editor.init(); ``` #### `async refresh(): Promise` Refreshes the organization tree and currently displayed user data. ```javascript await editor.refresh(); ``` #### `destroy(): void` Cleans up the editor and removes it from the DOM. ```javascript editor.destroy(); ``` ### Features The LdapUserEditor provides a complete user management interface with three main components: #### 1. Organization Tree - **Hierarchical navigation** - Browse LDAP organizations in a tree structure - **Visual indicators** - Material Icons for different node types - **Expand/collapse** - Load children on demand - **Selection highlighting** - Visual feedback for selected organization #### 2. User List - **Filtered by organization** - Shows users in the selected organization - **User details** - Displays name and email for each user - **Click to edit** - Select a user to open the edit form - **Material Design** - Clean, modern list interface #### 3. Edit Form - **Schema-driven** - Automatically generated from JSON schema - **Field validation** - Required fields, regex patterns, custom validation - **Field groups** - Organized sections for better UX - **Multiple input types** - Text, email, select, arrays - **Save/Cancel actions** - With loading states and error handling - **Real-time feedback** - Success/error messages **Complete Example:** ```html User Management

User Management System

``` ### CSS Customization The LdapUserEditor uses CSS variables for easy customization. Override these variables in your stylesheet: ```css :root { /* Primary colors */ --primary-color: #6200ee; --primary-dark: #3700b3; --success-color: #2e7d32; --error-color: #c62828; /* Background colors */ --bg-color: #f8fafc; --surface-color: #ffffff; /* Text colors */ --text-primary: #1e293b; --text-secondary: #64748b; /* Border color */ --border-color: #e2e8f0; } ``` **Example - Custom Theme:** ```css /* Dark theme customization */ :root { --primary-color: #bb86fc; --primary-dark: #9965f4; --success-color: #4caf50; --error-color: #ef5350; --bg-color: #121212; --surface-color: #1e1e1e; --text-primary: #ffffff; --text-secondary: #b0b0b0; --border-color: #333333; } ``` **Example - Custom Branding:** ```css /* Company branding */ :root { --primary-color: #ff6b35; /* Your brand color */ --primary-dark: #d45426; --success-color: #28a745; --error-color: #dc3545; } /* Custom button styles */ .btn { border-radius: 20px; /* Rounded buttons */ text-transform: none; /* No uppercase */ font-weight: 600; } /* Custom panel styles */ .demo-panel { border-radius: 12px; /* More rounded */ box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15); } /* Custom tree node hover */ .tree-node:hover { background: linear-gradient(90deg, var(--bg-color) 0%, transparent 100%); } ``` **Example - Compact Layout:** ```css /* Smaller, more compact layout */ .editor-layout { gap: 16px; /* Less spacing */ } .demo-panel { padding: 16px; /* Less padding */ max-height: 600px; /* Smaller height */ } .form-row { gap: 1rem; /* Tighter form spacing */ margin-bottom: 1rem; } .form-input, .form-select { padding: 0.5rem 0.625rem; /* Smaller inputs */ font-size: 0.8125rem; } ``` **Example - Accessibility Enhancements:** ```css /* High contrast mode */ @media (prefers-contrast: high) { :root { --border-color: #000000; --text-primary: #000000; --text-secondary: #333333; } .form-input:focus, .form-select:focus { border-width: 2px; border-color: #000000; } } /* Larger text for accessibility */ .ldap-user-editor { font-size: 16px; /* Larger base font */ } .form-label { font-weight: 600; /* Bolder labels */ } /* Better focus indicators */ .btn:focus, .form-input:focus, .form-select:focus { outline: 3px solid var(--primary-color); outline-offset: 2px; } ``` --- ## LdapUnitEditor A complete organizational unit (OU) management interface with tree navigation, create, move, edit, and delete operations. ### Installation #### Direct Browser Usage Include the required dependencies in your HTML: ```html
``` ### Basic Usage ```html Organization Management

Organization Management

Create, edit, move, and delete organizational units

``` ### Features The LdapUnitEditor provides a complete organization management interface with two main panels: #### 1. Organization Tree Panel - **Hierarchical navigation** - Browse LDAP organizations in an expandable tree structure - **Material Icons** - Visual indicators with business icons - **Create button** - Quickly create new sub-organizations under selected unit - **Click to edit** - Select an organization to view and edit its properties - **Automatic expansion** - Tree expands to show newly created organizations **Features:** - Create new organizational units under any parent organization - Prompts for OU name with validation - Automatically expands parent after creation - Refreshes tree to show new structure #### 2. Property Editor Panel - **Schema-driven forms** - Automatically generated from JSON schema - **Field validation** - Required fields, type checking - **Field groups** - Organized into "Basic Information" and "Additional Properties" - **Move operation** - Relocate organizations to different parents with modal tree picker - **Delete protection** - Automatically disables delete button when organization has sub-organizations - **Real-time feedback** - Success/error messages via alerts **Features:** - **Edit Properties**: Modify OU name, description, and custom attributes - **Move Organization**: - Opens modal with organization tree - Cannot move into itself or descendants (circular reference prevention) - Shows current organization as disabled in the tree - Updates DN automatically after move - **Delete Organization**: - Button disabled when organization contains sub-organizations - Visual indication (grayed out, cursor not-allowed) - Tooltip explaining why deletion is blocked - Confirmation dialog before deletion - **Validation**: All changes validated against configured schema #### 3. Move Modal When clicking the "Move" button, a modal dialog appears with: - **Organization tree picker** - Navigate and select target parent organization - **Visual feedback** - Current organization shown as disabled - **Expand/collapse** - Load children on demand - **Selection highlighting** - Clear visual indication of selected target - **Circular move prevention** - Cannot select current org or its descendants - **Cancel/Move actions** - Confirm or cancel the move operation **Example Move Operation:** ``` Original: ou=Recruitment,ou=HR,dc=example,dc=com Target: ou=Operations,dc=example,dc=com Result: ou=Recruitment,ou=Operations,dc=example,dc=com ``` All users, groups, and sub-organizations linked to the moved unit automatically update their references. ### Usage Example ```html LDAP Organization Manager

Organization Management System

Manage your LDAP organizational structure - Create, edit, move, and delete organizational units

``` ### API Requirements The LdapUnitEditor requires the following LDAP-Rest API endpoints: - `GET /api/v1/config` - Configuration and schema - `GET /api/v1/ldap/organizations/top` - Top organization - `GET /api/v1/ldap/organizations/:dn` - Get organization details - `GET /api/v1/ldap/organizations/:dn/subnodes` - Get child organizations - `POST /api/v1/ldap/organizations` - Create new organization - `PUT /api/v1/ldap/organizations/:dn` - Update organization - `POST /api/v1/ldap/organizations/:dn/move` - Move organization to new parent - `DELETE /api/v1/ldap/organizations/:dn` - Delete organization See [LDAP Organizations Plugin Documentation](../ldapOrganizations.md) for API details. --- ## LdapBrowser A low-level, read-only browser of the directory: lazy tree on the left, raw content of the selected entry on the right, LDAP filter search on top. Attributes are annotated with the directory schema — mandatory attributes are marked, unknown ones are flagged, binary values are shown as base64 (photos as thumbnails). It consumes the [raw plugin](../../usage/plugins/ldap/raw.md), which must be loaded server-side: ```bash ldap-rest --plugin core/ldap/raw --plugin core/static ``` ### Installation #### Direct Browser Usage ```html ``` The Material Icons font is used for the tree icons: ```html ``` #### With a Bundler ```typescript import { LdapBrowser } from 'ldap-rest/browser-ldap-browser-index'; ``` ### Basic Usage ```html
``` A ready-made demo page ([`examples/web/ldap-browser.html`](../../../examples/web/ldap-browser.html)) is served at `/static/examples/web/ldap-browser.html`. ### Configuration Options | Option | Type | Default | Description | | ----------------- | ------------------------ | ------------------ | --------------------------------------- | | `containerId` | `string` | _required_ | id of the element to render into | | `apiBaseUrl` | `string` | `''` (same origin) | Origin of the API | | `authToken` | `string` | — | Sent as `Authorization: Bearer …` | | `initialDn` | `string` | first exposed base | DN selected on startup | | `onEntrySelected` | `(dn: string) => void` | — | Called whenever an entry is displayed | | `onError` | `(error: Error) => void` | `console.error` | Called on any error the browser handles | ### Public Methods #### `async init(): Promise` Loads the exposed bases and the schema, builds the interface and opens `initialDn`. Throws when the container does not exist or the API is unreachable. A schema that cannot be read is reported through `onError` but does not prevent browsing — the entry table is then displayed without annotations. #### `getCurrentDn(): string | null` DN currently displayed, or `null` before the first selection. #### `destroy(): void` Releases the listeners held by the tree. ### RawApiClient and SchemaView Both are exported for applications that want the data without the interface: ```typescript import { RawApiClient, SchemaView } from 'ldap-rest/browser-ldap-browser-index'; const api = new RawApiClient(window.location.origin); const schema = new SchemaView(await api.getSchema()); const entry = await api.getEntry('uid=alice,ou=users,dc=example,dc=com'); const { must, may } = schema.resolveAttributes( entry.attributes.objectClass.values ); console.log('Mandatory attributes:', must); console.log('Allowed attributes:', may); const { children, truncated } = await api.getChildren('dc=example,dc=com'); const found = await api.search({ base: 'ou=users,dc=example,dc=com', filter: '(mail=*@example.com)', attributes: ['uid', 'mail'], }); ``` `SchemaView` resolves the `SUP` chains for you: `resolveAttributes()` walks object class inheritance, `getAttributeSyntax()` walks attribute type inheritance, and `isBinaryAttribute()` / `isImageAttribute()` tell how to render a value. ### API Integration The browser uses these endpoints: - `GET /api/v1/ldap/raw/bases` - Exposed subtrees, used as tree roots - `GET /api/v1/ldap/raw/schema` - Directory schema - `GET /api/v1/ldap/raw/entry/:dn` - Entry content - `GET /api/v1/ldap/raw/children/:dn` - Children of a node - `GET /api/v1/ldap/raw/search` - Filter search See [LDAP Raw Plugin Documentation](../../usage/plugins/ldap/raw.md) for API details. --- ## Next Steps - **[REST API Documentation](../api/REST_API.md)** - Learn about the underlying API - **[Integration Examples](../examples/EXAMPLES.md)** - See real-world integration examples - **[JSON Schemas Guide](../schemas/SCHEMAS.md)** - Understand schema-driven architecture - **[LDAP Organizations Plugin](../ldapOrganizations.md)** - Learn about organization management --- ## Resources - [GitHub Repository](https://github.com/linagora/ldap-rest) - [Developer Guide](../DEVELOPER_GUIDE.md) - [API Reference](../api/REFERENCE.md) --- ## License AGPL-3.0 - Copyright 2025-present LINAGORA linagora-ldap-rest-a4bb78e/docs/client-development/examples/000077500000000000000000000000001527331160600242455ustar00rootroot00000000000000linagora-ldap-rest-a4bb78e/docs/client-development/examples/README.md000066400000000000000000001170761527331160600255400ustar00rootroot00000000000000# Integration Examples - LDAP-Rest This guide provides complete, production-ready examples for integrating LDAP-Rest into your applications using React, Vue.js, and Vanilla JavaScript. ## Table of Contents - [React Application Example](#react-application-example) - [Vue.js Application Example](#vuejs-application-example) - [Vanilla JavaScript with TreeViewer](#vanilla-javascript-with-treeviewer) - [Group Management Application](#group-management-application) --- ## React Application Example This example demonstrates a complete React functional component that fetches users from LDAP-Rest and displays them in a list. ### Complete React Component ```jsx import React, { useState, useEffect } from 'react'; const UserManager = () => { const [users, setUsers] = useState([]); const [config, setConfig] = useState(null); const [usersEndpoint, setUsersEndpoint] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // Load configuration on component mount useEffect(() => { const loadConfig = async () => { try { const response = await fetch('http://localhost:8081/api/v1/config'); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const configData = await response.json(); setConfig(configData); // Discover users endpoint from config const userResource = configData.features?.flatResources?.find( r => r.pluralName === 'users' ); if (!userResource) { throw new Error('Users endpoint not found in configuration'); } setUsersEndpoint(userResource.endpoints.list); } catch (err) { console.error('Failed to load config:', err); setError(err.message); setLoading(false); } }; loadConfig(); }, []); // Load users when endpoint is discovered useEffect(() => { if (!usersEndpoint) return; const loadUsers = async () => { try { setLoading(true); const response = await fetch(usersEndpoint); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const usersData = await response.json(); setUsers(usersData); setError(null); } catch (err) { console.error('Failed to load users:', err); setError(err.message); } finally { setLoading(false); } }; loadUsers(); }, [usersEndpoint]); // Error state if (error) { return (

Error

{error}

); } // Loading state if (loading) { return (

Loading users...

); } // Success state - display users return (

User Management

Managing {users.length} users in {config?.ldapBase}

{users.length === 0 ? (

No users found

) : ( {users.map(user => ( ))}
Username Full Name Email DN
{user.uid} {user.cn} {user.mail ? ( {user.mail} ) : ( No email )} {user.dn}
)}
); }; export default UserManager; ``` ### Styles (Optional) ```css .user-manager { max-width: 1200px; margin: 0 auto; padding: 2rem; } .header { margin-bottom: 2rem; border-bottom: 2px solid #e2e8f0; padding-bottom: 1rem; } .header h1 { margin: 0 0 0.5rem 0; color: #1e293b; } .subtitle { color: #64748b; margin: 0; } .loading-container, .error-container { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 400px; gap: 1rem; } .spinner { width: 50px; height: 50px; border: 4px solid #e2e8f0; border-top-color: #6200ee; border-radius: 50%; animation: spin 1s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } .error-container { color: #c62828; } .error-container button { padding: 0.5rem 1rem; background: #6200ee; color: white; border: none; border-radius: 4px; cursor: pointer; } .user-table { width: 100%; border-collapse: collapse; background: white; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } .user-table th, .user-table td { padding: 1rem; text-align: left; border-bottom: 1px solid #e2e8f0; } .user-table th { background: #f8fafc; font-weight: 600; color: #1e293b; } .user-table tr:hover { background: #f8fafc; } .username { font-family: monospace; font-weight: 600; } .dn { font-family: monospace; font-size: 0.875rem; color: #64748b; } .no-email { color: #94a3b8; font-style: italic; } .empty-state { text-align: center; padding: 3rem; color: #64748b; } ``` ### Usage ```jsx import React from 'react'; import ReactDOM from 'react-dom/client'; import UserManager from './UserManager'; const root = ReactDOM.createRoot(document.getElementById('root')); root.render( ); ``` --- ## Vue.js Application Example This example shows a complete Vue.js component using the LdapUserEditor library with proper lifecycle management and toast notifications. ### Complete Vue Component ```vue ``` ### HTML Template ```html LDAP User Editor - Vue.js
``` ### App Entry Point (app.js) ```javascript const { createApp } = Vue; import LdapEditorApp from './LdapEditorApp.vue'; createApp(LdapEditorApp).mount('#app'); ``` --- ## Vanilla JavaScript with TreeViewer This example demonstrates a complete HTML page with sidebar tree navigation and content area for displaying node details. ### Complete HTML Page ```html Organization Browser - LDAP-Rest

Node Details

account_tree

Select a node from the tree to view details

``` --- ## Group Management Application This example demonstrates a complete class-based group management application with initialization, group operations, and member management. ### Complete GroupManager Class ```javascript /** * GroupManager - Complete group management application * Provides methods for listing, creating, and managing LDAP groups */ class GroupManager { constructor(apiBaseUrl = window.location.origin) { this.apiBaseUrl = apiBaseUrl; this.config = null; this.groupsEndpoint = null; this.groupsBase = null; } /** * Initialize the manager by loading configuration */ async init() { try { // Load configuration from API const response = await fetch(`${this.apiBaseUrl}/api/v1/config`); if (!response.ok) { throw new Error(`Failed to load config: ${response.status}`); } this.config = await response.json(); // Discover groups endpoints from config const groupsConfig = this.config.features?.groups; if (!groupsConfig || !groupsConfig.enabled) { throw new Error( 'Groups feature is not enabled in LDAP-Rest configuration' ); } this.groupsEndpoint = groupsConfig.endpoints; this.groupsBase = groupsConfig.base; console.log('GroupManager initialized successfully'); console.log('Groups base:', this.groupsBase); return true; } catch (error) { console.error('Failed to initialize GroupManager:', error); throw error; } } /** * List all groups or filter by name pattern * @param {string} matchPattern - Optional pattern to filter groups (e.g., "admin*") * @returns {Promise} Object with group CNs as keys and group data as values */ async listGroups(matchPattern = null) { if (!this.groupsEndpoint) { throw new Error('GroupManager not initialized. Call init() first.'); } try { let url = this.groupsEndpoint.list; // Add filter if provided if (matchPattern) { url += `?match=${encodeURIComponent(matchPattern)}`; } const response = await fetch(url); if (!response.ok) { throw new Error(`Failed to list groups: ${response.status}`); } const groups = await response.json(); console.log(`Found ${Object.keys(groups).length} groups`); return groups; } catch (error) { console.error('Error listing groups:', error); throw error; } } /** * Get a specific group by CN * @param {string} cn - Group common name * @returns {Promise} Group data */ async getGroup(cn) { if (!this.groupsEndpoint) { throw new Error('GroupManager not initialized. Call init() first.'); } try { const url = this.groupsEndpoint.get.replace( ':cn', encodeURIComponent(cn) ); const response = await fetch(url); if (!response.ok) { if (response.status === 404) { throw new Error(`Group "${cn}" not found`); } throw new Error(`Failed to get group: ${response.status}`); } return await response.json(); } catch (error) { console.error(`Error getting group "${cn}":`, error); throw error; } } /** * Create a new group * @param {string} cn - Group common name * @param {Object} options - Additional group options * @param {string[]} options.members - Array of member DNs * @param {string} options.description - Group description * @returns {Promise} Response with success status and DN */ async createGroup(cn, options = {}) { if (!this.groupsEndpoint) { throw new Error('GroupManager not initialized. Call init() first.'); } try { const groupData = { cn: cn, ...options, }; const response = await fetch(this.groupsEndpoint.create, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify(groupData), }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error( errorData.error || `Failed to create group: ${response.status}` ); } const result = await response.json(); console.log(`Group "${cn}" created successfully:`, result.dn); return result; } catch (error) { console.error(`Error creating group "${cn}":`, error); throw error; } } /** * Update a group's attributes * @param {string} cn - Group common name * @param {Object} changes - LDAP modify operations (replace, add, delete) * @returns {Promise} Response with success status */ async updateGroup(cn, changes) { if (!this.groupsEndpoint) { throw new Error('GroupManager not initialized. Call init() first.'); } try { const url = this.groupsEndpoint.update.replace( ':cn', encodeURIComponent(cn) ); const response = await fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify(changes), }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error( errorData.error || `Failed to update group: ${response.status}` ); } const result = await response.json(); console.log(`Group "${cn}" updated successfully`); return result; } catch (error) { console.error(`Error updating group "${cn}":`, error); throw error; } } /** * Delete a group * @param {string} cn - Group common name * @returns {Promise} Response with success status */ async deleteGroup(cn) { if (!this.groupsEndpoint) { throw new Error('GroupManager not initialized. Call init() first.'); } try { const url = this.groupsEndpoint.delete.replace( ':cn', encodeURIComponent(cn) ); const response = await fetch(url, { method: 'DELETE', headers: { Accept: 'application/json', }, }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error( errorData.error || `Failed to delete group: ${response.status}` ); } const result = await response.json(); console.log(`Group "${cn}" deleted successfully`); return result; } catch (error) { console.error(`Error deleting group "${cn}":`, error); throw error; } } /** * Add a member to a group * @param {string} groupCn - Group common name * @param {string|string[]} memberDn - Member DN(s) to add * @returns {Promise} Response with success status */ async addMember(groupCn, memberDn) { if (!this.groupsEndpoint) { throw new Error('GroupManager not initialized. Call init() first.'); } try { const url = this.groupsEndpoint.addMember.replace( ':id', encodeURIComponent(groupCn) ); const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify({ member: memberDn }), }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error( errorData.error || `Failed to add member: ${response.status}` ); } const result = await response.json(); const memberCount = Array.isArray(memberDn) ? memberDn.length : 1; console.log(`Added ${memberCount} member(s) to group "${groupCn}"`); return result; } catch (error) { console.error(`Error adding member to group "${groupCn}":`, error); throw error; } } /** * Remove a member from a group * @param {string} groupCn - Group common name * @param {string} memberDn - Member DN to remove * @returns {Promise} Response with success status */ async removeMember(groupCn, memberDn) { if (!this.groupsEndpoint) { throw new Error('GroupManager not initialized. Call init() first.'); } try { const url = this.groupsEndpoint.removeMember .replace(':cn', encodeURIComponent(groupCn)) .replace(':memberId', encodeURIComponent(memberDn)); const response = await fetch(url, { method: 'DELETE', headers: { Accept: 'application/json', }, }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error( errorData.error || `Failed to remove member: ${response.status}` ); } const result = await response.json(); console.log(`Removed member from group "${groupCn}"`); return result; } catch (error) { console.error(`Error removing member from group "${groupCn}":`, error); throw error; } } /** * Get all members of a group * @param {string} cn - Group common name * @returns {Promise} Array of member DNs */ async getMembers(cn) { try { const group = await this.getGroup(cn); const members = group.member || []; // LDAP attributes are always arrays, ensure we return an array return Array.isArray(members) ? members : [members]; } catch (error) { console.error(`Error getting members of group "${cn}":`, error); throw error; } } /** * Check if a user is a member of a group * @param {string} groupCn - Group common name * @param {string} userDn - User DN to check * @returns {Promise} True if user is a member */ async isMember(groupCn, userDn) { try { const members = await this.getMembers(groupCn); return members.includes(userDn); } catch (error) { console.error(`Error checking membership in group "${groupCn}":`, error); throw error; } } } // Export for use in modules if (typeof module !== 'undefined' && module.exports) { module.exports = GroupManager; } ``` ### Usage Example ```javascript // Initialize the GroupManager const manager = new GroupManager('http://localhost:8081'); async function demonstrateGroupManagement() { try { // Initialize console.log('Initializing GroupManager...'); await manager.init(); // List all groups console.log('\n--- Listing all groups ---'); const allGroups = await manager.listGroups(); console.log('All groups:', Object.keys(allGroups)); // Create a new group console.log('\n--- Creating a new group ---'); const newGroup = await manager.createGroup('developers', { description: 'Development Team', member: ['uid=alice,ou=users,dc=example,dc=com'], }); console.log('Created group:', newGroup.dn); // Get group details console.log('\n--- Getting group details ---'); const groupDetails = await manager.getGroup('developers'); console.log('Group details:', groupDetails); // Add members to group console.log('\n--- Adding members ---'); await manager.addMember('developers', 'uid=bob,ou=users,dc=example,dc=com'); await manager.addMember('developers', [ 'uid=carol,ou=users,dc=example,dc=com', 'uid=dave,ou=users,dc=example,dc=com', ]); // Get all members console.log('\n--- Getting all members ---'); const members = await manager.getMembers('developers'); console.log('Group members:', members); // Check membership console.log('\n--- Checking membership ---'); const isBobMember = await manager.isMember( 'developers', 'uid=bob,ou=users,dc=example,dc=com' ); console.log('Is Bob a member?', isBobMember); // Update group description console.log('\n--- Updating group ---'); await manager.updateGroup('developers', { replace: { description: 'Software Development Team', }, }); // Remove a member console.log('\n--- Removing member ---'); await manager.removeMember( 'developers', 'uid=dave,ou=users,dc=example,dc=com' ); // List updated members const updatedMembers = await manager.getMembers('developers'); console.log('Updated members:', updatedMembers); // Filter groups console.log('\n--- Filtering groups ---'); const filteredGroups = await manager.listGroups('dev*'); console.log('Groups matching "dev*":', Object.keys(filteredGroups)); } catch (error) { console.error('Error in demonstration:', error); } } // Run the demonstration demonstrateGroupManagement(); ``` ### HTML Usage Example ```html Group Management - LDAP-Rest

Group Management

Ready. Click a button to perform an operation.
``` --- ## Next Steps - **[REST API Documentation](../api/REST_API.md)** - Learn about all available API endpoints - **[Browser Libraries Guide](../browser/LIBRARIES.md)** - Detailed documentation for LdapTreeViewer and LdapUserEditor - **[JSON Schemas Guide](../schemas/SCHEMAS.md)** - Understanding schema-driven architecture - **[Developer Guide](../DEVELOPER_GUIDE.md)** - Complete developer documentation --- ## Resources - [GitHub Repository](https://github.com/linagora/ldap-rest) - [API Reference](../api/REFERENCE.md) - [Plugin Development](../plugins/DEVELOPMENT.md) --- ## License AGPL-3.0 - Copyright 2025-present LINAGORA linagora-ldap-rest-a4bb78e/docs/client-development/examples/ldif/000077500000000000000000000000001527331160600251635ustar00rootroot00000000000000twake-mailbox-type-nomenclature.ldif000066400000000000000000000023241527331160600341620ustar00rootroot00000000000000linagora-ldap-rest-a4bb78e/docs/client-development/examples/ldif# Nomenclature LDIF for twakeMailboxType # # This file contains the nomenclature entries for mailbox types: # - group: Simple group without mail functionality # - mailingList: Mailing list (email redistribution) # - teamMailbox: Team mailbox (shared mailbox with IMAP access) # # Installation: # 1. Replace {ldap_base} with your actual LDAP base (e.g., dc=example,dc=com) # 2. ldapadd -x -D "cn=admin,{ldap_base}" -W -f twake-mailbox-type-nomenclature.ldif # Create nomenclature base OU dn: ou=twakeMailboxType,ou=nomenclature,{ldap_base} objectClass: organizationalUnit ou: twakeMailboxType description: Mailbox type nomenclature # Group (no mailbox) dn: cn=group,ou=twakeMailboxType,ou=nomenclature,{ldap_base} objectClass: top objectClass: applicationProcess cn: group description: Simple group without mail functionality # Mailing list dn: cn=mailingList,ou=twakeMailboxType,ou=nomenclature,{ldap_base} objectClass: top objectClass: applicationProcess cn: mailingList description: Mailing list (email redistribution) # Team mailbox dn: cn=teamMailbox,ou=twakeMailboxType,ou=nomenclature,{ldap_base} objectClass: top objectClass: applicationProcess cn: teamMailbox description: Team mailbox (shared mailbox with IMAP access) twake-mailbox-type-schema-amendment.ldif000066400000000000000000000025431527331160600346770ustar00rootroot00000000000000linagora-ldap-rest-a4bb78e/docs/client-development/examples/ldif# LDAP Schema Amendment for twakeMailboxType # # This file contains the schema amendments needed to support mailbox type # classification for groups (group, mailingList, teamMailbox). # # Installation: # ldapmodify -Y EXTERNAL -H ldapi:/// -f twake-mailbox-type-schema-amendment.ldif # # Or add these lines to your existing twake.ldif schema file # Add new attribute type for mailbox type # Insert after line 18 (after twakeListType) dn: cn={3}twake,cn=schema,cn=config changetype: modify add: olcAttributeTypes olcAttributeTypes: {15} ( TwakeAttributType:14 NAME 'twakeMailboxType' DESC 'Type of mailbox for groups' SUP distinguishedName SINGLE-VALUE ) # Modify twakeStaticGroup to include twakeMailboxType # Update line 27 dn: cn={3}twake,cn=schema,cn=config changetype: modify replace: olcObjectClasses olcObjectClasses: {5}( TwakeObjectClass:5 NAME 'twakeStaticGroup' DESC 'Twake: static group' SUP groupOfNames STRUCTURAL MAY ( twakeDepartmentLink $ twakeDepartmentPath $ twakeListType $ mail $ twakeMailboxType ) ) # Modify twakeDynamicGroup to include twakeMailboxType # Update line 28 dn: cn={3}twake,cn=schema,cn=config changetype: modify replace: olcObjectClasses olcObjectClasses: {6}( TwakeObjectClass:6 NAME 'twakeDynamicGroup' DESC 'Twake: dynamic group' SUP groupOfURLs STRUCTURAL MAY ( twakeDepartmentLink $ twakeDepartmentPath $ mail $ twakeMailboxType ) ) linagora-ldap-rest-a4bb78e/docs/client-development/schemas/000077500000000000000000000000001527331160600240525ustar00rootroot00000000000000linagora-ldap-rest-a4bb78e/docs/client-development/schemas/README.md000066400000000000000000001174171527331160600253440ustar00rootroot00000000000000# JSON Schemas Documentation LDAP-Rest uses JSON schemas to define LDAP entity structure, validation rules, and UI behavior. Schemas are the foundation for API operations, data validation, and automatic user interface generation. --- ## Table of Contents - [Schema Structure](#schema-structure) - [Entity Metadata](#entity-metadata) - [Attribute Types](#attribute-types) - [Attribute Properties](#attribute-properties) - [Semantic Roles](#semantic-roles) - [Validation Rules](#validation-rules) - [Predefined Schemas](#predefined-schemas) - [Extending a Schema](#extending-a-schema) --- ## Schema Structure A complete schema consists of two main sections: entity metadata and attribute definitions. ### Complete Example ```json { "entity": { "name": "standardUser", "mainAttribute": "uid", "objectClass": ["top", "inetOrgPerson", "organizationalPerson", "person"], "singularName": "user", "pluralName": "users", "base": "ou=users,dc=example,dc=com", "defaultAttributes": { "cn": "New User", "sn": "User" } }, "strict": true, "attributes": { "objectClass": { "type": "array", "items": { "type": "string", "test": "^[a-zA-Z][a-zA-Z0-9-]*$" }, "default": ["top", "inetOrgPerson", "organizationalPerson", "person"], "required": true, "fixed": true }, "uid": { "type": "string", "test": "^[a-zA-Z0-9._-]{1,255}$", "required": true, "role": "identifier" }, "cn": { "type": "string", "required": true, "role": "displayName" }, "sn": { "type": "string", "required": true }, "mail": { "type": "string", "test": "^[^@\\s]+@[^@\\s.]+(\\.[^@\\s.]+)+$", "required": false, "role": "primaryEmail" }, "mailAlternateAddress": { "type": "array", "items": { "type": "string", "test": "^[^@\\s]+@[^@\\s.]+(\\.[^@\\s.]+)+$" }, "required": false, "role": "emailAliases" }, "mailQuota": { "type": "number", "required": false, "role": "emailQuota" }, "givenName": { "type": "string", "required": false }, "displayName": { "type": "string", "required": false }, "telephoneNumber": { "type": "string", "required": false }, "mobile": { "type": "string", "required": false }, "userPassword": { "type": "string", "required": false }, "departmentNumber": { "type": "string", "test": "^.*,dc=example,dc=com$", "required": false } } } ``` --- ## Entity Metadata The `entity` section defines metadata about the LDAP entity type. ### Entity Properties Table | Property | Type | Required | Description | Example | | ------------------- | -------- | -------- | ---------------------------------------- | ------------------------------------ | | `name` | string | Yes | Internal entity identifier | `"standardUser"`, `"twakeGroup"` | | `mainAttribute` | string | Yes | Primary identifier attribute (RDN) | `"uid"`, `"cn"`, `"sAMAccountName"` | | `objectClass` | string[] | Yes | LDAP objectClass values for new entries | `["top", "inetOrgPerson"]` | | `singularName` | string | Yes | Singular name for API routes | `"user"`, `"group"`, `"position"` | | `pluralName` | string | Yes | Plural name for API routes | `"users"`, `"groups"`, `"positions"` | | `base` | string | Yes | LDAP base DN for this entity type | `"ou=users,dc=example,dc=com"` | | `defaultAttributes` | object | No | Default attribute values for new entries | `{"cn": "New User", "sn": "User"}` | ### Entity Metadata Example ```json { "entity": { "name": "twakeUser", "mainAttribute": "uid", "objectClass": ["top", "twakeAccount", "twakeWhitePages"], "singularName": "user", "pluralName": "users", "base": "ou=users,dc=example,dc=com", "defaultAttributes": { "cn": "New User", "sn": "User", "twakeAccountStatus": "active" } } } ``` ### Base DN Variable Substitution Use `{ldap_base}` or `__ldap_base__` in the base DN to reference the configured LDAP base: ```json { "entity": { "base": "ou=users,{ldap_base}" } } ``` This will be replaced with the actual LDAP base at runtime (e.g., `ou=users,dc=example,dc=com`). --- ## Attribute Types LDAP-Rest supports three main attribute types: `string`, `array`, and `pointer`, plus `number`, `integer`, `boolean` and `date` for single values. What a client sends has to have the shape of the type, see [what a value must look like](#what-a-value-must-look-like). ### String Type Simple text values. **Properties:** - `type`: `"string"` - `test`: Optional regex pattern for validation - `required`: Whether the attribute is mandatory - `default`: Default value for new entries **Example:** ```json { "uid": { "type": "string", "test": "^[a-zA-Z0-9._-]{1,255}$", "required": true, "role": "identifier" }, "mail": { "type": "string", "test": "^[^@\\s]+@[^@\\s.]+(\\.[^@\\s.]+)+$", "required": false, "role": "primaryEmail" }, "telephoneNumber": { "type": "string", "required": false } } ``` ### Array Type Multi-valued attributes containing multiple items of the same type. **Properties:** - `type`: `"array"` - `items`: Object defining the type and validation for array elements - `required`: Whether the attribute is mandatory - `default`: Default array value for new entries **Example:** ```json { "objectClass": { "type": "array", "items": { "type": "string", "test": "^[a-zA-Z][a-zA-Z0-9-]*$" }, "default": ["top", "inetOrgPerson", "organizationalPerson", "person"], "required": true, "fixed": true }, "mailAlternateAddress": { "type": "array", "items": { "type": "string", "test": "^[^@\\s]+@[^@\\s.]+(\\.[^@\\s.]+)+$" }, "required": false, "role": "emailAliases" }, "member": { "type": "array", "items": { "type": "string", "test": "^[a-zA-Z]+=.*,dc=example,dc=com$" }, "required": false } } ``` ### Pointer Type References to other LDAP entries (DN pointers). Used for relationships like group membership, organizational units, or nomenclature references. **Properties:** - `type`: `"pointer"` - `branch`: Array of LDAP base DNs where referenced entries can be found - `ui`: Optional UI configuration for pointer selection (not yet implemented) - `required`: Whether the pointer is mandatory **Example:** ```json { "twakeAccountStatus": { "type": "pointer", "branch": ["ou=twakeAccountStatus,ou=nomenclature,dc=example,dc=com"], "required": true }, "twakeDeliveryMode": { "type": "pointer", "branch": ["ou=twakeDeliveryMode,ou=nomenclature,dc=example,dc=com"], "required": true }, "twakeDelegatedUsers": { "type": "array", "items": { "type": "pointer", "branch": ["ou=users,dc=example,dc=com"] }, "required": false } } ``` **Pointer Arrays:** Pointer types can also be used within arrays to create multi-valued references: ```json { "twakeDelegatedUsers": { "type": "array", "items": { "type": "pointer", "branch": ["ou=users,dc=example,dc=com"] }, "required": false } } ``` ### Number and Integer Types Numeric values for counters, IDs, or quota management. **Example:** ```json { "mailQuota": { "type": "number", "required": false, "role": "emailQuota" }, "mailQuotaSize": { "type": "integer", "required": false }, "userAccountControl": { "type": "number", "required": false } } ``` --- ## Attribute Properties Complete reference for all attribute properties. ### Attribute Properties Table | Property | Type | Applies To | Description | Example | | ----------- | --------------- | ------------------- | ------------------------------------------------------------------------------------- | ----------------------------------- | | `type` | string | All | `"string"`, `"array"`, `"pointer"`, `"number"`, `"integer"`, `"boolean"`, `"date"` | `"string"` | | `required` | boolean | All | Whether the attribute must be present | `true` | | `fixed` | boolean | All | Whether the attribute value cannot be changed after creation | `true` | | `role` | string/string[] | String | Semantic role, or roles, for special handling (see [Semantic Roles](#semantic-roles)) | `"identifier"` | | `test` | string/regex | String, Array items | Regular expression for validation | `"^[a-zA-Z0-9._-]{1,255}$"` | | `maxLength` | number | String, Array items | Longest value accepted, in characters; a longer one is refused before `test` (4096) | `255` | | `branch` | string[] | Pointer | LDAP branches where referenced entries exist | `["ou=users,dc=example,dc=com"]` | | `ui` | object | Pointer | UI configuration for pointer selection (future feature) | `{}` | | `hint` | text | String, Array items | What a valid value looks like, shown under the field and quoted in refusals | `"Expected pattern 999 9999"` | | `group` | text | All | Form section the attribute is shown in | `"Mailbox Settings"` | | `default` | any | All | Default value for new entries | `["top", "person"]` | | `items` | object | Array | Schema for array element validation | `{"type": "string", "test": "..."}` | A _text_ is a plain string or a map keyed by language tag, `{ "en": "Mailbox Settings", "fr": "Paramètres de messagerie" }`, picked as [labels are](../../usage/plugins/ldap/flat-generic.md#naming-things-in-the-readers-language). ### Property Details #### `type` (Required) Defines the data type of the attribute. Supported values: - `"string"`: Single text value - `"array"`: Multiple values of the same type - `"pointer"`: Reference to another LDAP entry (DN) - `"number"`: Numeric value (integer or float) - `"integer"`: Integer value only - `"boolean"`: `true` or `false`, stored as `TRUE` / `FALSE` - `"date"`: LDAP generalized time, as text #### What a value must look like A single-valued attribute takes one value, an `array` a list. A list of one value stands for that value and one value for a list of one, so what a read answers, one value where the attribute holds one, can be sent back as is; two values or more for a single-valued attribute are refused. Each element of an `array` follows `items.type`, text without `items`. A `number` or `integer` is a JSON number or its text (`42`, `"42"`), a `boolean` a JSON boolean or the directory's `TRUE` / `FALSE`. A JSON number is judged on the text written for it, what the directory is sent: `1e21` and `1e-7` are written `1e+21` and `1e-7`, so they are refused like that text, and a whole number beyond 2⁵³ is refused whatever the type, as the JSON parser has already lost its digits. Anything else for them, and anything but text for a `string`, `date` or `pointer`, is refused; an attribute with `normalize` takes any text, read after validation. In a `replace`, `null` or `[]` clears an attribute; in an `add` or on a creation they are refused as no value when the schema declares the attribute. A `null` under a name the schema does not declare — and, for an entity without a schema, under any name — is dropped from a creation and from an `add`, as if the key were left out, and clears the attribute in a `replace`. A `delete` naming the attribute, or naming it with no value, removes it, unless it is `required`; naming values removes those, every value it holds included, as the server does not read what remains, and `''` names no value. An empty string sent as a value is checked against `test`, refused for a pointer, and missing for a `required` attribute. Every refusal answers 400 with the attribute's name. #### What an attribute may be called A name is read as the directory reads it: case and the `;binary` option do not change the attribute it names, so `Cn`, `CN` and `cn;binary` all name `cn`. Each key the schema declares is written under the schema's spelling — `Cn` becomes `cn`, `L;binary` becomes `l;binary` — before any check, so that a `required` attribute counts as present wherever it was sent, and a `fixed` one is written once, at its default. The attribute's type is read the same way when a value is converted, so a boolean sent as `pwdReset;binary` is written `TRUE`. Two keys of an entry or of one part of a change naming one attribute are refused; the option does not make them two, so `l` and `l;binary` are refused together. A name the schema does not declare is left as sent, and a strict schema refuses it. Every entity route applies these rules; `test/lib/entitySchema.contract.test.ts` asserts them on a flat entity, a group and an organization alike. In the server they are enforced by `EntityValidator` (`src/lib/entitySchema/validator.ts`) alone: ESLint refuses the import of the schema helpers anywhere else. #### `required` (Optional, default: `false`) Specifies whether the attribute is mandatory when creating or updating entries. ```json { "uid": { "type": "string", "required": true }, "description": { "type": "string", "required": false } } ``` #### `fixed` (Optional, default: `false`) Marks the attribute as immutable after creation. Useful for objectClass or identifier attributes. ```json { "objectClass": { "type": "array", "required": true, "fixed": true, "default": ["top", "inetOrgPerson"] } } ``` #### `role` (Optional) Assigns semantic meaning to attributes for special handling by the application. See [Semantic Roles](#semantic-roles). An attribute holding several roles lists them: `"role": ["domainName", "displayName"]`. ```json { "uid": { "type": "string", "required": true, "role": "identifier" }, "cn": { "type": "string", "required": true, "role": "displayName" }, "mail": { "type": "string", "role": "primaryEmail" } } ``` #### `test` (Optional) Regular expression pattern for validating attribute values. The value is judged only up to its attribute's length: see [`maxLength`](#maxlength-optional) below, which a deployment writing its own pattern should read. ```json { "uid": { "type": "string", "test": "^[a-zA-Z0-9._-]{1,255}$" }, "mail": { "type": "string", "test": "^[^@\\s]+@[^@\\s.]+(\\.[^@\\s.]+)+$" } } ``` **For arrays**, the test applies to each item: ```json { "mailAlternateAddress": { "type": "array", "items": { "type": "string", "test": "^[^@\\s]+@[^@\\s.]+(\\.[^@\\s.]+)+$" } } } ``` #### `maxLength` (Optional) Longest value, in UTF-16 code units (what `.length` counts, so an emoji or an accented letter outside the basic plane counts as two), accepted before `test` runs on it. A value past it is refused with 400, and the pattern never reads it — whether or not it would have matched. On an array, `maxLength` on the attribute governs every item; one declared under `items` is read only when the attribute declares none. It is a positive whole number of characters: the server refuses to load a schema where it is not. A pattern is written for values of its attribute, not for a payload of any size: handed several kilobytes, a pattern whose parts overlap walks the value in quadratic time, on the event loop that answers every other request meanwhile. The limit keeps the pattern within what it was written for. It is **4096** unless the schema says otherwise; an attribute holding longer values — a description, a public key — declares its own. ```json { "sshPublicKey": { "type": "array", "items": { "type": "string", "maxLength": 16384, "test": "^[^\\r\\n]*$" } } } ``` #### `branch` (Required for Pointers) Specifies one or more LDAP base DNs where referenced entries can be found. ```json { "twakeAccountStatus": { "type": "pointer", "branch": ["ou=twakeAccountStatus,ou=nomenclature,dc=example,dc=com"] } } ``` Use variable substitution for dynamic bases: ```json { "departmentNumber": { "type": "pointer", "branch": ["ou=departments,__ldap_base__"] } } ``` #### `default` (Optional) Provides a default value for new entries. Commonly used with objectClass and fixed attributes. ```json { "objectClass": { "type": "array", "default": ["top", "inetOrgPerson", "organizationalPerson", "person"], "required": true, "fixed": true } } ``` #### `items` (Required for Arrays) Defines the schema for array elements, including type and validation. ```json { "member": { "type": "array", "items": { "type": "string", "test": "^[a-zA-Z]+=.*,dc=example,dc=com$" } } } ``` --- ## Semantic Roles Semantic roles assign special meaning to attributes for UI and business logic handling. ### Available Roles | Role | Description | Example Attribute | Usage | | -------------- | ---------------------------------- | ---------------------------------------- | ------------------------------------- | | `identifier` | Primary identifier (RDN attribute) | `uid`, `cn`, `sAMAccountName` | Used as the entry's unique identifier | | `displayName` | Human-readable display name | `cn`, `displayName` | Shown in lists and UI elements | | `primaryEmail` | Main email address | `mail`, `userPrincipalName` | Primary contact email | | `emailAliases` | Additional email addresses | `mailAlternateAddress`, `proxyAddresses` | Alternative email addresses | | `emailQuota` | Email storage quota | `mailQuota`, `mailQuotaSize` | Storage limit for mailbox | ### Role Usage Examples #### Identifier Role The `identifier` role marks the attribute used as the RDN (Relative Distinguished Name) component of the entry's DN. ```json { "uid": { "type": "string", "test": "^[a-zA-Z0-9._-]{1,255}$", "required": true, "role": "identifier" } } ``` For a user with `uid=jdoe`, the full DN would be `uid=jdoe,ou=users,dc=example,dc=com`. #### Display Name Role The `displayName` role indicates which attribute should be shown in UI lists and summaries. ```json { "cn": { "type": "string", "required": true, "role": "displayName" } } ``` #### Email Roles Email-related roles help applications handle email addresses consistently across different LDAP schemas. **Primary Email:** ```json { "mail": { "type": "string", "test": "^[^@\\s]+@[^@\\s.]+(\\.[^@\\s.]+)+$", "required": false, "role": "primaryEmail" } } ``` **Email Aliases:** ```json { "mailAlternateAddress": { "type": "array", "items": { "type": "string", "test": "^[^@\\s]+@[^@\\s.]+(\\.[^@\\s.]+)+$" }, "required": false, "role": "emailAliases" } } ``` **Email Quota:** ```json { "mailQuota": { "type": "number", "required": false, "role": "emailQuota" } } ``` --- ## Validation Rules LDAP-Rest validates LDAP entries against schemas using the following rules: ### Strict Mode The `strict` property controls validation behavior: ```json { "strict": true, "attributes": { ... } } ``` - **`strict: true`**: Only attributes defined in the schema are allowed - **`strict: false`** or omitted: Additional attributes are permitted ### Required Attributes Attributes with `"required": true` must be present in all create and update operations. ```json { "uid": { "type": "string", "required": true } } ``` ### Pattern Validation The `test` property enforces regular expression validation: ```json { "uid": { "type": "string", "test": "^[a-zA-Z0-9._-]{1,255}$" } } ``` Common validation patterns: **Username/UID:** ```regex ^[a-zA-Z0-9._-]{1,255}$ ``` **Email:** ```regex ^[^@\s]+@[^@\s.]+(\.[^@\s.]+)+$ ``` The domain part holds no dot of its own: where it did, the pattern tried every dot as the last separator and read the value again each time — seconds for a value of a few kilobytes. The result is that a trailing dot (`a@b.c.`) is refused, where the older form accepted it. **DN (Distinguished Name):** ```regex ^[a-zA-Z]+=.*,dc=example,dc=com$ ``` **ObjectClass name:** ```regex ^[a-zA-Z][a-zA-Z0-9-]*$ ``` ### Type Validation Attribute values must have the shape of their declared type, see [what a value must look like](#what-a-value-must-look-like). ### Fixed Attributes Attributes with `"fixed": true` cannot be modified after creation. A creation that sends one **with a default** under an option (`l;lang-fr`, or an alias such as `localityName;lang-fr`) is refused with `400`: the default is written under the attribute's own name, and the option would add a second value beside it. A fixed attribute without a default is set at creation and keeps its options. ```json { "objectClass": { "type": "array", "fixed": true, "default": ["top", "inetOrgPerson"] } } ``` --- ## Predefined Schemas LDAP-Rest includes three sets of predefined schemas for common LDAP directory types. ### Schema Structure Overview ``` static/schemas/ ├── twake/ # Twake Mail schemas │ ├── users.json │ ├── groups.json │ ├── organizations.json │ ├── positions.json │ └── nomenclature/ │ ├── twakeAccountStatus.json │ ├── twakeDeliveryMode.json │ ├── twakeListType.json │ └── twakeTitle.json ├── ad/ # Active Directory schemas │ ├── users.json │ ├── groups.json │ └── organizations.json └── standard/ # Standard LDAP schemas ├── users.json ├── groups.json └── organizations.json ``` --- ### Twake Schemas Schemas for Twake Mail directories with extended attributes for collaboration and messaging. #### Twake Schema Tree ``` twake/ ├── users.json # Twake user accounts │ ├── Entity: twakeUser │ ├── ObjectClasses: top, twakeAccount, twakeWhitePages │ ├── Main Attribute: uid │ └── Special Attributes: │ ├── twakeAccountStatus (pointer to nomenclature) │ ├── twakeDeliveryMode (pointer to nomenclature) │ ├── twakeDelegatedUsers (pointer array) │ ├── twakeDepartmentLink (DN reference) │ └── twakeDepartmentPath (organizational path) │ ├── groups.json # Twake groups │ ├── Entity: twakeGroup │ ├── ObjectClasses: top, groupOfNames, twakeStaticGroup │ ├── Main Attribute: cn │ └── Special Attributes: │ ├── twakeDepartmentLink │ └── twakeDepartmentPath │ ├── organizations.json # Twake departments/OUs │ ├── Entity: (organization schema) │ ├── ObjectClasses: top, organizationalUnit, twakeDepartment │ ├── Main Attribute: ou │ └── Special Attributes: │ ├── twakeDepartmentPath │ └── twakeLocalAdminLink (array) │ ├── positions.json # Job positions │ ├── Entity: twakePosition │ ├── ObjectClasses: top, twakePosition │ └── Main Attribute: cn │ └── nomenclature/ # Controlled vocabularies ├── twakeAccountStatus.json # Account status values ├── twakeDeliveryMode.json # Email delivery modes ├── twakeListType.json # Mailing list types └── twakeTitle.json # Job titles ``` **Usage:** ```bash npx ldap-rest \ --ldap-base 'dc=example,dc=com' \ --ldap-dn 'cn=admin,dc=example,dc=com' --ldap-pwd secret \ --plugin core/ldap/flatGeneric \ --ldap-flat-schema ./static/schemas/twake/users.json \ --plugin core/ldap/groups \ --ldap-groups-schema ./static/schemas/twake/groups.json \ --plugin core/ldap/organization \ --ldap-org-schema ./static/schemas/twake/organizations.json ``` --- ### Active Directory Schemas Schemas for Microsoft Active Directory with Windows-specific attributes. #### Active Directory Schema Tree ``` ad/ ├── users.json # AD user accounts │ ├── Entity: adUser │ ├── ObjectClasses: top, person, organizationalPerson, user │ ├── Main Attribute: sAMAccountName │ └── Special Attributes: │ ├── sAMAccountName (Windows logon name, max 20 chars) │ ├── userPrincipalName (UPN format) │ ├── proxyAddresses (SMTP email aliases) │ ├── userAccountControl (account flags) │ ├── unicodePwd (password attribute) │ ├── accountExpires (expiration timestamp) │ ├── pwdLastSet (password change timestamp) │ └── manager (DN reference) │ ├── groups.json # AD security/distribution groups │ ├── Entity: (AD group schema) │ ├── ObjectClasses: top, group │ ├── Main Attribute: cn │ └── Special Attributes: │ ├── sAMAccountName (group name, max 256 chars) │ ├── groupType (security/distribution flags) │ ├── managedBy (DN reference) │ └── member (DN array, CN-based) │ └── organizations.json # AD organizational units ├── Entity: (AD OU schema) ├── ObjectClasses: top, organizationalUnit └── Main Attribute: ou ``` **Key Differences from Standard LDAP:** - Uses `sAMAccountName` instead of `uid` for user identification - Uses `CN=` based DNs instead of `uid=` or `ou=` - Includes Windows-specific attributes (userAccountControl, unicodePwd) - ProxyAddresses use `SMTP:` or `smtp:` prefixes - Maximum 20 characters for user sAMAccountName - Maximum 256 characters for group sAMAccountName **Usage:** ```bash npx ldap-rest \ --ldap-base 'dc=example,dc=com' \ --ldap-dn 'cn=admin,dc=example,dc=com' --ldap-pwd secret \ --plugin core/ldap/flatGeneric \ --ldap-flat-schema ./static/schemas/ad/users.json \ --plugin core/ldap/groups \ --ldap-groups-schema ./static/schemas/ad/groups.json ``` --- ### Standard LDAP Schemas Schemas for standard LDAP directories using inetOrgPerson and organizationalUnit. #### Standard LDAP Schema Tree ``` standard/ ├── users.json # Standard LDAP users │ ├── Entity: standardUser │ ├── ObjectClasses: top, inetOrgPerson, organizationalPerson, person │ ├── Main Attribute: uid │ └── Attributes: │ ├── uid (identifier) │ ├── cn (common name, displayName role) │ ├── sn (surname, required) │ ├── mail (primary email) │ ├── mailAlternateAddress (email aliases) │ ├── mailQuota (email quota) │ ├── givenName │ ├── displayName │ ├── telephoneNumber │ ├── mobile │ ├── userPassword │ ├── departmentNumber (DN reference) │ └── ou (organizational unit) │ ├── groups.json # Standard LDAP groups │ ├── Entity: (standard group schema) │ ├── ObjectClasses: top, groupOfNames │ ├── Main Attribute: cn │ └── Attributes: │ ├── cn (group name) │ ├── description │ ├── mail (group email) │ ├── member (DN array) │ ├── owner (DN array) │ ├── businessCategory (DN reference) │ └── ou │ └── organizations.json # Standard LDAP OUs ├── Entity: (standard OU schema) ├── ObjectClasses: top, organizationalUnit ├── Main Attribute: ou └── Attributes: ├── ou (organizational unit name) ├── description ├── telephoneNumber ├── facsimileTelephoneNumber ├── l (locality) ├── postalAddress ├── businessCategory (DN reference) └── seeAlso ``` **Usage:** ```bash npx ldap-rest \ --ldap-base 'dc=example,dc=com' \ --ldap-dn 'cn=admin,dc=example,dc=com' --ldap-pwd secret \ --plugin core/ldap/flatGeneric \ --ldap-flat-schema ./static/schemas/standard/users.json \ --plugin core/ldap/groups \ --ldap-groups-schema ./static/schemas/standard/groups.json \ --plugin core/ldap/organization \ --ldap-org-schema ./static/schemas/standard/organizations.json ``` --- ## Schema Comparison ### User Schemas Comparison | Feature | Twake | Active Directory | Standard LDAP | | ----------------- | --------------------------------- | ---------------------------------- | ------------------------ | | Main Attribute | `uid` | `sAMAccountName` | `uid` | | Display Name Role | `cn` | `cn` | `cn` | | ObjectClasses | twakeAccount, twakeWhitePages | user, person | inetOrgPerson, person | | Primary Email | `mail` | `mail` | `mail` | | Email Aliases | `mailAlternateAddress` | `proxyAddresses` | `mailAlternateAddress` | | Department Link | `twakeDepartmentLink` (DN) | `manager` (DN) | `departmentNumber` (DN) | | Special Features | Nomenclature pointers, delegation | AD-specific (UPN, account control) | Standard attributes only | ### Group Schemas Comparison | Feature | Twake | Active Directory | Standard LDAP | | ---------------- | ------------------------------ | ------------------------- | ------------------- | | Main Attribute | `cn` | `cn` | `cn` | | ObjectClasses | groupOfNames, twakeStaticGroup | group | groupOfNames | | Member Attribute | `member` (DN array) | `member` (DN array) | `member` (DN array) | | Owner Attribute | `owner` (DN array) | `managedBy` (single DN) | `owner` (DN array) | | Special Features | Department links | groupType, sAMAccountName | Business category | --- ## Creating Custom Schemas To create a custom schema for your specific LDAP directory: ### Step 1: Choose a Base Schema Start with the schema that most closely matches your directory type: - **Twake**: For Twake Mail or similar collaborative directories - **Active Directory**: For Microsoft AD - **Standard LDAP**: For OpenLDAP, 389 Directory Server, etc. ### Step 2: Define Entity Metadata ```json { "entity": { "name": "customUser", "mainAttribute": "uid", "objectClass": ["top", "inetOrgPerson", "customPerson"], "singularName": "user", "pluralName": "users", "base": "ou=users,{ldap_base}", "defaultAttributes": { "cn": "New User", "sn": "User" } } } ``` ### Step 3: Define Required Attributes ```json { "strict": true, "attributes": { "objectClass": { "type": "array", "items": { "type": "string" }, "default": ["top", "inetOrgPerson", "customPerson"], "required": true, "fixed": true }, "uid": { "type": "string", "test": "^[a-zA-Z0-9._-]{1,255}$", "required": true, "role": "identifier" }, "cn": { "type": "string", "required": true, "role": "displayName" }, "sn": { "type": "string", "required": true } } } ``` ### Step 4: Add Optional Attributes ```json { "attributes": { "mail": { "type": "string", "test": "^[^@\\s]+@[^@\\s.]+(\\.[^@\\s.]+)+$", "required": false, "role": "primaryEmail" }, "telephoneNumber": { "type": "string", "required": false }, "customAttribute": { "type": "string", "required": false } } } ``` ### Step 5: Add Pointer Attributes (if needed) ```json { "attributes": { "customStatus": { "type": "pointer", "branch": ["ou=status,ou=nomenclature,{ldap_base}"], "required": true } } } ``` ### Step 6: Test and Deploy Save your schema and reference it when starting LDAP-Rest: ```bash npx ldap-rest \ --ldap-base 'dc=example,dc=com' \ --ldap-dn 'cn=admin,dc=example,dc=com' --ldap-pwd secret \ --plugin core/ldap/flatGeneric \ --ldap-flat-schema ./path/to/custom-users.json ``` --- ## Extending a Schema To change a few points of a schema, shipped or not, write a file holding only the differences and naming the schema it `extends`. The deployment keeps the later fixes of the base schema, which a copy would miss: ```json { "extends": "/app/node_modules/ldap-rest/static/schemas/twake/users.json", "attributes": { "personalTitle": null } } ``` Point the option at this file as at any schema: `DM_LDAP_FLAT_SCHEMA=/etc/ldap-rest/schemas/users.json`. ### Naming the Base `extends` takes: - `ldap-rest:`: a schema shipped with LDAP-Rest, `` being relative to its `static/schemas` directory, wherever the package is installed and whatever `--schemas-path` says: `"extends": "ldap-rest:twake/users.json"` - an absolute path - any other path, relative to the directory of the extending file: `"extends": "../base/users.json"` The base may extend another file in turn. A loop (`a.json` extends `b.json`, which extends `a.json`) is an error. ### Merge Rules The extending file is applied on its base: - objects are merged key by key, at every depth; the extending file wins - `null` deletes the key - arrays and other values replace those of the base: an `objectClass` list must be written whole - `extends` itself is not part of the result Removing an attribute and changing only the French label of another: ```json { "extends": "ldap-rest:twake/users.json", "attributes": { "personalTitle": null, "cn": { "label": { "fr": "Nom complet" } } } } ``` An attribute added by the extending file comes after the inherited ones, and so does its field in the forms built from the schema. The `__ldap_base__` style placeholders are replaced in each file of the chain before the merge: inherited values get them, and `extends` may hold one. `{ldap_base}` in `entity.base` is resolved on the merged schema. ### Where It Applies Every schema file is read this way: `--ldap-flat-schema`, `--group-schema`, `--organization-schema`, `--bulk-import-schemas`, the SCIM schemas of `--schemas-path`, and the schemas `core/static` serves. A file whose base is missing or invalid is not loaded, as an unreadable file; `core/static` answers it with a 500. ### Served Schemas `core/static` serves the merged schema. The configuration API gives each entity its merged schema inline (`schema`) and, where a static plugin's schema routes serve that very file, its `schemaUrl`, under the plugin's own `--static-name`: - a schema file of the configuration gets the file path from its first `schemas` directory, `/etc/ldap-rest/schemas/users.json` getting `/static/schemas/users.json`. `core/static` serves it at that URL even when it is outside `--static-path`, and before a shipped schema of the same URL: `/etc/ldap-rest/schemas/twake/users.json` is what `/static/schemas/twake/users.json` returns. If two configured files get the same URL, the first one is served there and a warning names the other; - a file the `schemas/` directory of `--static-path` holds, that directory being a link or not, gets its path in it, if it is named `.json`. An option left at its default does not count as configured: without `--group-schema`, a `--static-path` of your own still serves its `schemas/twake/groups.json`, and no `schemaUrl` is given unless that file is the package's, through a link. A static plugin loaded under another name, or with a `static_name` of its own, is asked as well, the one registered as `static` first. A file no static plugin serves merged has no `schemaUrl`, one express.static would send as it stands included (outside `schemas/`, or not named `.json`); its schema is still inline. --- ## Best Practices ### 1. Always Define ObjectClass Include objectClass with fixed default values: ```json { "objectClass": { "type": "array", "items": { "type": "string" }, "default": ["top", "inetOrgPerson"], "required": true, "fixed": true } } ``` ### 2. Use Semantic Roles Assign roles to key attributes for proper handling: ```json { "uid": { "type": "string", "role": "identifier" }, "cn": { "type": "string", "role": "displayName" }, "mail": { "type": "string", "role": "primaryEmail" } } ``` ### 3. Validate with Regular Expressions Use `test` patterns to enforce data quality: ```json { "uid": { "type": "string", "test": "^[a-zA-Z0-9._-]{1,255}$" }, "mail": { "type": "string", "test": "^[^@\\s]+@[^@\\s.]+(\\.[^@\\s.]+)+$" } } ``` ### 4. Use Variable Substitution Reference the LDAP base dynamically: ```json { "entity": { "base": "ou=users,{ldap_base}" }, "attributes": { "departmentNumber": { "type": "string", "test": "^.*,__ldap_base__$" } } } ``` ### 5. Enable Strict Mode Prevent unwanted attributes: ```json { "strict": true } ``` ### 6. Document Custom Attributes Add comments in your schema documentation (not in JSON) explaining custom attributes and their purpose. --- ## Troubleshooting ### Schema Not Loading **Problem**: Schema file not found or fails to parse. **Solution**: - Verify the file path is correct - Check JSON syntax with a validator - Review server logs for detailed error messages ```bash npx ldap-rest --log-level debug ... ``` ### Validation Errors **Problem**: Entries fail validation when creating or updating. **Solution**: - Check that required attributes are provided - Verify attribute values match `test` patterns - Ensure DN pointers reference valid entries - Check that array items match their item schema ### Pointer Attributes Not Working **Problem**: Pointer attributes don't reference entries correctly. **Solution**: - Verify the `branch` DN is correct - Ensure referenced entries exist in LDAP - Check that DN format matches the `test` pattern - Use variable substitution for dynamic bases ### Attribute Not Appearing in API **Problem**: Custom attribute doesn't appear in API responses. **Solution**: - Verify the attribute is in the schema - Check that `strict` mode isn't preventing it - Ensure the attribute exists in LDAP entries - Request it explicitly with the `attributes` query parameter --- ## Additional Resources - **[REST API Documentation](../api/REST_API.md)** - Using schemas with the API - **[Browser Libraries](../browser/LIBRARIES.md)** - Schema-driven UI components - **[Plugin Development](../plugins/DEVELOPMENT.md)** - Creating schema-aware plugins - **[Integration Examples](../examples/EXAMPLES.md)** - Working with schemas in applications --- ## License AGPL-3.0 - Copyright 2025-present LINAGORA linagora-ldap-rest-a4bb78e/docs/development/000077500000000000000000000000001527331160600211535ustar00rootroot00000000000000linagora-ldap-rest-a4bb78e/docs/development/contracts.md000066400000000000000000000172661527331160600235110ustar00rootroot00000000000000# Published contracts Other code depends on three things ldap-rest publishes. A change to one that is not additive breaks that code, so it needs a major release and the pull request says so before it merges. - **Configuration**: the CLI options and `DM_*` variables of [Configuration](../usage/configuration.md) and of each plugin's page. - **REST API**: the routes, request bodies and answers of the [REST API](../client-development/api/rest-api.md), their parameters, responses (references resolved), statuses and response headers. The error `code` values are reviewed by hand: the document types them as strings. - **Plugin API**: what the npm sub-path exports declare — `ldap-rest`, `./hooks`, `./plugin`, `./ldapactions`, `./expressformatedresponses`, the `./plugin-*`, `./abstract-*` and `./browser-*` entries — and the JSON schema format of the `./schema-*` entries. [API stability](../README.md#api-stability) says what a minor or patch release promises. This page says how a change is detected, and what to do when it is not additive. ## Committed snapshots Two snapshots are generated locally and checked in CI, so a change to a contract shows up as a diff instead of a surprise after the release: - `docs/usage/options.snapshot.json` lists every option's CLI name, plural form, environment variable, type, default, kind, required flag and aliases. Regenerate it with `npm run snapshot:options`; `npm run check:options`, part of `npm run check`, fails when it is stale. - `openapi.json` is the REST API document the published reference is built from. Regenerate it with `npm run generate:openapi`; `npm run check:openapi` fails when it is stale. - `etc/api/*.api.md` are the API Extractor reports of the five entry points #595 covers first — `ldap-rest`, `./plugin`, `./hooks`, `./ldapactions` and `./expressformatedresponses` — one file per sub-path. Regenerate them with `npm run snapshot:api`; `npm run check:api`, part of `npm run check`, fails when one is stale. A report lists what the entry exports and the shapes of the types those exports reference, including the types the entry does not export itself, so a change to `RouteDefinition` or `OptionSet` shows up even though a plugin cannot name them. The 66 `./plugin-*` entries come with the modularization, see [the plan](modularization-plan.md). The snapshot records what the parser reads. A narrowed `choices` list, a raised `min` or a lowered `max` is checked when a plugin reads its options, not by the snapshot, and is reviewed by hand, as is a custom error `code`. ## Resolution guard The published package is `"type": "module"`, so a plugin compiled with `moduleResolution: nodenext` resolves the declarations as Node resolves JavaScript: every relative specifier needs its `.js` extension. The build keeps them in the sources, and the declarations inherit them. TypeScript says nothing about a specifier it cannot resolve in `node_modules`: it reports the imported type as `any`, so a plugin loses type checking on the plugin API without a warning. `npm run lint` refuses an extensionless relative specifier under `src/` (the `import-x/extensions` rule), so the mistake shows up locally. `npm run check:declarations` then reads the built declarations for one, and type-checks a small `nodenext` consumer against the package entries installed with only the runtime dependencies: a type the declarations take from a devDependency is missing there too. The consumer checks the library declarations themselves, so a bare import they make must resolve even when the consumer names no type from it. The build drops the browser sources' side-effect CSS imports from the emitted declarations: they say nothing about the JavaScript API, and the consumer has no CSS module for them. The check needs a build: run `npm run build` first. The `test` workflow runs it after the build. ## The pull request gate The `contracts` job of `.github/workflows/contracts.yml` generates the snapshots from the pull request and compares them with the base branch's committed ones. It reports: - additive changes — a new option, route or field — and lets them through; - non-additive changes, and fails unless the pull request carries the `breaking` label. A change is non-additive when it removes or narrows what was published: a removed option or environment variable, a changed type, default, bound, `const`, enum or required flag, a request value that becomes an enum, a request object that stops accepting additional properties, a removed route, method, parameter, response status, response header or field, a request field that becomes required, a response that may now be null, changed security requirements, a removed union variant, a value an alternative of the type closes with an enum or a `const` where the type used to accept any, an untyped request value that becomes a union, an `allOf` that adds a constraint, or a removed declaration in `etc/api/` that a plugin can name — the exported one, or a member of an exported namespace. Renaming a type only reachable through a signature is not a break: nobody can write its name, and the declarations that use it are reported as changed. A changed declaration is printed for review and does not fail the job yet: telling an added optional member from a narrowed union needs more than a line diff, and comes with the `./plugin-*` reports. Renaming an option is a deprecation, not a removal, as long as the old CLI name and environment variable stay in its `aliases` and `envAliases`. Adding a union variant, a field or an optional parameter is additive, and so is a request union with an alternative that still accepts everything the base did — in a response the client reads every alternative, so a field the others drop is still a removal. A `oneOf` is the exception: it refuses a value two alternatives match, so an alternative as wide as the base narrows the others away, and the alternatives are not told apart from one another — a disjoint `oneOf` therefore asks for the label too. Documentation-only edits, such as a summary or description, are ignored. The job runs the scripts from the pull request, so a change to them is reviewed like any other code, as `.github/CODEOWNERS` requires. The `breaking` label records that the maintainers accepted the break, and the `CHANGELOG.md` section must say so: the release gate below reads the section, not the label, and refuses to ship the break as a minor or patch release. The workflow listens to label events, so adding the label runs the job again; it has its own concurrency group, so that event does not cancel the test matrix. To try the comparison locally against your base branch: ```bash npx tsx scripts/contracts.ts --base origin/master ``` ## The release gate `.github/workflows/release.yml` runs `scripts/check-release.ts` on the tag. A non-major tag whose `CHANGELOG.md` section lists `### Breaking Changes` is refused. For a 0.x version, a minor release counts as major, as semantic versioning lets it break. When the tag has no section of its own, the gate reads `## Unreleased`, so a tag cut before the section was renamed is still checked; with neither section it fails. When the CHANGELOG lists no earlier release, only a first `X.0.0` counts as major, so a trimmed file cannot open the gate. ## Deprecating instead of breaking The window and the rules are in [CONTRIBUTING.md](../../CONTRIBUTING.md#deprecating-a-published-contract). The mechanics of an option alias are in [plugin options](../plugin-development/options.md#deprecating-an-option): the old CLI name and environment variable go in `aliases` and `envAliases`, and the server warns at startup while it still reads them. An array option's plural form has no alias slot: renaming it is a breaking change, so it waits for a major release. linagora-ldap-rest-a4bb78e/docs/development/modularization-plan.md000066400000000000000000000221641527331160600254730ustar00rootroot00000000000000# Modularization plan How the remaining architecture work is sequenced: [#397] and the five `code-quality` issues that depend on it or prepare it. The plan is a roadmap, not a contract; update it as the work lands. ## Open issues | Issue | Subject | Role | Size | | ------ | ----------------------------------------------------------------------------------- | ----------------------------- | ------ | | [#397] | Split the repository into `@ldap-rest/*` modules | Final step | Major | | [#593] | Finish the plugin service migration: entity registry, call sites, family boundaries | Prerequisite of #397 | Large | | [#594] | Finish the `ldapActions` split: operation pipeline and façade | Prerequisite of #397 | Medium | | [#595] | Snapshot the plugin API types and gate changes | Safety net, start before #397 | Medium | | [#549] | Architecture pages, decision records, comment check | Independent, start early | Medium | | [#603] | Enable the type-checked ESLint rules the test block leaves out | Independent, quick win | Small | [#397] is still in the `2.0.0` milestone, which is released. Retarget it to a 2.x epic or to 3.0.0 once the compatibility decision below is made. [#593] and [#594] are labelled `Minor`/`backlog` but are the real prerequisites of [#397]; promote them to the same milestone. ## What blocks #397 today The core still imports plugin code: - `src/config/args.ts` imports the option sets of every plugin family. - `src/bin/index.ts` imports `src/plugins/priority.json`. - `src/hooks.ts` imports `ChangesToNotify` from `src/plugins/ldap/onChange.ts` and the SCIM types from `src/plugins/scim/types.ts`. - `src/abstract/ldapFlat.ts` reads `server.loadedPlugins` and uses `requirePlugin`. - `src/lib/ldapActions.ts` is 1,375 lines; `src/lib/ldap/operations.ts` does not exist. - The single package generates 66 `./plugin-*` exports from `rollup.config.mjs`, with no npm workspaces. Between plugin families the coupling is smaller: only the Twake plugins import `ldap`/`scim` (15 imports), plus `TwakeGroups extends LdapGroups`. ## Target architecture An npm workspaces monorepo with an umbrella package that preserves the public API: ```text packages/ core/ @ldap-rest/core plugin-auth/ @ldap-rest/plugin-auth plugin-ldap/ @ldap-rest/plugin-ldap plugin-scim/ @ldap-rest/plugin-scim plugin-storage/ @ldap-rest/plugin-storage plugin-twake/ @ldap-rest/plugin-twake plugin-misc/ @ldap-rest/plugin-misc browser/ @ldap-rest/browser schemas/ @ldap-rest/schemas ldap-rest/ umbrella package (CLI and re-exports) ``` Rules: - `core` depends on no family; a family depends on no other family. Cross-family needs go through services (`src/lib/services`) or hooks. - `TwakeGroups extends LdapGroups` is either the one documented exception or the shared base moves to `core`. - `ldap-rest` stays the entry package: it depends on every package, keeps the CLI and the `core/…` namespace, and re-exports the former sub-paths (`ldap-rest/plugin-auth-authzlinid1`, …). The split is therefore not breaking for users. - The core parser accepts an injected `OptionSet`; the umbrella composes the option sets of every family. `Config` may stay a type-only aggregate in `core`, but runtime option imports leave the core. - Each package builds on its own with a shared configuration; the umbrella re-exports. The Twake-specific `bin/` scripts move to their package or to a `@ldap-rest/cli` package. A package per plugin (`@ldap-rest/authz-linid1`, …) is possible, but start with families and extract optional plugins individually later. Otherwise the first day costs 66 packages and 66 release cycles. ## Phases ### Phase 0 — Guardrails and quick wins 1. [#603]: enable the type-checked rules on `test/**/*.ts`, fix the 31 `no-floating-promises` and 24 `await-thenable` reports, add the block to CI. 2. [#549], part 1: `docs/architecture/README.md` (module map, life of a request, life of a write) and the decision records for the decisions already in place. Add `scripts/check-comments.sh` with a baseline that may only shrink. The GitHub ruleset change can land separately. 3. [#595], part 1: `@microsoft/api-extractor` on the five main entry points (`ldap-rest`, `./plugin`, `./hooks`, `./ldapactions`, `./expressformatedresponses`), reports under `etc/api/`, wired into the `contracts` job. The 66 `./plugin-*` entries wait for phase 3. Outcome: CI catches regressions, the current architecture is documented, and the public types have a baseline. ### Phase 1 — Decouple the core 1. [#593]: - typed entity registry replacing the six hand-written loops; - `GroupsApi`, `OrganizationsApi`, `TrashApi`, `PasswordPolicyApi`, `TwakeGroupsApi`, `AuthenticatorsInfo` service interfaces; - replace the 68 `loadedPlugins` reads and the 17 `requirePlugin` uses; - move `ChangesToNotify` out of `plugins/ldap/onChange.ts` and the SCIM types out of `plugins/scim/` into `src/lib/`; - ESLint: forbid `loadedPlugins` under `src/plugins/**` and imports between plugin families. 2. [#594]: extract the operation pipeline into `src/lib/ldap/operations.ts`, bring `ldapActions.ts` under 400 lines, add the file-size test, move the ESLint exemption, add the type test on `ldap-rest/ldapactions`. 3. Options refactor (needs its own issue): split `src/config/args.ts` into core options and per-family option sets; let the parser/`DM` accept an `OptionSet`; move `priority.json` into the core or make it a parameter. Outcome: `src/hooks.ts` imports no plugin, the core reads no `loadedPlugins`, families do not import each other, and `ldapActions` is a stable façade. ### Phase 2 — Workspaces and extraction 1. Set up npm workspaces, `packages/`, a shared `tsconfig` and per-package builds. 2. Extract `@ldap-rest/core` first, with the `ldap-rest` umbrella depending on it and re-exporting the existing entry points. 3. Extract the families in dependency order: storage → auth → ldap → scim → twake → misc. 4. Extract `browser` and `schemas`. 5. After each step: `npm run test:dev`, `npm run check`, `npm run check:openapi`, `npm run check:options`, and the `contracts` job. 6. Update the Dockerfile, the `bin/` scripts, the examples and the docs. Outcome: a modular repository, with `ldap-rest` still installable and compatible. ### Phase 3 — Contracts and finalization 1. [#595], part 2: one api-extractor report per sub-path, including the `./plugin-*` entries, and non-additive diffs refused by `contracts`. 2. [#549], part 2: the modularization decision record, a shrinking comment baseline, and the `require_code_owner_review` ruleset. 3. Publish the scoped packages and document the deprecation policy for the former sub-paths. 4. Close [#397]. ## Branch strategy Phase 0 lands on `master`: it is non-breaking, and #603 fixes test bugs that matter to the default branch too. Merge `master` into `v3.0` before phase 1, so the 3.0 work starts from a green, guarded base. Phases 1 to 3 land on `v3.0`. ## Order ```text #603 ───────────────┐ #549 (part 1) ──────┼──> Phase 0 #595 (part 1) ──────┘ │ #593 ────────────────┼──> Phase 1 ──> #397 (Phase 2) ──> #595/#549 (part 2) #594 ────────────────┘ ``` [#603] and [#549] part 1 can start in parallel. [#595] part 1 is a short spike. [#593] and [#594] can then run in parallel, but both touch the core: freeze new cross-family imports and new `loadedPlugins` reads from the start of phase 1. ## Decisions to make 1. **Granularity**: packages per family (recommended) or one package per plugin from the start? 2. **Versioning**: a non-breaking internal split in 2.x with the umbrella (recommended), or 3.0.0 with the former sub-paths removed? 3. **`TwakeGroups extends LdapGroups`**: documented exception, or move the shared base into `core`? 4. **`Config` type**: type-only aggregate in `core` (recommended to start), or split per package with declaration merging? 5. **Build**: Rollup per package (consistent with today) or `tsc` with generated exports? ## First actions 1. Retarget [#397] and add the package map and the dependency rule to it. 2. Open sub-issues for [#593] and [#594]; both are too large for one pull request. 3. Start [#603] and [#549] part 1 in parallel. 4. Spike [#595] on the five main entry points. 5. Then [#593]/[#594], then the package extraction. [#397]: https://github.com/linagora/ldap-rest/issues/397 [#549]: https://github.com/linagora/ldap-rest/issues/549 [#593]: https://github.com/linagora/ldap-rest/issues/593 [#594]: https://github.com/linagora/ldap-rest/issues/594 [#595]: https://github.com/linagora/ldap-rest/issues/595 [#603]: https://github.com/linagora/ldap-rest/issues/603 linagora-ldap-rest-a4bb78e/docs/linagora.png000066400000000000000000000105711527331160600211370ustar00rootroot00000000000000‰PNG  IHDRÈ)p@f¹sRGBÙÉ,gAMA±� üa cHRMz&€„ú€èu0ê`:˜pœºQ< pHYs.#.#x¥?vtIMEé ;0ƒ\ÏIDATxÚí]iXTGÖ~{ï¦ihÖfQPDwQ‘h\Fܒàܣ1£æs™øh¢FtƸ&ê7q7Á¨ьf &‘5î(je‡¦�hz½ß>EæÖín ™±ÏóðçVÝ:ÕçÖ[õžS§ þEÒüãâ$è @‡8¤íK5€tGŒÉO5>+`½ŒGôwIÙÀ>àËÜڤ5teåșñ‰cX8„$wŒ‰ÉO}ò iþq�. â:+Bÿü>¤íƒ۴j•%¸Ùc’c(8„I ô�ÉOÍf§ùDZ¤tTDlý¸̓Ã!±A|¤¤ùDZØ&èÕ~åBð¥.Ó8Ä!õÒÀX6€©¢QÑp ïè0‰CÒX¦pôp‹íã0Å+(¼#C.ú‡¹Х=Ø,‡a$Š À“#3֨zš]¹šö\(÷€Dáo³&MnêTeôvÜe�ÔÁX]C|Ÿ#@ֹ“U=5…E¨-RҞ;* òpo‘µÄcû‚ë)£=×Þ}}ÚïÍn—íî٬1píÖb…ø ø.€łY§‡^£�¾JƒšüT޾‡òí'@Õš¬‡ê ùŒ±�õìg…?¸"a£r³Áˆš‚B¨32¡&Lê*Ԝ¸l_„ , ³†òÜÏ(Y{€þææùMˆ*í Š–í¢=÷^;’Äz€ä9�ª¿ýÈØF—Ôݐ†¶·¨§ìÆmäÍI¦==þi‹ÂösA—õ+À‰he~ǽ¡sšÜ&7Ì þ˒àտm°>×+àC(p‡ÐÃ.í‚à3°?Œ3¦¢ø—KÈ_ÿLO˭÷Ý_ ÿUsà7¡�¹� IP$APŒ Õõ›Èް †kÙ̠S¸£ëêeMúݔÑM^ʯßBñgaÊV7ë›þÏxõëMˆN‡ß2ށ鑪Åa¿Lë]ÖæÏa6ÿ#º½L&‚\;†B8´k“ړ-ú#¢Nì†ߐXFp0ËÉ þ#† û齐Îf±®sB ºÿ}/#‡Zmeàr ïÝQ‡·Ã}i‚]mÉârሠqo"ê̗p�ÿFÓWÞðˆîF.à=×>aü— Ú37PøÓ/ÿÝÞq-|q|ßoûº½y:-ž žĹE}¸JÑ~Î4€Efnïţëú•º˚­ƒë$B؂$ømYØ*vå»HÐéƒy�&�hڄ5{8sù þ¯@ wùԕ•ÿ[uJß'/‹u<{E�í/µږçʷøLô�¢Prõ ö§ ö»k��ïà7t08BÑ/ y/ 7]̖£PÆÚZ¨Á�¼0/DÙJà҈ÎPÃ:@|F ³é{¸uìa\gԥÞ{u(Ö3y²f LuºÖ_=f$€ÅáЀVK{&öõ†Ëô×ɫÇЮpïN^5JT¸ýö"Fp€¹° ys6âþæx¸m7*??C¬'ÛÝ#ˆe¦º:Ü۸�Æ-¥� ×s�77·æ,EMQ1± '/Ox¯�Ùl{(QtžìGòݭ'Ç bÃá֩�ZÂèfÁwڄ¶$ôÙÌð[6rO�ie½|xõëE¤&?�i†Ϙ‘d§qÂ("}1Œ¸¿z 7smêSÅöÓ(ÿëqf@'¼IÖc4â~òvTl?m��¿‹»3–B§® ¯”¯ j‘]M5µÍ~×wFñ÷==| f£‘àFƒíçҶ¢شˆø¼pÉNTç¶š^Ï?MϙNUJ¯ÝDÉáS ÌfZ™{—pðzÑ>]ÂB‰:T¿݀–0›7ÏAd�‰EE.¢r÷÷¶Ïôx¼óoäUÊÏÂa‘Íî¦Gÿ^d�šj˃Uî yïhúdY]�’� â÷ÇÄ(œ|Áä¶탸GE¢fÉ”%§ÐøtÖÖ=ˆذ ,¶ý±î3„~×!4žî33¹W74T>ÞĶJ¾ÿ‰QÀŽ%�thoµŸ)§P±ç{ˆÇ÷OìD_=ôä'ïmòï¯øü 4SÇA@ç÷q}QôÃmæÁÌãÁy|}¤‰%äƒÅãA¤€<¶\‚ƒˆïTe>´Øùâ)Ľ(յ[€™BIêEÈÂþáãó¶í¤¿=)ç`Êi¼ëZ}ôW(G_�÷€¾vÕçßq£É3þ¥« TkÏ ˜tô@ßEÏ% ;¹üðò PWF³ÝúÌq&çÔÕ7»Íêû䳦ìÊ[§†žü”FÏXE:sľôU¸¦ µ§ê¿ ¥5 ôV™ ďhV×^ª(–wL_ˆbèNߣRdùÆnzxÝp�ìJ *z¨ö|Ó(²Tž‘Iî밸†zÕZr€kQP�aÃTÛ|§ج­c Pö!fƒ�÷E٧_Y¦»ñ#­NX :K¦¬².�ÀïӮm„Åá da™f|rÛˆI‰@}ÿ!Ìyj°øœçªó¿Ûp „ӘúcúŒ<2¥°eNö›ˆÍäÕHЂ$M¡ :�¾E}5hªQôKn$.‚rÕÿZ¬ËïÕ².túH™LP<Õè{hŽüLÌgq8ð™9±íú Ï}…|“ç¡pÉZYÖÆ��ˆm™6 ^ þŒ{dWôËúÑvš6)�O^Ìtê Ü\éÎîôQ(۔B|_}ýN= k°ˆ�áx‚6'Ÿø\ØlsHÂ;�éa™å ÜÚb%”ÓÁ ð‡á/\òÿ3�ýA2ÌÊjëÁ’¤‰ÄՖÅá ×é/mþ-^ý{#WÌUcûQ�—r£0 ~$ø½éËeݏw Üv¸Em{,��«Ô.ýôˆŠ'¬ށ®aG{ eÞS(^¹Y“W6úÓW2ûZÕ?“ÃÅ"/O¸$iºO,ƒG7ò¦£ÆÊj­SW pñ6äÍIFY}'[(sƒb�õH–€ ¯þ½íC���á¹djۥX/:ˆíV‘�kzR֢¶}†ÇٯŸ¼gÕ/ëeWo븅‡A¶xœ]ônç¡:—¼Š'½ðšæó¬YØ(ÿëE¿¡â¨í¡œû@™èôÏ7n œF÷°ø®ç‡佨fÓ硱m õt§ Üޱk›Noö„‹�ouy–v]ºë9@‡¹Ó!}w¸]ô©ү2PS„~µ°ñ Ÿ÷†Ùð��!–•ß̈́)¯Âæ>iÏހêúMúàãrÑnñ,»h«”1ÀΓµ>@Š–îĥöÃ-þݘþ~«‚¤Ýô)-J# ÍhSþh÷>>K»6+«QpîãJÓyÅbíû3xÝdg±“7|7̓ÀÍ2ý+þëAèv¤形~~øý™÷V8A2X�öSÈç[(ŠBþþcM¶CÎæ=0èÜ_Òò�ɠž4’@…ݿ‰ïøѭï¤S&3`²Ã7S¦VˆP憀�"{ÚږӡPOxD‘O¨©®ßBá±ï,ó[™áKÉ)ø>£†B½í[,Þ÷ènpö÷%:œ~¯ ‚ol ªó QWZ ÊL�ÅaC$—Cìã Çú|f.¨BÎW'úîÛdJÖÑ·!÷ô÷(XôYc:³ò-´›:\1sdM™~5Çӛl_ý¥GP¦]†Ïà´² „1(ûê,LKä 䓆F­™k7[Ÿ@ç$™p,Ü=²+¸~0Þ)h{Q,Úl;%cz£ö䕖qÓy“Á�SыŽ}‡êÃ?[m£rìhâÅ{na!݅LÈóÅå dñì–;o G4k•%ÐØ(I½È`e|ë)‹.5|­ª´UíòhîjhrÈ”ٌÒzâ¢zû·Ä4~ÐWi�¹b�M—D0‰1£…çɶôêÓ ’·7|�¤ÆӐ%g²ÍۙSy£Ók@_Zè¹M$Að^—Ôì÷ÝŽ��a3­ôÊuÛä­_3:âòÞ=Àö¬߿¨ûÇmܞ0Åÿ¼¸¹gÍ5¡ôÆmT¦¦©ÖÝ)‹¡¾÷€VVõ$ÆÛôh¥3¢‚�Z¢Uª�±ø#âéŦJÁ§{` ]íÄf¡ýü™õ©ì,¼bÈ{QÚªö�·I—¹° ¥7É}¸Já±ÔúÅV)W,† OH³ŒÁ÷òlhG$"¶óbbÏՅXǖLÝÀ±o *íLåU䙀Ë|6Ûc@¹\EA¹ï¸í¶¢Ê^†¼79t)K…Òä¯ëéÌã2dM\ŽÂÝá›8î‘]7ÿšœ { const data = await someAsyncOperation(); res.json(data); }) ); // ❌ Wrong: Errors can crash the server app.get('/api/data', async (req, res) => { const data = await someAsyncOperation(); res.json(data); }); } } ``` The `asyncHandler` is exported from: - `ldap-rest/abstract/plugin` (recommended for plugins) - `ldap-rest` (for external use) See [Exposing API Endpoints](#exposing-api-endpoints) for detailed examples. --- ## Creating Your Plugin ### Option 1: Core Plugin (Inside ldap-rest Repository) Create a file in `src/plugins//.ts`: ```typescript /** * @module plugins/notification/webhook * @author Your Name * * Webhook notification plugin that sends HTTP requests on LDAP changes */ import type { Express, Request, Response } from 'express'; import DmPlugin, { type Role, asyncHandler } from '../../abstract/plugin'; import type { DM } from '../../bin'; import type { Hooks } from '../../hooks'; export default class WebhookNotifier extends DmPlugin { name = 'webhookNotifier'; roles: Role[] = ['api'] as const; // Declare dependencies (optional) dependencies = { ldapGroups: 'core/ldap/groups', }; constructor(server: DM) { super(server); // Validate configuration if (!this.config.webhook_url) { throw new Error('--webhook-url is required'); } this.logger.info('WebhookNotifier initialized'); } // Register API endpoints api(app: Express): void { app.get( `${this.config.api_prefix}/v1/webhook/status`, asyncHandler(async (req, res) => { res.json({ status: 'active', url: this.config.webhook_url, }); }) ); } // Register hooks hooks: Hooks = { ldapadddone: async ([dn, entry]) => { this.logger.info(`User added: ${dn}`); await this.sendWebhook('user.added', { dn, entry }); }, }; // Private methods private async sendWebhook(event: string, data: unknown): Promise { try { await this.fetch(this.config.webhook_url as string, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event, data }), }); } catch (error) { this.logger.error('Webhook failed:', error); } } } ``` Load with: ```bash npx ldap-rest \ --plugin core/notification/webhook \ --webhook-url https://example.com/webhook ``` ### Option 2: External Plugin (Outside Repository) Create a standalone file anywhere on your system, e.g., `/opt/custom-plugins/my-plugin.ts`: ```typescript /** * External plugin example * This plugin can be loaded from anywhere */ import type { Express, Request, Response } from 'express'; // Import from ldap-rest import DmPlugin, { asyncHandler } from 'ldap-rest/plugin'; import type { DM } from 'ldap-rest'; import type { Hooks } from 'ldap-rest/hooks'; export default class ExternalAuditPlugin extends DmPlugin { name = 'externalAudit'; constructor(server: DM) { super(server); // Access custom configuration options const auditPath = this.config.audit_path || '/var/log/ldap-audit.log'; this.logger.info(`Audit log path: ${auditPath}`); } api(app: Express): void { app.get( `${this.config.api_prefix}/v1/audit/stats`, asyncHandler(async (req: Request, res: Response) => { res.json({ message: 'External plugin API', auditEnabled: true, }); }) ); } hooks: Hooks = { ldapaddrequest: async ([dn, entry, req]) => { this.logger.debug(`[AUDIT] Adding: ${dn}`); // Your audit logic here return [dn, entry, req]; }, ldapmodifyrequest: async ([dn, changes, op]) => { this.logger.debug(`[AUDIT] Modifying: ${dn}`); // Your audit logic here return [dn, changes, op]; }, }; } ``` Load with: ```bash npx ldap-rest \ --plugin /opt/custom-plugins/my-plugin.ts \ --audit-path /var/log/ldap-audit.log ``` Or via environment variable: ```bash export DM_PLUGINS=/opt/custom-plugins/my-plugin.ts export DM_AUDIT_PATH=/var/log/ldap-audit.log npx ldap-rest ``` --- ## Custom Configuration Options A core plugin declares its options next to its code, in a `.options.ts` file, and reads them through `this.options()` with their types, defaults and checks. See [plugin options](options.md). A plugin outside the repository cannot add to `src/config/args.ts` yet. It keeps reading `this.config.`: LDAP-Rest accepts and stores unknown `--option-name value` pairs under `option_name`, with `--option-name` and `DM_OPTION_NAME` overriding in that order. See [configuration](../usage/configuration.md#environment-variables). --- ## Loading Your Plugin ### Core Plugin ```bash # Load by path within src/plugins/ npx ldap-rest --plugin core/notification/webhook # Load multiple plugins npx ldap-rest \ --plugin core/ldap/groups \ --plugin core/notification/webhook \ --plugin core/static ``` ### External Plugin ```bash # Load by absolute file path npx ldap-rest --plugin /path/to/my-plugin.ts # Load multiple external plugins npx ldap-rest \ --plugin /opt/plugins/audit.ts \ --plugin /opt/plugins/validation.ts ``` ### Via Environment Variable ```bash # Single plugin export DM_PLUGINS=core/notification/webhook npx ldap-rest # Multiple plugins (comma-separated) export DM_PLUGINS=core/ldap/groups,core/static,/opt/plugins/custom.ts npx ldap-rest ``` ### Plugin Loading Order Some plugins must load before others (e.g., authentication plugins). Edit `src/plugins/priority.json`: ```json [ "core/auth/token", "core/auth/llng", "core/auth/openidconnect", "core/auth/authzPerBranch", "core/myNewAuthPlugin" ] ``` Plugins in this list load first, in order. All other plugins load after. --- ## Plugin Features ### Declaring Dependencies Ensure required plugins load before yours: ```typescript export default class MyPlugin extends DmPlugin { name = 'myPlugin'; dependencies = { // Key is how you reference it, value is the plugin path groups: 'core/ldap/groups', users: 'core/ldap/flatGeneric', }; constructor(server: DM) { super(server); // Access dependent plugin const groupsPlugin = this.server.loadedPlugins['ldapGroups']; if (groupsPlugin) { this.logger.info('Groups plugin is available'); } } } ``` ### Using LDAP Operations The `this.server.ldap` object provides access to all LDAP operations: > **Say whose each operation is.** > > The authorization plugins (`core/auth/authzPerBranch`, > `core/auth/authzDynamic`, …) hook `ldapsearchrequest`, `ldapaddrequest`, > `ldapmodifyrequest`, `ldaprenamerequest` and `ldapdeleterequest`, and judge > an operation by the request it carries. The methods below take it as a > trailing optional argument. > > In a request handler, bind once and use the result: > > ```typescript > const directory = this.server.ldap.forRequest(req); > await directory.search({ scope: 'base' }, dn); > await directory.modify(dn, changes); > ``` > > An operation that names no request while an authenticated request is being > served is made for that request all the same, and judged as its caller: a > forgotten argument fails closed. Work that is the server's — a uniqueness > check has to see a whole branch, and a reference to an entry the caller > cannot read is still a valid reference — goes through `ldap.system`, which > steps out of the request, with a comment saying why: > > ```typescript > // As the server: a name is taken whoever may read the entry holding it > await this.server.ldap.system.search({ scope: 'base' }, dn); > ``` > > A lint rule refuses an operation that names neither a request nor > `ldap.system`. Hooks run outside the request: what a hook reads or writes > to validate, complete or follow an operation is the server's, and a hook > acting as the caller passes the request it is handed. Startup and > scheduled tasks run outside any request. > **Read as the server? Judge the caller first.** > > A read the server makes for a request answers the caller with what it > found: a 404 for an entry that is not there, a 400 for a pointer leading > nowhere, a 409 for a name taken. Made before the write it prepares is > judged, it tells a caller who may not make that write what the directory > holds where they cannot read. Have the authorization plugins judge the > write first, without making it: > > ```typescript > await this.server.ldap.authorize('ldaprenamerequest', [dn, newDn, req]); > // only now: does dn exist, is newDn free, … > ``` > > It runs the hooks of the plugins with the `authz` role only, and refuses > with their 403 whether the entry is there or not. The write, when made, > is judged again with every other hook. > > A route that finds an entry as the server, across branches, and then > answers about it, checks that the caller can read what it found, and > answers as for an entry that is not there otherwise: > > ```typescript > if (!(await this.server.ldap.readableBy(dn, req))) > throw new NotFoundError(`User ${id} not found`); > ``` #### Search ```typescript // Basic search const results = await this.server.ldap.search( { paged: false, scope: 'sub', // 'base', 'one', or 'sub' filter: '(objectClass=inetOrgPerson)', attributes: ['cn', 'mail', 'uid'], }, 'ou=users,dc=example,dc=com' ); // Search with pagination (returns AsyncGenerator; the connection is taken // when the first page is read) const pagedResults = await this.server.ldap.search( { paged: true, filter: '(uid=*)', attributes: ['cn', 'mail'], }, 'dc=example,dc=com' ); for await (const page of pagedResults) { page.searchEntries.forEach(entry => { console.log(entry.dn, entry.cn); }); } ``` #### Add ```typescript await this.server.ldap.add('uid=john,ou=users,dc=example,dc=com', { objectClass: ['inetOrgPerson', 'person', 'top'], uid: 'john', cn: 'John Doe', sn: 'Doe', mail: 'john@example.com', userPassword: 'secret123', }); ``` #### Modify ```typescript await this.server.ldap.modify('uid=john,ou=users,dc=example,dc=com', { replace: { mail: 'john.doe@example.com', telephoneNumber: '+1234567890', }, add: { description: 'Senior Developer', }, delete: { oldAttribute: 'valueToRemove', }, }); ``` #### Attribute names An add or a modify names its attributes as the server does before the authorization plugins and the hooks see them: `MAIL`, `rfc822Mailbox` or the OID of `mail` reach a hook as `mail`, spelled as the configuration or a loaded schema spells it. A hook may therefore look an attribute up by that name. An option other than `;binary` is refused, as is one attribute named twice in one part of a write. A route that checks a body before handing it to `add` or `modify` names its attributes the same way first, and a plugin loading a schema of its own says how it spells them: ```typescript await this.server.ldap.canonicalChanges(changes); // or canonicalAttributes(entry) this.server.ldap.knowAttributeNames(Object.keys(schema.attributes)); ``` #### Delete ```typescript // Delete single entry await this.server.ldap.delete('uid=john,ou=users,dc=example,dc=com'); // Delete multiple entries await this.server.ldap.delete([ 'uid=user1,ou=users,dc=example,dc=com', 'uid=user2,ou=users,dc=example,dc=com', ]); ``` ### Registering Hooks Hooks let you intercept and modify LDAP operations: ```typescript hooks: Hooks = { // LDAP ADD HOOKS ldapaddrequest: async ([dn, entry, req]) => { // Called BEFORE adding an entry // Validate, modify entry, or prevent operation this.logger.debug(`Adding entry: ${dn}`); // Add computed field entry.createdAt = new Date().toISOString(); // Validation if (!entry.mail) { throw new Error('Email is required'); } return [dn, entry, req]; }, ldapadddone: async ([dn, entry]) => { // Called AFTER successful add this.logger.info(`Entry added: ${dn}`); // Trigger side effects, notifications, etc. }, // LDAP MODIFY HOOKS ldapmodifyrequest: async ([dn, changes, opNumber]) => { // Called BEFORE modifying an entry this.logger.debug(`Modifying entry: ${dn}`); // Add audit trail if (!changes.add) changes.add = {}; changes.add.modifiedAt = new Date().toISOString(); return [dn, changes, opNumber]; }, ldapmodifydone: async ([dn, changes, opNumber]) => { // Called AFTER successful modify this.logger.info(`Entry modified: ${dn}`); }, // LDAP DELETE HOOKS ldapdeleterequest: async dn => { // Called BEFORE deleting entry this.logger.debug(`Deleting entry: ${dn}`); // Prevent deletion of admin if (dn.includes('cn=admin')) { throw new Error('Cannot delete admin user'); } return dn; }, ldapdeletedone: async dn => { // Called AFTER successful delete this.logger.info(`Entry deleted: ${dn}`); }, // CUSTOM HOOKS (from other plugins) onLdapChange: async (dn, changes) => { // React to any LDAP change this.logger.info(`Change detected on ${dn}`); }, }; ``` ### Exposing API Endpoints Declare routes with [`defineRoute`](routes.md): it types and validates the request (`params`, `query`, `body`), answers a failing request with a 400 naming the paths at fault, and records the route for the OpenAPI document. The `asyncHandler` form below remains for routes that have not been migrated yet. Create REST API endpoints for your plugin. Always use the `asyncHandler` wrapper for async routes, so that errors are properly handled and do not crash the server. ```typescript import DmPlugin, { asyncHandler } from '../../abstract/plugin'; api(app: Express): void { // GET endpoint with asyncHandler app.get( `${this.config.api_prefix}/v1/myplugin/status`, asyncHandler(async (req: Request, res: Response) => { const status = await this.getStatus(); res.json({ success: true, data: status }); }) ); // POST endpoint with body validation app.post( `${this.config.api_prefix}/v1/myplugin/process`, asyncHandler(async (req: Request, res: Response) => { // Validate request body if (!req.body || !req.body.data) { return res.status(400).json({ success: false, error: 'data is required' }); } const result = await this.processData(req.body.data); res.json({ success: true, data: result }); }) ); // PUT endpoint app.put( `${this.config.api_prefix}/v1/myplugin/config`, asyncHandler(async (req: Request, res: Response) => { await this.updateConfig(req.body); res.json({ success: true }); }) ); // DELETE endpoint app.delete( `${this.config.api_prefix}/v1/myplugin/cache`, asyncHandler(async (req: Request, res: Response) => { await this.clearCache(); res.json({ success: true, message: 'Cache cleared' }); }) ); this.logger.info('MyPlugin API endpoints registered'); } ``` #### Parsing request bodies The server parses `application/json` (up to 100 kB) and `application/x-www-form-urlencoded` bodies before authentication runs, so that `core/auth/hmac` can check them. A plugin that needs another media type or limit under its prefix registers its own parser, which runs ahead of those: ```typescript import { jsonBodyParser } from '../../lib/rawBody'; api(app: Express): void { this.server.registerBodyParser('/myplugin', jsonBodyParser({ limit: '1mb' })); } ``` `jsonBodyParser` keeps the bytes it reads, which `core/auth/hmac` hashes. Any other parser must pass `keepRawBody` as `verify`, or `core/auth/hmac` refuses its bodies with `401`. A parser mounted with `app.use()` in `api()` runs after authentication, too late for that check. #### Error Handling Best Practices The `asyncHandler` wrapper automatically catches errors and passes them to the global error middleware. This ensures: - **Server stability**: Exceptions in routes won't crash the server - **Consistent error responses**: All errors return proper HTTP 500 responses - **Automatic logging**: Errors are logged with full stack traces - **Clean code**: No need for try/catch blocks in every route **Without asyncHandler (DON'T DO THIS):** ```typescript // ❌ BAD: Unhandled async errors can crash the server app.get('/api/data', async (req, res) => { const data = await fetchData(); // If this throws, server crashes res.json(data); }); ``` **With asyncHandler (RECOMMENDED):** ```typescript // ✅ GOOD: Errors are caught and handled gracefully app.get( '/api/data', asyncHandler(async (req, res) => { const data = await fetchData(); // If this throws, error is logged and 500 returned res.json(data); }) ); ``` **Wrapping an error keeps its answer:** Wrap with `cause` rather than in the message alone. The status and the class of what was thrown are read through the chain, so a refusal stays a 403 and a timeout a 504 wherever it surfaces: ```typescript // ✅ GOOD: the refusal keeps its 403 when it reaches the client throw new Error(`Failed to add group ${dn}: ${String(err)}`, { cause: err }); // ❌ BAD: a 403 becomes a 500, and its message is all that is left throw new Error(`Failed to add group ${dn}: ${String(err)}`); ``` **Refusing a caller:** An authorization plugin throws `AuthzRefusedError`, whose message is for the operator: it names the model and the branch it judged. The client is told `AUTHZ_REFUSED` with `code: "forbidden"` instead, whatever a plugin wrapped it in on the way. A refusal that is not an authorization plugin's is any `ForbiddenError`, answered with its own message; `isAuthzRefusal(err)` tells the two apart. **Custom error handling:** ```typescript // You can still handle specific errors manually app.post( '/api/process', asyncHandler(async (req, res) => { try { const result = await processData(req.body); res.json({ success: true, data: result }); } catch (error) { if (error instanceof ValidationError) { // Handle specific error types return res.status(400).json({ success: false, error: 'Validation failed', details: error.details, }); } // Re-throw to let asyncHandler handle it throw error; } }) ); ``` ### Logging Use the provided logger with appropriate levels: ```typescript // Debug - detailed diagnostic information this.logger.debug('Processing entry:', entry); // Info - general informational messages this.logger.info('Plugin initialized successfully'); // Warn - warning messages for potentially harmful situations this.logger.warn('Configuration not found, using defaults'); // Error - error events that might still allow the application to continue this.logger.error('Failed to process webhook:', error); ``` ### Plugin Roles Categorize your plugin using roles: ```typescript export default class MyPlugin extends DmPlugin { name = 'myPlugin'; roles: Role[] = ['auth', 'api'] as const; } ``` Available roles: - **auth** - Authentication plugins - **authz** - Authorization/access control plugins - **protect** - Security/rate limiting plugins - **api** - Plugins exposing API endpoints - **logging** - Logging and audit plugins - **demo** - Example/demonstration plugins - **consistency** - Data consistency enforcement plugins --- ## Configuration Best Practices ### Validation in Constructor ```typescript constructor(server: DM) { super(server); // Validate required configuration if (!this.config.webhook_url) { throw new Error('Missing required configuration: --webhook-url'); } // Validate format const url = this.config.webhook_url as string; if (!url.startsWith('http://') && !url.startsWith('https://')) { throw new Error('--webhook-url must start with http:// or https://'); } // Validate numeric ranges const timeout = parseInt(this.config.webhook_timeout as string || '3000'); if (timeout < 100 || timeout > 30000) { throw new Error('--webhook-timeout must be between 100 and 30000'); } this.logger.info('Configuration validated successfully'); } ``` ### Provide Defaults ```typescript constructor(server: DM) { super(server); // Set defaults for optional configuration this.webhookUrl = this.config.webhook_url as string; this.webhookTimeout = parseInt( this.config.webhook_timeout as string || '3000' ); this.maxRetries = parseInt( this.config.webhook_max_retries as string || '3' ); this.retryDelay = parseInt( this.config.webhook_retry_delay as string || '1000' ); } ``` ### Document Configuration Options Add clear documentation in your plugin file: ```typescript /** * @module plugins/notification/webhook * @author Your Name * * Webhook notification plugin * * Configuration options: * * Required: * - --webhook-url Webhook endpoint URL (required) * - --webhook-secret Secret for webhook authentication (required) * * Optional: * - --webhook-timeout Request timeout in milliseconds (default: 3000) * - --webhook-max-retries Maximum retry attempts (default: 3) * - --webhook-retry-delay Delay between retries (default: 1000) * - --webhook-event Events to send (multiple, default: all) * * Environment variables: * - DM_WEBHOOK_URL * - DM_WEBHOOK_SECRET * - DM_WEBHOOK_TIMEOUT * - DM_WEBHOOK_MAX_RETRIES * - DM_WEBHOOK_RETRY_DELAY * - DM_WEBHOOK_EVENTS (comma-separated) * * Example usage: * * npx ldap-rest \ * --plugin core/notification/webhook \ * --webhook-url https://example.com/webhook \ * --webhook-secret mysecret123 \ * --webhook-event user.added \ * --webhook-event user.modified */ ``` --- ## Complete Plugin Example Here's a complete, production-ready plugin that sends webhook notifications on LDAP changes: ```typescript /** * @module plugins/notification/webhook * @author Example Developer * * Sends webhook notifications when LDAP entries are modified * * Configuration: * - --webhook-url Target webhook URL (required) * - --webhook-secret Authentication secret (required) * - --webhook-timeout Request timeout (default: 5000) * - --webhook-retry-count Retry attempts (default: 3) * - --webhook-event Events to monitor (multiple allowed) */ import type { Express, Request, Response } from 'express'; import crypto from 'crypto'; import DmPlugin, { type Role, asyncHandler } from '../../abstract/plugin'; import type { DM } from '../../bin'; import type { Hooks } from '../../hooks'; import type { AttributesList, ModifyRequest } from '../../lib/ldapActions'; interface WebhookPayload { event: string; timestamp: string; dn: string; data: AttributesList | ModifyRequest; } interface WebhookStats { sent: number; failed: number; lastSent: string | null; lastError: string | null; } export default class NotificationPlugin extends DmPlugin { name = 'notification'; roles: Role[] = ['api', 'logging'] as const; private webhookUrl: string; private webhookSecret: string; private webhookTimeout: number; private retryCount: number; private enabledEvents: Set; private stats: WebhookStats; constructor(server: DM) { super(server); // Validate required configuration if (!this.config.webhook_url) { throw new Error('NotificationPlugin requires --webhook-url'); } if (!this.config.webhook_secret) { throw new Error('NotificationPlugin requires --webhook-secret'); } // Load configuration with defaults this.webhookUrl = this.config.webhook_url as string; this.webhookSecret = this.config.webhook_secret as string; this.webhookTimeout = parseInt( (this.config.webhook_timeout as string) || '5000' ); this.retryCount = parseInt( (this.config.webhook_retry_count as string) || '3' ); // Parse enabled events const events = (this.config.webhook_event as string[]) || ['*']; this.enabledEvents = new Set(events); // Initialize stats this.stats = { sent: 0, failed: 0, lastSent: null, lastError: null, }; this.logger.info( `NotificationPlugin initialized: ${this.webhookUrl} ` + `(events: ${Array.from(this.enabledEvents).join(', ')})` ); } api(app: Express): void { // Get webhook stats app.get( `${this.config.api_prefix}/v1/notifications/stats`, asyncHandler(async (req: Request, res: Response) => { res.json({ success: true, data: { ...this.stats, webhookUrl: this.webhookUrl, enabledEvents: Array.from(this.enabledEvents), }, }); }) ); // Test webhook app.post( `${this.config.api_prefix}/v1/notifications/test`, asyncHandler(async (req: Request, res: Response) => { await this.sendWebhook('test', 'test', { message: 'Test webhook' }); res.json({ success: true, message: 'Test webhook sent' }); }) ); this.logger.info('NotificationPlugin API registered'); } hooks: Hooks = { ldapadddone: async ([dn, entry]) => { if (this.shouldSendEvent('ldap.add')) { void this.sendWebhook('ldap.add', dn, entry); } }, ldapmodifydone: async ([dn, changes, _op]) => { if (this.shouldSendEvent('ldap.modify')) { void this.sendWebhook('ldap.modify', dn, changes); } }, ldapdeletedone: async dn => { if (this.shouldSendEvent('ldap.delete')) { const dnStr = Array.isArray(dn) ? dn.join(', ') : dn; void this.sendWebhook('ldap.delete', dnStr, {}); } }, }; /** * Check if an event should trigger webhook */ private shouldSendEvent(event: string): boolean { return this.enabledEvents.has('*') || this.enabledEvents.has(event); } /** * Send webhook notification with retry logic */ private async sendWebhook( event: string, dn: string, data: AttributesList | ModifyRequest ): Promise { const payload: WebhookPayload = { event, timestamp: new Date().toISOString(), dn, data, }; let lastError: Error | null = null; for (let attempt = 0; attempt <= this.retryCount; attempt++) { try { await this.sendWebhookRequest(payload); // Success this.stats.sent++; this.stats.lastSent = payload.timestamp; this.logger.info(`Webhook sent: ${event} ${dn}`); return; } catch (error) { lastError = error as Error; if (attempt < this.retryCount) { this.logger.warn( `Webhook attempt ${attempt + 1} failed, retrying...` ); await this.delay(1000 * (attempt + 1)); } } } // All attempts failed this.stats.failed++; this.stats.lastError = lastError?.message || 'Unknown error'; this.logger.error( `Webhook failed after ${this.retryCount + 1} attempts:`, lastError ); } /** * Send single webhook HTTP request */ private async sendWebhookRequest(payload: WebhookPayload): Promise { const body = JSON.stringify(payload); const signature = this.generateSignature(body); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.webhookTimeout); try { const response = await this.fetch(this.webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Webhook-Signature': signature, 'User-Agent': 'LDAP-Rest-Webhook/1.0', }, body, signal: controller.signal, }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } } finally { clearTimeout(timeoutId); } } /** * Generate HMAC signature for webhook authentication */ private generateSignature(payload: string): string { return crypto .createHmac('sha256', this.webhookSecret) .update(payload) .digest('hex'); } /** * Delay helper for retry logic */ private delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } } ``` ### Usage Example ```bash # Start LDAP-Rest with notification plugin npx ldap-rest \ --ldap-base dc=example,dc=com \ --ldap-url ldap://localhost:389 \ --ldap-dn cn=admin,dc=example,dc=com \ --ldap-pwd admin \ --plugin core/ldap/flatGeneric \ --ldap-flat-schema ./static/schemas/standard/users.json \ --plugin core/notification/webhook \ --webhook-url https://example.com/webhook \ --webhook-secret mysecret123 \ --webhook-timeout 5000 \ --webhook-retry-count 3 \ --webhook-event ldap.add \ --webhook-event ldap.modify # Check webhook stats curl http://localhost:8081/api/v1/notifications/stats # Test webhook curl -X POST http://localhost:8081/api/v1/notifications/test ``` --- ## Testing Your Plugin ### Test File Structure Create tests in `test/plugins//.test.ts`: ```typescript import { describe, it, before, after } from 'mocha'; import { expect } from 'chai'; import request from 'supertest'; import { DM } from '../../../src/bin/index'; import NotificationPlugin from '../../../src/plugins/notification/webhook'; describe('NotificationPlugin', function () { let server: DM; let plugin: NotificationPlugin; // Skip tests if environment not configured if (!process.env.DM_LDAP_DN || !process.env.DM_LDAP_PWD) { console.warn('Skipping NotificationPlugin tests: LDAP not configured'); // @ts-ignore this.skip?.(); return; } before(async () => { // Create server instance server = new DM(); // Set up command-line args for plugin process.argv = [ 'node', 'test', '--plugin', 'core/notification/webhook', '--webhook-url', 'http://localhost:9999/webhook', '--webhook-secret', 'test-secret', '--webhook-event', 'ldap.add', ]; await server.ready; await server.run(); // Get plugin instance plugin = server.loadedPlugins['notification'] as NotificationPlugin; }); after(async () => { if (server.server) { await new Promise(resolve => server.server!.close(resolve)); } }); describe('Initialization', () => { it('should initialize with correct configuration', () => { expect(plugin).to.exist; expect(plugin.name).to.equal('notification'); }); it('should throw error without required config', () => { expect(() => { const badServer = new DM(); new NotificationPlugin(badServer); }).to.throw('requires --webhook-url'); }); }); describe('API Endpoints', () => { it('should return webhook stats', async () => { const response = await request(server.app) .get('/api/v1/notifications/stats') .expect(200); expect(response.body).to.have.property('success', true); expect(response.body.data).to.have.property('sent'); expect(response.body.data).to.have.property('failed'); expect(response.body.data).to.have.property('webhookUrl'); }); it('should send test webhook', async () => { const response = await request(server.app) .post('/api/v1/notifications/test') .expect(200); expect(response.body).to.have.property('success', true); }); }); describe('Hooks', () => { it('should trigger webhook on LDAP add', async () => { const testDn = `uid=testuser,${process.env.DM_LDAP_BASE}`; // Add test entry await server.ldap.add(testDn, { objectClass: ['inetOrgPerson'], uid: 'testuser', cn: 'Test User', sn: 'User', }); // Wait for webhook await new Promise(resolve => setTimeout(resolve, 100)); // Check stats const response = await request(server.app) .get('/api/v1/notifications/stats') .expect(200); expect(response.body.data.sent).to.be.greaterThan(0); // Cleanup await server.ldap.delete(testDn); }); }); describe('Private Methods', () => { it('should check enabled events correctly', () => { // Access private method via type assertion for testing const shouldSend = (plugin as any).shouldSendEvent('ldap.add'); expect(shouldSend).to.be.true; }); }); }); ``` ### Running Tests See [Tests and checks](../../CONTRIBUTING.md#tests-and-checks). ### Test Best Practices 1. **Use test helpers** from `test/helpers/` for common operations 2. **Clean up** - Delete test entries in `after()` hooks 3. **Skip gracefully** - Skip tests if environment not configured 4. **Mock external services** - Use mock servers for webhooks, APIs 5. **Test error cases** - Verify error handling and validation 6. **Test configuration** - Verify all config options work correctly --- ## Additional Resources - [Contributing Guide](../../CONTRIBUTING.md) - Git workflow and PR guidelines - [Hooks Reference](./hooks.md) - Complete list of available hooks - [Plugin services](./services.md) - Typed interfaces between plugins - [Core Plugin Examples](../../src/plugins/) - Browse existing plugins - [Test Examples](../../test/plugins/) - See how core plugins are tested --- ## Support - **Issues**: https://github.com/linagora/ldap-rest/issues - **Discussions**: https://github.com/linagora/ldap-rest/discussions --- ## License By contributing plugins to LDAP-Rest, you agree that your contributions will be licensed under the AGPL-3.0 License. linagora-ldap-rest-a4bb78e/docs/plugin-development/dependencies.md000066400000000000000000000761751527331160600254370ustar00rootroot00000000000000# Plugin Dependency Graph This document describes the dependencies between LDAP-Rest plugins. There are three types of dependencies: 1. **Explicit Dependencies**: Defined in the `dependencies` property of a plugin class 2. **Service Dependencies**: A plugin reaches a typed interface another one provides, see [plugin services](services.md) 3. **Hook Dependencies**: Implicit dependencies through hooks (one plugin provides a hook, another consumes it) ## Dependency Types ### Explicit Dependencies These are declared in the plugin's `dependencies` property and are required for the plugin to function. ```typescript dependencies = { ldapGroups: 'core/ldap/ldapGroups', onLdapChange: 'core/ldap/onChange', }; ``` A dependency is loaded when the plugin registers, unless a plugin of that name already is. A module requested again under the same name and without overrides — a dependency also given to `--plugin`, or an entry listed twice — is loaded once: the second request waits for the first. Only an entry with overrides builds a second instance, and it needs a name of its own. ### Service Dependencies A plugin provides a typed interface under a token, and a consumer reaches it with `this.service(token)`. The token replaces the registry name as the way to find another plugin's code; `dependencies` still decides what loads first. A consumer whose provider is absent gets `null` and a single warning. See [plugin services](services.md) for the token, the provided interface and the registration rules. ### Hook Dependencies **Hook Providers**: Plugins that implement hooks (via `hooks: Hooks = { ... }`) **Hook Consumers**: Plugins that raise hooks (via `this.hookBus`) ## Complete Dependency Graph ### Authentication Plugins #### `authToken` (core/auth/token) - **Dependencies**: None - **Provides Hooks**: None - **Consumes Hooks**: None - **Notes**: Standalone authentication plugin #### `authLemonldapNg` (core/auth/llng) - **Dependencies**: None - **Provides Hooks**: None - **Consumes Hooks**: None - **Notes**: LemonLDAP::NG SSO integration #### `openidconnect` (core/auth/openidconnect) - **Dependencies**: None - **Provides Hooks**: None - **Consumes Hooks**: `beforeAuth`, `afterAuth` - **Notes**: OAuth2/OIDC authentication #### `crowdsec` (core/auth/crowdsec) - **Dependencies**: None - **Provides Hooks**: None - **Consumes Hooks**: None - **Notes**: IP blocking via CrowdSec (must be loaded before auth plugins) #### `rateLimit` (core/auth/rateLimit) - **Dependencies**: None - **Provides Hooks**: None - **Consumes Hooks**: None - **Notes**: Rate limiting (must be loaded before auth plugins) #### `authzPerBranch` (core/auth/authzPerBranch) - **Dependencies**: None (requires any auth plugin to be loaded first) - **Provides Hooks**: Authorization hooks via `AuthzBase` - **Consumes Hooks**: None - **Notes**: Branch-level authorization #### `authzLinid1` (core/auth/authzLinid1) - **Dependencies**: None (requires any auth plugin to be loaded first) - **Provides Hooks**: Authorization hooks via `AuthzBase` - **Consumes Hooks**: None - **Notes**: LinID 1.x authorization integration #### `bcl` (core/bcl) - **Dependencies**: `core/storage` — **required**: a logout check with nowhere to record what died would leave every session outliving the logout that closed it, so the plugin refuses to start - **Provides Hooks**: None - **Consumes Hooks**: `oidclogouttoken`, `oidclogin`, `oidcsessionvalid` - **Notes**: Back-Channel Logout. Records only the sessions a provider declared dead; needs `core/auth/openidconnect` to raise those hooks ### LDAP Core Plugins #### `onLdapChange` (core/ldap/onChange) - **Dependencies**: None - **Provides Hooks**: - `ldapmodifyrequest` - Before LDAP modify - `ldapmodifydone` - After LDAP modify - **Consumes Hooks**: (Fires these hooks for other plugins to consume) - `onLdapChange` - Any LDAP change - `onLdapMailChange` - Mail attribute changed - `onLdapQuotaChange` - Quota attribute changed - `onLdapForwardChange` - Forward attribute changed - `onLdapAliasChange` - Alias attribute changed - `onLdapDisplayNameChange` - Display name changed - **Notes**: Core plugin for change detection and notification #### `ldapFlatGeneric` (core/ldap/flatGeneric) - **Dependencies**: - `departmentSync: 'core/ldap/departmentSync'` (optional) - **Provides Hooks**: Dynamic hooks based on entity type (e.g., `ldap{Entity}add`, `ldap{Entity}modify`, etc.) - **Consumes Hooks**: Entity-specific hooks - **Notes**: Creates multiple plugin instances based on schemas #### `ldapGroups` (core/ldap/groups) - **Dependencies**: None - **Provides Hooks**: - `ldapdeleterequest` - Before LDAP delete (checks group membership) - **Consumes Hooks**: - `ldapgroupadd` - Before adding group - `ldapgroupadddone` - After adding group - `ldapgroupmodify` - Before modifying group - `ldapgroupmodifydone` - After modifying group - `ldapgrouprename` - Before renaming group - `ldapgrouprenamedone` - After renaming group - `ldapgroupdelete` - Before deleting group - `ldapgroupdeletedone` - After deleting group - `ldapgroupaddmember` - Before adding member - `ldapgroupdeletemember` - Before deleting member - `ldapgroupvalidatemembers` - Validate group members - `_ldapgrouplist` - Filter group list - `ldapgroupvalidatenew` - Validate new group - `ldapgroupvalidatechanges` - Validate group changes - **Notes**: Central group management plugin #### `ldapOrganizations` (core/ldap/organization) - **Dependencies**: None - **Provides Hooks**: - `ldapaddrequest` - Validates organization link/path on add - `ldapmodifyrequest` - Validates organization link/path on modify - `ldapdeleterequest` - Prevents deleting non-empty organizations - `ldaprenamerequest` - Validates organization renames - **Consumes Hooks**: - `getOrganisationTop` - Filter top organization - **Notes**: Hierarchical organization management #### `ldapDepartmentSync` (core/ldap/departmentSync) - **Dependencies**: None - **Provides Hooks**: - `ldaprenamedone` - After organization rename/move, updates all resources - **Consumes Hooks**: None - **Notes**: Synchronizes department links when organizations are renamed #### `externalUsersInGroups` (core/ldap/externalUsersInGroups) - **Dependencies**: - `ldapGroups: 'core/ldap/ldapGroups'` ⚠️ **Required** - **Provides Hooks**: - `ldapgroupvalidatemembers` - Auto-creates external users - **Consumes Hooks**: - `externaluserentry` - Modify external user entry before creation - `externaluseradded` - After external user is created - **Notes**: Auto-creates external contacts when added to groups #### `trash` (core/ldap/trash) - **Dependencies**: None - **Provides Hooks**: - `ldapdeleterequest` - Intercepts deletes and moves to trash - **Consumes Hooks**: None - **Notes**: Soft delete system #### `ldapBulkImport` (core/ldap/bulkImport) - **Dependencies**: None - **Provides Hooks**: None - **Consumes Hooks**: None - **Notes**: CSV-based bulk import (uses schema-driven configuration) ### Twake Integration Plugins #### `james` (twake/james) - **Dependencies**: - `onLdapChange: 'core/ldap/onChange'` ⚠️ **Required** - `ldapGroups: 'core/ldap/groups'` ⚠️ **Required** - **Provides Hooks**: - `ldapadddone` - After user added - `onLdapMailChange` - Syncs mailbox on mail change - `onLdapAliasChange` - Syncs aliases - `onLdapQuotaChange` - Syncs quota - `onLdapForwardChange` - Syncs forwarding - `onLdapChange` - General LDAP change handling - `onLdapDisplayNameChange` - Syncs display name - `onLdapEntryChange` - Follows team mailboxes - **Consumes Hooks**: (Hooks provided by `onLdapChange`) - **Notes**: Synchronizes LDAP users/groups with Apache James mail server #### `migrationProxy` (twake/migrationProxy) - **Dependencies**: - `onLdapChange: 'core/ldap/onChange'` ⚠️ **Required** - **Provides Hooks**: None - **Consumes Hooks**: `onLdapEntryChange` (provided by `onLdapChange`) - **Notes**: Keeps the migrated-users list of the Twake Mail migration proxy, see [mail-migration](../usage/plugins/integrations/mail-migration.md) #### `calendar` (twake/calendar) - **Dependencies**: - `onLdapChange: 'core/ldap/onChange'` ⚠️ **Required** - **Provides Hooks**: - `ldapcalendarResourceadddone` - After calendar resource added - `ldapcalendarResourcemodifydone` - After calendar resource modified - `ldapcalendarResourcedeletedone` - After calendar resource deleted - `onLdapChange` - Propagates user email / name changes - **Consumes Hooks**: (Hooks provided by `onLdapChange`) - **Notes**: Synchronizes calendar resources and registered users with Twake Calendar. Formerly `calendarResources`, still loadable under that name #### `appAccountsConsistency` (twake/appAccountsConsistency) - **Dependencies**: - `onLdapChange: 'core/ldap/onChange'` ⚠️ **Required** - **Provides Hooks**: - `ldapadddone` - After user added, creates principal applicative account - `onLdapMailChange` - When mail changes, updates applicative accounts - **Consumes Hooks**: (Hooks provided by `onLdapChange`) - **Notes**: Automatically creates and maintains principal applicative accounts (uid=mail) #### `appAccountsApi` (twake/appAccountsApi) - **Dependencies**: - `authToken: 'core/auth/token'` ⚠️ **Required** (or another auth plugin) - `appAccountsConsistency: 'core/twake/appAccountsConsistency'` ⚠️ **Required** - **Provides Hooks**: None - **Consumes Hooks**: None - **Notes**: RESTful API for managing applicative accounts (device/app-specific accounts). Depends on `appAccountsConsistency` for automatic cleanup and synchronization of app accounts. ### Utility Plugins #### `static` (core/static) - **Dependencies**: None - **Provides Hooks**: None - **Consumes Hooks**: None - **Notes**: Serves static files and JSON schemas #### `weblogs` (core/weblogs) - **Dependencies**: None - **Provides Hooks**: None - **Consumes Hooks**: None - **Notes**: HTTP request logging #### `storage` (core/storage) - **Dependencies**: None (the `ldap` backend uses the server's connection) - **Provides Hooks**: None - **Consumes Hooks**: None - **Notes**: Keyed storage with a deadline. Only one instance may be loaded — a second one is refused rather than leaving registration order to decide which answers #### `configApi` (core/configApi) - **Dependencies**: None (reads configuration from other loaded plugins) - **Provides Hooks**: None - **Consumes Hooks**: None - **Notes**: Exposes API configuration for frontend applications ### Demo Plugins #### `hello` (demo/helloworld) - **Dependencies**: None - **Provides Hooks**: None - **Consumes Hooks**: `hello` (demonstrates hook system) - **Notes**: Example plugin for development/testing ## Dependency Chains ### Mail Server Integration Chain ``` james (twake/james) ├─→ requires: onLdapChange (core/ldap/onChange) │ └─→ provides: ldapmodifyrequest, ldapmodifydone │ └─→ fires: onLdapMailChange, onLdapQuotaChange, etc. └─→ requires: ldapGroups (core/ldap/groups) └─→ provides: ldapdeleterequest └─→ consumes: ldapgroup* hooks ``` ### External Users Chain ``` externalUsersInGroups (core/ldap/externalUsersInGroups) └─→ requires: ldapGroups (core/ldap/ldapGroups) └─→ provides: ldapgroupvalidatemembers hook └─→ consumes: externaluserentry, externaluseradded hooks ``` ### Organization Sync Chain ``` ldapFlatGeneric (core/ldap/flatGeneric) └─→ optionally uses: departmentSync (core/ldap/departmentSync) └─→ provides: ldaprenamedone hook └─→ updates resources when organizations are renamed ``` ### Delete Interception Chain ``` trash (core/ldap/trash) └─→ provides: ldapdeleterequest hook └─→ intercepts deletes, moves to trash instead ldapGroups (core/ldap/groups) └─→ provides: ldapdeleterequest hook └─→ checks group membership before delete ldapOrganizations (core/ldap/organization) └─→ provides: ldapdeleterequest hook └─→ prevents deleting non-empty organizations ``` **Note**: Multiple plugins can provide the same hook. Hooks are executed as chains, allowing multiple plugins to participate in the same operation. ## Visual Dependency Graph ### ASCII Dependency Graph ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ AUTHENTICATION LAYER │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────────┐ ┌─────────────┐ │ │ │crowdsec │ │rateLimit │ │authToken │ │authLLNG │ │ │ │ │ │ │ │ │ │ │ │ │ └──────────┘ └──────────┘ └──────────────┘ └─────────────┘ │ │ │ │ ┌──────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │openidconnect │ │authzPerBranch│ │authzLinid1 │ │ │ │ │ │ │ │ │ │ │ └──────────────┘ └─────────────┘ └─────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────────────┐ │ LDAP CORE LAYER │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌────────────────┐ ┌──────────────┐ ┌───────────────┐ │ │ │ onChange │ │ groups │ │organizations │ │ │ │ (change │ │ (group │ │ (org tree) │ │ │ │ detection) │ │ management) │ │ │ │ │ └────────────────┘ └──────────────┘ └───────────────┘ │ │ │ │ │ │ │ │ │ │ │ │ │ ┌─────┴──────┐ │ │ │ │ │ │ │ │ │ │ ┌──────────▼───────┐ │ │ │ │ │ │externalUsers │ │ │ │ │ │ │InGroups │ │ │ │ │ │ │(auto-create) │ │ │ │ │ │ └──────────────────┘ │ │ │ │ │ │ │ │ │ │ ┌──────▼──────────┐ │ │ │ ┌──────▼────────┐ │ flatGeneric │◄──────┘ │ │ │departmentSync │◄───────────────│ (entity mgmt) │ │ │ │(org rename) │ (optional) │ │ │ │ └───────────────┘ └─────────────────┘ │ │ │ │ ┌──────────┐ ┌──────────────┐ │ │ │ trash │ │ bulkImport │ │ │ │(soft del)│ │ (CSV import)│ │ │ └──────────┘ └──────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────────────┐ │ INTEGRATION LAYER │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌────────────────────────────┐ ┌──────────────────────────┐ │ │ │ james │ │ calendar │ │ │ │ (Apache James sync) │ │ (Twake Calendar sync) │ │ │ │ │ │ │ │ │ │ Requires: │ │ │ │ │ │ • onChange ─────────────┼──┐ │ │ │ │ │ • groups ───────────────┼──┘ │ │ │ │ └────────────────────────────┘ └──────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────────────┐ │ UTILITY LAYER │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │ │ static │ │ weblogs │ │ configApi │ │ │ │(files) │ │(logging) │ │(config API) │ │ │ └──────────┘ └──────────┘ └──────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ Legend: ──► Explicit dependency (required) ◄── Optional dependency │ Hook relationship ``` ### Detailed Dependency Connections ``` ┌──────────────────────────────────────────────────────────────┐ │ EXPLICIT DEPENDENCIES (Plugin A requires Plugin B) │ └──────────────────────────────────────────────────────────────┘ james ──────► onChange (core/ldap/onChange) ──────► groups (core/ldap/groups) externalUsersInGroups ──────► groups (core/ldap/groups) flatGeneric ─ ─ ─► departmentSync (optional) ┌──────────────────────────────────────────────────────────────┐ │ HOOK DEPENDENCIES (Plugin A fires hooks, Plugin B consumes) │ └──────────────────────────────────────────────────────────────┘ onChange ─┬─ fires ──► onLdapChange ────────► james ├─ fires ──► onLdapMailChange ────► james ├─ fires ──► onLdapQuotaChange ───► james ├─ fires ──► onLdapAliasChange ───► james ├─ fires ──► onLdapForwardChange ─► james └─ fires ──► onLdapDisplayNameChange ─► james groups ───── fires ──► ldapgroupvalidatemembers ──► externalUsersInGroups departmentSync ── fires ──► ldaprenamedone ──► flatGeneric instances ┌──────────────────────────────────────────────────────────────┐ │ HOOK PROVIDERS (Plugins that intercept LDAP operations) │ └──────────────────────────────────────────────────────────────┘ LDAP Delete Request Chain (multiple plugins): trash ──► ldapdeleterequest (moves to trash) groups ──► ldapdeleterequest (checks members) organizations ──► ldapdeleterequest (checks empty) LDAP Modify Request Chain: onChange ──► ldapmodifyrequest (detects changes) organizations ──► ldapmodifyrequest (validates org links) LDAP Add Request Chain: organizations ──► ldapaddrequest (validates org links) LDAP Rename Request Chain: organizations ──► ldaprenamerequest (validates renames) ``` ### Mermaid Diagram (for GitHub/viewers) ```mermaid graph TB %% Authentication plugins authToken[authToken] authLLNG[authLemonldapNg] openidconnect[openidconnect] crowdsec[crowdsec] rateLimit[rateLimit] authzPerBranch[authzPerBranch] authzLinid1[authzLinid1] %% LDAP Core onChange[onLdapChange] flatGeneric[ldapFlatGeneric] groups[ldapGroups] organizations[ldapOrganizations] departmentSync[ldapDepartmentSync] externalUsers[externalUsersInGroups] trash[trash] bulkImport[ldapBulkImport] %% Twake Integration james[james] calendar[calendar] %% Utility static[static] weblogs[weblogs] configApi[configApi] %% Explicit dependencies james -->|requires| onChange james -->|requires| groups externalUsers -->|requires| groups flatGeneric -.->|optionally uses| departmentSync %% Hook dependencies (major ones) onChange -.->|fires hooks| james groups -.->|validates members| externalUsers %% Styling classDef auth fill:#e1f5ff,stroke:#0288d1 classDef ldap fill:#e8f5e9,stroke:#388e3c classDef twake fill:#fff3e0,stroke:#f57c00 classDef util fill:#f3e5f5,stroke:#7b1fa2 class authToken,authLLNG,openidconnect,crowdsec,rateLimit,authzPerBranch,authzLinid1 auth class onChange,flatGeneric,groups,organizations,departmentSync,externalUsers,trash,bulkImport ldap class james,calendar twake class static,weblogs,configApi util ``` ## Loading Order Recommendations ### 1. Security Plugins (must load first) 1. `crowdsec` - IP blocking 2. `rateLimit` - Rate limiting ### 2. Authentication Plugins 3. `authToken` OR `authLemonldapNg` OR `openidconnect` 4. `authzPerBranch` OR `authzLinid1` (optional, after auth) ### 3. LDAP Core Plugins 5. `onChange` - Required by `james` 6. `groups` - Required by `james` and `externalUsersInGroups` 7. `organizations` - Organization management 8. `departmentSync` - Before `flatGeneric` if using department sync 9. `flatGeneric` - Entity management 10. `externalUsersInGroups` - After `groups` 11. `trash` - Soft delete (optional) 12. `bulkImport` - Bulk import (optional) ### 4. Integration Plugins 13. `james` - After `onChange` and `groups` 14. `calendar` - Twake Calendar sync (optional) ### 5. Utility Plugins (can load anytime) - `static` - Static files - `weblogs` - Logging - `configApi` - Configuration API - `storage` - Keyed storage. Written anywhere in the configuration, but loaded before the others: its consumers have to find it registered, so it is in the priority list ## Plugin Priority System LDAP-Rest uses a priority system defined in [`src/plugins/priority.json`](../../src/plugins/priority.json), which is the list itself — copying it here is how this section came to name two plugins the file does not and to omit four it does. Plugins in that list are loaded one at a time, in its order, before every other plugin is loaded in parallel. Two reasons put a plugin there: a security plugin whose middleware must sit ahead of the routes it guards, and a plugin others depend on, which has to be registered before the batch its consumers land in — `core/storage` is there for the second reason. The list matches on the **module**, so a named instance (`core/auth/token:authAdmins:{…}`) keeps its rank, and every instance of a priority module is loaded in that first pass, in configuration order. It used to be matched on the whole `--plugin` string, which sent exactly the configurations that carry per-instance options into the parallel batch — `core/auth/trustedProxy` named that way registered after the routes it guards. ## Hook Execution Order Hooks are executed in the order plugins are loaded, with one exception: the request hooks of authorization plugins (`ldapsearchrequest`, `ldapaddrequest`, `ldapmodifyrequest`, `ldapdeleterequest`, `ldaprenamerequest`) run before any other plugin's, in the order the authorization plugins loaded. The first hook to throw answers the request, so a caller who may not make an operation is refused before a plugin judges its content, or replaces it. `ldapsearchfilter` keeps the load order. For chained hooks: 1. First plugin in chain can modify/validate data 2. Returns modified data to next plugin 3. Last plugin's return value is used For notified hooks, see [hooks](hooks.md#the-bus): - All hooks run one after the other, in load order - A failing one is logged and the next still runs **Example**: `ldapdeleterequest` chain ``` Request → authorization → trash (moves to trash) → groups (checks members) → organizations (checks empty) → LDAP ``` ## Creating Plugins with Dependencies ### Explicit Dependencies ```typescript export default class MyPlugin extends DmPlugin { name = 'myPlugin'; dependencies = { ldapGroups: 'core/ldap/ldapGroups', onChange: 'core/ldap/onChange', }; } ``` ### Consuming Hooks ```typescript export default class MyPlugin extends DmPlugin { hooks: Hooks = { onLdapMailChange: async ([dn, oldMail, newMail]) => { // React to mail changes console.log(`Mail changed from ${oldMail} to ${newMail}`); }, }; } ``` ### Providing Hooks A hook is declared in `src/hooks.ts` before anything raises it, and raised through the bus of the kind it is, see [hooks](hooks.md#the-bus): ```typescript export default class MyPlugin extends DmPlugin { async someMethod() { // Thread a value through the subscribers of a chained hook value = await this.hookBus.chain('ldapsearchopts', value); // Tell the subscribers of a notified one, without waiting for them this.hookBus.notify('ldapadddone', dn, entry, changeContext(req)); } } ``` ## Dependency Validation LDAP-Rest validates dependencies at startup: 1. Checks all `dependencies` properties 2. Ensures required plugins are loaded 3. Fails with error if dependencies missing 4. Logs warning for optional dependencies ## Best Practices 1. **Declare explicit dependencies**: Use `dependencies` property for hard requirements 2. **Document hook dependencies**: Comment which hooks your plugin consumes 3. **Use optional dependencies carefully**: Check if plugin is loaded before using 4. **Respect load order**: Security → Auth → LDAP → Integration → Utility 5. **Test plugin isolation**: Ensure plugin works when optional dependencies are missing 6. **Avoid circular dependencies**: Plugin A shouldn't require Plugin B if B requires A 7. **Use hooks for loose coupling**: Prefer hooks over direct plugin references ## See Also - [Plugin Development Guide](DEVELOPER_GUIDE.md) - [Hook System Documentation](../src/hooks/README.md) - [Plugin Priority Configuration](../src/plugins/priority.json) linagora-ldap-rest-a4bb78e/docs/plugin-development/hooks.md000066400000000000000000000313201527331160600241130ustar00rootroot00000000000000# Core hooks Hooks are how plugins talk to each other. Each one is declared in [hooks.ts](../../src/hooks.ts), which is the reference for the arguments it gets and what it must answer; a plugin subscribing to a hook it does not name there does not compile. ## The bus `this.hookBus` (or `server.hookBus`) is how a plugin raises a hook, and how a test waits for one. It has one method per kind of hook, told apart by what a subscriber answers: | Kind | Method | Subscriber | | -------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------ | | chained | `chain(name, value)` | answers the value, changed or not | | notified | `notify(name, ...args)` | answers nothing, not awaited | | notified | `notifyAndWait(name, ...args)` | answered nothing, awaited | | notified | `notifyStrict(name, ...args)` | as `notifyAndWait`, but a failure goes to the caller | | any | `has(name)`, `subscribers(name)` | — | | any | `settled()` | resolves once every notification so far has run | | any | `set`, `subscribe`, `insert`, `unsubscribe`, `toLast` | add a subscriber, or move one: for a test, or for a plugin changing what its base declares | A chained hook is handed its arguments as one value, a tuple when it has several. A notified hook is handed them one by one: its subscriber is `(dn, entry, context) => …`, never `([dn, entry], context) => …` — that form still compiles without annotation, since its parameter takes the declared `string` and a string can be destructured, but then reads the DN's first character as the DN. - **Chained.** The value goes from one subscriber to the next, each getting what the one before answered; the last answer is the result. A subscriber that throws stops the chain and the error travels up: a refusal is a refusal. Several values are passed as one tuple. - **Notified.** Subscribers run one after the other, outside the request that caused the write, and are told what happened and was judged: they react as the server — synchronizing, publishing, keeping invariants — whoever asked. `notify` does not wait for them and never fails: the write they react to is done, and a failing subscriber is logged, with its plugin and hook, and counted in the [hook failure metrics](../usage/operations.md#metrics), then the next one still runs. - **`notifyStrict`** is for the rare subscriber whose failure is part of the answer: a logout token reaches the provider only once what it closes is recorded, so the library answers 400 and the provider retries. Every subscriber runs even when one fails, and the first failure is raised once they all have. A test that would otherwise sleep waits instead: ```ts // One hook's subscribers await server.hookBus.notifyAndWait('ldapadddone', dn, entry, {}); // or whatever the write under test started await server.hookBus.settled(); ``` ## Hooks - **hello**: raised by the [demo plugin](../../src/plugins/demo/helloworld.ts) for `/hello`, answered by the demo plugins - **ldapsearchopts**: chained before any search, with its options - **ldapsearchrequest**: chained before any search, with `[base, options, request]`; an authorization plugin refuses by throwing - **ldapsearchresult**: chained after any search, with its result, before it is cached; for a paged search, when the first page is read - **ldapsearchfilter**: chained on the result of a search after the cache, once per page, with `[result, request, options]`, to drop what the caller may not see - **ldapaddrequest**: chained before any add, with `[dn, entry, request]` - **ldapadddone**: notified once an add has written the entry, with the DN, the entry and the context, one by one; not awaited - **ldapaddafter**: notified once an add has written the entry, before it answers, with the DN, the entry and the context, one by one, see [below](#ldapaddafter) - **ldapmodifyrequest**: chained before any modify, with `[dn, changes, op, request]` - **ldapmodifydone**: notified once a modify is written, with the DN, the changes, the operation number and the context, one by one - **ldapmodifyend**: notified once a modify is over, whatever became of it, and its done subscribers have returned - **ldapdeleterequest**: chained before any delete, with `[dn, request]` - **ldapdeletedone**: notified once a delete is written, with the DN and the context, one by one - **ldapdeleteend**: notified once a delete is over, whatever became of it, and its done subscribers have returned - **ldaprenamerequest**: chained before any rename or move, with `[dn, newDn, request]` - **ldaprenamedone**: notified once a rename or a move is written, with the DN, its new DN and the context, one by one - **ldaprenameend**: notified once a rename is over, whatever became of it, and its done subscribers have returned - **oidcsessionvalid**: chained with the claims of a session and the verdict so far, see [the OpenID Connect page](../usage/plugins/auth/openid-connect.md) - **oidclogouttoken**: notified with the claims of a session a logout token or `/logout` ends - **oidclogin**: notified with the claims of a session just established - **ldapgroupvalidatenew**: chained before a group is created, with `[dn, entry]` - **ldapgroupadd**: chained before a group is added, with `[dn, entry]` - **ldapgroupadddone**: notified once a group is added, with the DN and the entry, one by one - **ldapgroupvalidatechanges**: chained before a group is modified, with `[dn, changes]` - **ldapgroupmodify**: chained before a group is modified, with `[dn, changes, op]` - **ldapgroupmodifydone**: notified once a group is modified, with the DN, the changes and the operation number, one by one - **ldapgrouprename**: chained before a group is renamed, with `[dn, newDn]` - **ldapgrouprenamedone**: notified once a group is renamed, with the DN and its new DN, one by one - **ldapgroupdelete**: chained before a group is deleted, with its DN - **ldapgroupdeletedone**: notified once a group is deleted, with its DN - **ldapgroupaddmember**: chained before members are added, with `[cn, members]` - **ldapgroupdeletemember**: chained before a member is removed, with `[cn, member]` - **ldapgroupvalidatemembers**: chained before a member is added, to judge the members, with `[dn, members]` - **\_ldapgrouplist**: chained around the list of groups, internal to the groups plugin - **onLdapEntryChange**: notified with the entry before and after any write, and who made it - **onLdapChange**: notified with the attributes a write changed - **onLdapMailChange**: notified with the mail before and after a change, or with no new mail for a delete - **onLdapAliasChange**: notified with the mail and the aliases before and after a change - **onLdapQuotaChange**: notified with the mail and the quota before and after a change - **onLdapForwardChange**: notified with the mail and the forwards before and after a change - **onLdapDisplayNameChange**: notified with the name before and after a change - **onLdapDriveQuotaChange**: notified with the drive quota before and after a change - **externaluserentry**: chained before an external member is created, with `[dn, entry]` - **externaluseradded**: notified once an external member is created, with the DN and the entry, one by one - **beforeAuth**: chained on a request before it is authenticated, with `[request, response]` - **afterAuth**: chained on a request once it is authenticated, with `[request, response]` - **getOrganisationTop**: chained before the top organization is answered, with `[request, entry]` - **scimusercreate**: chained before a SCIM user is created, with `[user, request]` - **scimusercreatedone**: notified once a SCIM user is created - **scimuserupdate**: chained before a SCIM user is replaced, with `[id, user, request]` - **scimuserupdatedone**: notified once a SCIM user is replaced, with id, user (one by one) - **scimuserdelete**: chained before a SCIM user is deleted, with `[id, request]` - **scimuserdeletedone**: notified once a SCIM user is deleted, with its id - **scimgroupcreate**: chained before a SCIM group is created, with `[group, request, base]` - **scimgroupcreatedone**: notified once a SCIM group is created - **scimgroupupdate**: chained before a SCIM group is replaced, with `[id, group, request]` - **scimgroupupdatedone**: notified once a SCIM group is replaced, with id, group (one by one) - **scimgroupdelete**: chained before a SCIM group is deleted, with `[id, request]` - **scimgroupdeletedone**: notified once a SCIM group is deleted, with its id - **scimbulkdone**: notified once a SCIM bulk request is answered - **twakedeletionreplay**: notified with the DN, the entry and the context, one by one when a delete asks of an entry that is already a tombstone ## Flat entities A plugin built on `LdapFlat` (`--ldap-flat-schema`) raises one set of hooks per entity: `ldap${entity.name}` followed by the suffix of the shared hook of the same kind — `add` and `adddone`, `modify` and `modifydone`, `rename` and `renamedone`, `delete` and `deletedone`, with the same arguments — plus `{prefix}move` before an entry is moved, followed by `onLdapChange` as a modify is. A plugin watching one declares it beside its other hooks. ## LDAP write hooks ### ldapaddafter For what must follow an add within the same request, such as provisioning the account the entry describes. - Subscribers get the DN, the entry and the change context, one by one, as `ldapadddone` does. They run one after the other, and `add()` answers once the last has returned. - An error is logged and goes no further: the add has succeeded, and the next subscriber still runs. A subscriber that can fail owns its repair path, and should be safe to run again on the same entry. - It fires for every add, including those made without a request (trash, storage, appAccountsConsistency, externalUsersInGroups), whose context is empty. Some of these follow an API call all the same, so select entries by their DN or attributes rather than by an empty context. - The entry is the one submitted, after `ldapaddrequest`, not what the directory holds: no operational attributes, and a `Buffer` value — a password sent that way — read as a string. - `ldapadddone` and `onLdapEntryChange` fire whatever this hook does, and in no set order with it: an add event may or may not include what a subscriber here wrote. ### ldapmodifyend, ldapdeleteend, ldaprenameend For a plugin that keeps something from a request hook to the matching "done" one, as core/ldap/onChange keeps the entry it read. A write that is refused by a request hook, that one of them takes out of the request (core/ldap/trash and core/twake/tombstone do with a delete), or that the directory refuses, never reaches its "done" hook; its "end" hook fires all the same. - Each gets the number of its operation. A modify's request and "done" hooks are given it in their arguments; for every modify, delete and rename, `currentOperation()` (`lib/operation`) returns it inside the request and "done" hooks. Key what you keep by it rather than by DN: two writes of one DN may be under way, and only the operation tells them apart. Outside those hooks it is whatever encloses the call (an add made from a delete's request hook sees the delete's number): in an end hook, use the number it is given. - It is launched once the "done" subscribers of the operation have returned, and is not awaited by the write. - A move (`ldapActions.move`) launches no request hook and no end hook, and its `ldaprenamedone` runs outside any operation, even when a request hook made it. ## onLdapChange - **onLdapEntryChange**: called after any write, with the entry before and after it, and who made it. - **onLdapChange**, **onLdapMailChange**…: derived from it, and the attribute ones fire only while the entry has the attribute configured for them. A delete fires `onLdapEntryChange`, `onLdapChange` and `onLdapMailChange` only. ## Ordering Hooks run in the order their plugins loaded, but for the request hooks of authorization plugins, which run before every other plugin's, see [dependencies](dependencies.md#hook-execution-order). linagora-ldap-rest-a4bb78e/docs/plugin-development/openapi.md000066400000000000000000000150341527331160600244270ustar00rootroot00000000000000# Documenting plugin endpoints (OpenAPI) The published API reference at is generated from `openapi.json`, which itself is built statically by [`scripts/generate-openapi.ts`](../../scripts/generate-openapi.ts) — it walks every plugin's `api()` method and reads the JSDoc comments above each route, and reads the declaration of every [`defineRoute`](routes.md) call. With `defineRoute` the declaration is the document: the generator takes its `summary`, `description`, `tags`, `params`, `query`, `body` and `responses`, and imports the exported schema constants it names. An `@openapi` block above the call still wins over what the declaration says — a `requestBody` it describes replaces the declared `body` schema whole, rather than merging into it — and a route registered with `app.(` still needs one. You don't need to touch the generator to enrich your plugin's documentation. Two JSDoc directives drive the entire pipeline: - `@openapi` — placed **just above an `app.method(path, handler)` call**, describes that single route. - `@openapi-component` — placed anywhere in the file, registers reusable schemas under `components.schemas` so multiple routes can `$ref` them. Both directives carry inline **YAML** that follows the [OpenAPI 3.0 Operation Object](https://spec.openapis.org/oas/v3.0.3#operation-object) (or [Schema Object](https://spec.openapis.org/oas/v3.0.3#schema-object) for components). The generator parses the YAML and merges it on top of the defaults it would have emitted, so you only describe what you want to enrich. ## Per-route metadata: `@openapi` ```ts /** * @openapi * summary: Get group by CN * description: | * The `:cn` segment may be either the group's RDN value or its full DN. * Returns the raw LDAP entry. * responses: * '200': * description: Group entry. * content: * application/json: * schema: { $ref: '#/components/schemas/Group' } * example: * dn: cn=admins,ou=groups,dc=example,dc=com * cn: admins * description: Server administrators * '404': * description: Group not found. */ app.get(`${this.config.api_prefix}/v1/ldap/groups/:cn`, asyncHandler(...)); ``` Anything below the `@openapi` line (up to the next `@directive` or the end of the JSDoc block) is YAML. The keys you provide override the auto-generated ones; everything you omit keeps the generator's defaults. ### What you can override Any field of an OpenAPI Operation Object, including: | Field | Notes | | ------------- | -------------------------------------------------------------------------------- | | `summary` | Short, one-line title. | | `description` | Long description (markdown). | | `tags` | Array of strings; defaults to the plugin's tag. | | `parameters` | Concatenated with auto-generated path params (your entries win on duplicates). | | `requestBody` | Replace the generic `application/json` placeholder with a real schema + example. | | `responses` | Replace per status code. | | `security` | List of security requirement objects (works as-is). | | `deprecated` | `true` to mark the operation as deprecated. | | `operationId` | Stable identifier for code generators. | ### What is autodetected The generator already extracts: - the **HTTP method** (`get`, `post`, …) from the call expression; - the **path** (substituting local `const prefix = ...` variables, the `${this.config.api_prefix}` template, and a few well-known config knobs); - **path parameters** from `:param` segments; - a default **summary** built from the verb and the last path segment; - the plugin's **tag** (configured in `pluginTags` in the generator). So a minimal, correct `@openapi` block is often only two or three keys. ## Reusable schemas: `@openapi-component` Place this anywhere in your plugin file (the convention is right above the class). The body is a YAML map of `: ` pairs: ```ts /** * @openapi-component * Group: * type: object * required: [dn, cn] * properties: * dn: { type: string, example: cn=admins,ou=groups,dc=example,dc=com } * cn: { type: string, example: admins } * member: * type: array * items: { type: string } * Error: * type: object * properties: * error: { type: string } * code: { type: integer } */ export default class LdapGroups extends DmPlugin { // ... } ``` Refer to them from any route using `$ref`: ```yaml schema: { $ref: '#/components/schemas/Group' } ``` Component names are global to the spec, so prefix them with the plugin's domain (`Group`, `ScimUser`, `PasswordPolicy`) to avoid collisions. ## Tips - **Examples beat specs.** A single concrete `example:` block is worth a dozen `description:` lines for someone trying the API for the first time. - **YAML alignment.** The generator strips the leading `*` from each line before parsing, then dedents the block, so you can use any consistent indentation as long as it's even within the block. - **Multiline strings.** Use YAML block scalars (`description: |`) for paragraphs and code samples — they round-trip cleanly through Redoc. - **Annotation is opt-in.** Routes without an `@openapi` block are **excluded** from the published spec — the generator prints a `⚠️ Skipping undocumented route` warning so the author notices, but the route does not surface in Redoc. The published doc therefore reflects intentionally-documented API surface only, not every Express call we happened to find. - **A plugin with zero annotated routes disappears entirely** from the spec, by the same rule. ## Regenerate locally ```sh npm run generate:openapi # writes openapi.json npm run build:pages # rebuilds the static site under _site/ ``` `openapi.json` is committed: it is what the site publishes, so a change to a route or to a schema it declares must come with a regenerated document. CI runs `npm run check:openapi`, which builds the spec and fails when the file differs, naming the first line at fault. Open `_site/api/index.html` to preview the Redoc rendering. The published site at is rebuilt automatically on every `v*` tag push. linagora-ldap-rest-a4bb78e/docs/plugin-development/options.md000066400000000000000000000121101527331160600244570ustar00rootroot00000000000000# Plugin options A core plugin declares its configuration options next to its code, in a `.options.ts` file. A family that shares imports and lifecycle keeps one set per plugin in a single `.options.ts` file, named after the family. `src/config/args.ts` concatenates those declarations, so the parser, the command line and the environment variables see the same entries as before. The plugin then reads typed values instead of casting `this.config`. ## Declaring options ```typescript // src/plugins/example/example.options.ts import { defineOptions, opt, type OptionsOf } from '../../config/options'; export const exampleOptions = defineOptions({ example_url: opt.string({ env: 'DM_EXAMPLE_URL', required: true, }), example_timeout: opt.number({ env: 'DM_EXAMPLE_TIMEOUT', default: 5000, min: 1, }), example_retry: opt.boolean({ env: 'DM_EXAMPLE_RETRY', default: false, }), example_domains: opt.array({ env: 'DM_EXAMPLE_DOMAINS', default: [], plural: 'example-domains', kind: 'identifiers', }), }); export type ExampleOptions = OptionsOf; ``` The key is the `Config` name: `example_timeout` is `--example-timeout`, `DM_EXAMPLE_TIMEOUT` when the descriptor names that variable. ### Deprecating an option Rename an option by giving the old name as an alias: the parser still reads it, and the server warns once at startup. Keep the alias for two minor releases, as [CONTRIBUTING.md](../../CONTRIBUTING.md#deprecating-a-published-contract) requires. ```typescript export const exampleOptions = defineOptions({ example_url: opt.string({ env: 'DM_EXAMPLE_URL', aliases: ['example-uri'], envAliases: ['DM_EXAMPLE_URI'], }), }); ``` A CLI alias takes one value per occurrence, as the singular form does, and an array option's plural form has no alias: renaming it is a breaking change. An environment alias is read when `env` is unset, or empty for a type where an empty value means nothing, and only for a non-array option when its canonical CLI name is absent; an array option combines the environment value with the CLI one, so its alias is still read. Each declaration takes: | Field | Meaning | | ------------ | --------------------------------------------------------------------- | | `env` | Environment variable read when the CLI does not set the option | | `default` | Value kept when neither the CLI nor the environment sets the option | | `required` | Refuse an empty value instead of reading it as unset | | `plural` | Array only: CLI name of the plural form, without the leading `--` | | `kind` | Array only: how a value splits, see `ArrayKind` in `parseConfig.ts` | | `min/max` | Number only: bounds checked when the option is read | | `choices` | String only: the values the option accepts | | `aliases` | Former CLI names still accepted, without the leading `--` | | `envAliases` | Former environment variables still accepted, read when `env` is unset | ## Reading options The plugin names the declaration as a static field, and asks for the values in its constructor: ```typescript export default class Example extends DmPlugin { static optionDefinitions = exampleOptions; name = 'example'; private readonly settings: ExampleOptions; constructor(server: DM) { super(server); this.settings = this.options(exampleOptions); } } ``` `options(exampleOptions)` applies the defaults, copies array defaults and checks `required`, `min`, `max` and `choices`. The base constructor asks once, so a bad value stops the plugin from loading rather than failing at the first request. Each later call reads the configuration again rather than keeping the first values. `--plugin example:name:{...}` overrides still work: the view the plugin receives carries the merged configuration, and `options()` reads that view. Give it the same declaration object the class names in `optionDefinitions`: any other one is refused, so the generic type parameter cannot describe the wrong view. ## Wiring it in Import the declaration in `src/config/args.ts` and spread it where the plugin's options used to be: ```typescript import { exampleOptions } from '../plugins/example/example.options'; import { toConfigEntries } from './options'; const configArgs: ConfigTemplate = [ // ... ...toConfigEntries(exampleOptions), ]; ``` The `Config` interface keeps the hand-written fields until every plugin has moved. A migrated plugin no longer reads them: it uses `options()`. Plugins outside the repository cannot add to `args.ts` yet, but they can declare options the same way and name them in `optionDefinitions`: the startup report subtracts those keys and their aliases, and `options()` reads a declared `env` variable or a deprecated CLI alias the parser did not carry, warning at startup for the alias as it does for a core option. An option without a declaration still reaches `this.config` from an unknown `--key value` pair, and the startup report names it. linagora-ldap-rest-a4bb78e/docs/plugin-development/routes.md000066400000000000000000000131521527331160600243140ustar00rootroot00000000000000# Declaring routes `defineRoute` declares an HTTP route once: its inputs, their schemas, what the OpenAPI document says of it, and the handler. The schemas are checked before the handler runs, the openapi generator describes the route from the declaration, and the route probe finds it in the registry. ```typescript import { Type } from '@sinclair/typebox'; import type { Express } from 'express'; import DmPlugin, { defineRoute, type Role } from '../../abstract/plugin'; /** Exported: the OpenAPI generator reads it without running the plugin. */ export const GroupParams = Type.Object({ cn: Type.String({ minLength: 1 }), }); export const GroupQuery = Type.Object({ dryRun: Type.Optional(Type.Boolean()), }); export const GroupMembers = Type.Object( { member: Type.Union([Type.String(), Type.Array(Type.String())]) }, { additionalProperties: false } ); export const MembersAdded = Type.Object({ added: Type.Number() }); export default class MyPlugin extends DmPlugin { name = 'myPlugin'; roles: Role[] = ['api'] as const; api(app: Express): void { defineRoute(app, { method: 'post', path: `${this.config.api_prefix}/v1/ldap/groups/:cn/members`, params: GroupParams, query: GroupQuery, body: GroupMembers, responses: { 200: { schema: MembersAdded, description: 'Members added' }, }, summary: 'Add members to a group', tags: ['Groups'], handler: async ({ params, query, body, res }) => { const added = await this.addMembers(params.cn, body.member, { dryRun: query.dryRun ?? false, }); res.json({ added }); }, }); } } ``` The handler has the same duties as an `asyncHandler` callback: it answers through `res`, and a rejection is passed to the error middleware. ## Schemas Each schema is an **exported constant**, possibly imported from another module. `npm run generate:openapi` does not run the plugin: it finds the `defineRoute` call in the source, imports the module that exports each schema, and reads its value — a TypeBox schema is JSON Schema already. A schema written inline in the declaration is checked at runtime but is not in the document, and the generator warns about it. Keep in mind that the schemas are part of the published API: give the properties a `description`, or rely on what TypeBox emits. ## Inputs | Field | Source | Checked as | | -------- | ------------ | ---------- | | `params` | `req.params` | converted | | `query` | `req.query` | converted | | `body` | `req.body` | as parsed | A route that needs a route middleware — a multipart parser, say — passes it as `middleware`. Express runs it before the handler, and before the `Accept` and schema checks: a multipart body is read before the request may be refused. Path and query values always arrive as strings, so they are converted before being checked: a `Type.Number()` query accepts `?limit=10` and hands the handler `10`. The body is whatever the registered parser left in `req.body`; a request carrying none is checked as an empty object, so an all-optional schema accepts it and a required field is refused by name. A part the route declares no schema for is handed to the handler unchecked, as before. The declaration feeds the document the same way: `params` becomes the path parameters, `query` the query parameters, `body` the JSON request body, and `responses` the answers; the standard 400/401/404/500 entries stay. An answer is a schema, or `{ schema, description }` when the document needs a sentence of its own: ```typescript responses: { 200: { schema: MembersAdded, description: 'Members added' }, 409: { schema: AlreadyMember, description: 'Already a member' }, 500: ErrorSchema, } ``` Only the parameters the path actually carries (`:cn` → `{cn}`) become path parameters: a `params` key the path does not name is reported by the generator and left out, since OpenAPI refuses it. ## Refusals A request that fails a schema is answered with `400` and a body naming every failing path: ```json { "error": "Invalid request: body.member: Expected string; query.limit: Expected number" } ``` A caller whose `Accept` excludes JSON is answered `400 Bad request` before the handler runs. A route serving another media type sets `json: false` and answers it itself; SCIM does, for `application/scim+json`. Its declared schemas are still checked first, and a failure there is answered in the REST shape (`Invalid request: …`): such a route validates the rest of its input itself. The handler also receives Express's `next`, for a route that falls through or has already answered. `Type.Object` accepts unknown keys unless the schema says `{ additionalProperties: false }`. A route migrated from hand-written checks starts without that option, so it refuses no client it accepted before; tighten it one route at a time. ## The registry Every declaration is recorded on the app, schemas included, without its handler. `registeredRoutes(app)` returns them, and `unregisteredRoutes(app)` lists what the Express stack carries that `defineRoute` did not declare. ## The migration guard `npm run lint` refuses `app.(` under `src/plugins` and `src/abstract`, through `eslint-rules/no-raw-route.mjs`. `eslint-rules/route-allowlist.json` names the files a raw route is tolerated in; it is empty, so every route in the tree is declared. `test/scripts/routeAllowlist.test.ts` keeps the list exact: a listed file must still declare a raw route, and an unlisted file must not. `test/security/routeValidation.test.ts` reads the registry and sends each declared query and body a value it must refuse, so a new declaration is probed without touching the test. linagora-ldap-rest-a4bb78e/docs/plugin-development/services.md000066400000000000000000000072541527331160600246240ustar00rootroot00000000000000# Plugin services A plugin sometimes needs what another plugin holds: the group operations, the entity schemas, the trash. A service is a typed interface a plugin provides under a token, and another plugin reaches with `this.service(token)`. The token carries the interface. The compiler checks that an instance bound with `with()` answers it, and gives a consumer the type it declared. Whether a provider exists is a runtime question: a plugin that stops declaring the token still compiles, and its consumers get `null` and a warning. The plugin registry name stays what it is for: loading a plugin and naming it in the logs. ## Declaring a service Put the token and its interface in a module both sides import — a plugin family's own service in its own module. A service shared between families is declared in `src/lib/services/` and re-exported from `ldap-rest/plugin`, one of the package's entry points; a module under `src/lib/` has no export path of its own. ```typescript import { serviceToken } from 'ldap-rest/plugin'; export interface GroupsApi { listGroups(): Promise; } export const GroupsService = serviceToken('groups'); ``` Declare a token once. The object is the registry key; calling `serviceToken` again under the same name declares another service, not the same one. The name only identifies it in the logs. ## Providing a service Bind the plugin instance to each token it provides: ```typescript import DmPlugin from 'ldap-rest/plugin'; export default class LdapGroups extends DmPlugin { name = 'ldapGroups'; provides = [GroupsService.with(this)]; async listGroups(): Promise { /* … */ } } ``` `with(this)` is checked by the compiler: an instance that does not answer the interface does not compile. A plugin can provide several services. A service is registered once its plugin's registration completes — after `api()` and the hooks — so a consumer loaded afterwards finds it, and a provider does not find its own service there. A plugin needing another one declares it in [`dependencies`](dependencies.md#explicit-dependencies) so that it loads first. The first provider to register wins. A second one offering the same token is ignored and named in a warning: which one answered would otherwise depend on load order. Loading the same module twice under two names gives two instances but one module-level token, so the second instance cannot provide it either. When a host registers a plugin after startup, its `assertComposition` runs before the service is registered, so a provider does not find its own service there; the configuration's plugins are all registered before their `assertComposition` runs. ## Consuming a service ```typescript const groups = this.service(GroupsService); if (!groups) { // Nothing provides it: the plugin skips the feature it would serve return; } await groups.listGroups(); ``` `service()` answers the provided instance, or `null` when nothing provides the token. It logs one warning per plugin and service rather than one per call. A plugin that cannot work without a service declares the provider in `dependencies`, checks the answer in `assertComposition`, and throws naming the service — that stops the server at startup instead of leaving a feature silently absent. A constructor runs before the dependencies load, so it cannot be the place for that check. The registry belongs to the server and is shared by the views `withConfig` builds for plugins loaded with overrides. It has no removal: a plugin registers once and keeps its services for the life of the server. `this.requirePlugin(name)` remains for a plugin whose interface is not yet declared as a service; new code reaches another plugin through a token. linagora-ldap-rest-a4bb78e/docs/plugin-development/testing.md000066400000000000000000000207601527331160600244530ustar00rootroot00000000000000# Testing with Embedded LDAP Server ## Overview The test suite now includes an **embedded LDAP server** that runs automatically in Docker when no external LDAP is configured. This provides: - ✅ **Zero-configuration testing** - Works out of the box in CI/CD - ✅ **Isolation** - Each test run gets a fresh LDAP instance - ✅ **Flexibility** - Use either embedded or external LDAP - ✅ **Fast** - Server starts in ~2-3 seconds ## How It Works ### Automatic Server Selection The test framework automatically detects which LDAP server to use: ``` ┌─────────────────────────────────────┐ │ Test starts │ └──────────────┬──────────────────────┘ │ ▼ ┌─────────────────────────────────────┐ │ Check environment variables: │ │ - DM_LDAP_URL │ │ - DM_LDAP_DN │ │ - DM_LDAP_PWD │ │ - DM_LDAP_BASE │ └──────────────┬──────────────────────┘ │ ┌──────┴──────┐ │ │ ▼ ▼ ┌─────────┐ ┌──────────┐ │ All set │ │ Missing │ └────┬────┘ └─────┬────┘ │ │ ▼ ▼ ┌─────────────┐ ┌────────────────┐ │ Use │ │ Start embedded │ │ external │ │ Docker LDAP │ │ LDAP │ │ server │ └─────────────┘ └────────────────┘ ``` ### Test Output Examples **With embedded LDAP (default):** ``` 🚀 Setting up global test environment... ℹ️ No external LDAP configured, starting embedded LDAP server... Starting LDAP test server on port 36759... ✓ LDAP test server started on port 36759 (container: ldap-test-...) Loading initial LDIF from .../base-structure.ldif... ✓ Initial LDIF loaded ✓ Embedded LDAP server ready URL: ldap://localhost:36759 Base DN: dc=example,dc=com ``` **With external LDAP:** ``` 🚀 Setting up global test environment... ✓ Using external LDAP server URL: ldap://my-server:389 Base DN: dc=company,dc=com ``` ## Usage ### Running Tests See [Tests and checks](../../CONTRIBUTING.md#tests-and-checks). ### In CI/CD **GitHub Actions example:** ```yaml name: Tests on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - name: Setup Node.js uses: actions/setup-node@v7 with: node-version: '22' - name: Install dependencies run: npm ci - name: Run tests run: npm test # No LDAP setup needed - embedded server starts automatically! ``` ## LDAP Fixtures The embedded server loads pre-configured test data: ### Base Structure (`test/fixtures/base-structure.ldif`) - **Branches:** - `ou=users,dc=example,dc=com` (B2C users) - `ou=groups,dc=example,dc=com` (B2C groups) - `ou=nomenclature,dc=example,dc=com` (reference data) - **Test Users:** - `john.doe@example.com` / password: `password123` - `jane.smith@example.com` / password: `password123` - **Test Groups:** - `cn=admins` (contains john.doe, jane.smith) - **Nomenclature Data:** - Titles: Dr, Mr, Ms - List Types: openList, memberRestrictedList - Mailbox Types: group, mailingList, teamMailbox ### B2B Organizations (`test/fixtures/b2b-organizations.ldif`) For future B2B testing: - **Acme Corporation** (`o=acme-corp`) - Users: alice@acme-corp.example.com, bob@acme-corp.example.com - Groups: engineering, management - **Beta Industries** (`o=beta-industries`) - Users: charlie@beta-industries.example.com - Groups: sales ## Implementation Details ### Key Files ``` test/ ├── setup.ts # Global test hooks ├── helpers/ │ ├── ldapServer.ts # LDAP Docker server management │ ├── env.ts # Environment detection │ └── testSetup.ts # Helper to access server in tests └── fixtures/ ├── base-structure.ldif # B2C test data └── b2b-organizations.ldif # B2B test data (future) ``` ### How It Works Internally 1. **Startup** (`test/setup.ts:mochaHooks.beforeAll`): - Check if `DM_LDAP_*` env vars are set - If yes → use external LDAP - If no → start Docker container with OpenLDAP - Load fixtures from `test/fixtures/*.ldif` - Set environment variables for DM 2. **During Tests**: - All tests use `process.env.DM_LDAP_*` transparently - Works exactly the same whether using embedded or external LDAP 3. **Cleanup** (`test/setup.ts:mochaHooks.afterAll`): - Stop and remove Docker container (only if embedded) - External LDAP is left untouched ### Environment Variables Set When using embedded LDAP, these are automatically set: ```bash DM_LDAP_URL=ldap://localhost: DM_LDAP_DN=cn=admin,dc=example,dc=com DM_LDAP_PWD=adminpassword DM_LDAP_BASE=dc=example,dc=com DM_LDAP_TOP_ORGANIZATION=dc=example,dc=com DM_JAMES_WEBADMIN_URL=http://localhost:8000 DM_JAMES_WEBADMIN_TOKEN=test-token ``` ## Accessing the LDAP Server in Tests ### Standard Usage (via DM) Most tests don't need direct LDAP access - they test via the API: ```typescript import { DM } from '../../../src/bin'; describe('My plugin', () => { let server: DM; before(async () => { server = new DM(); // Reads env vars automatically await server.ready; }); it('should work', async () => { // Test via API const res = await request.get('/api/v1/users'); expect(res.status).to.equal(200); }); }); ``` ### Direct LDAP Access For tests that need direct LDAP operations: ```typescript import { getTestLdapServer } from '../helpers/testSetup'; describe('Advanced LDAP test', () => { it('should perform raw LDAP operations', async () => { const server = getTestLdapServer(); // Direct LDAP search const result = await server.search('(uid=john.doe)', ['cn', 'mail']); expect(result).to.include('john.doe'); // Load additional test data await server.loadLdif(` dn: uid=test,ou=users,dc=example,dc=com objectClass: inetOrgPerson uid: test cn: Test User sn: User mail: test@example.com `); }); }); ``` ## Requirements - **Docker** must be installed and running - Tests will fail gracefully if Docker is not available - Port range 30000-40000 must be available (for random port assignment) ## Troubleshooting ### Container Won't Start ```bash # Check Docker is running docker ps # Check for conflicting containers docker ps -a | grep ldap-test # Clean up old test containers docker rm -f $(docker ps -a | grep ldap-test | awk '{print $1}') ``` ### Tests Timeout Increase timeout in your test if LDAP is slow to start: ```typescript before(async function () { this.timeout(120000); // 2 minutes // ... }); ``` ### Wrong LDAP Data The embedded server loads fresh data on each run. If you see stale data, you're likely connected to an external LDAP server. Check: ```bash echo $DM_LDAP_URL # Should be empty or ldap://localhost: ``` ## Migration Guide ### Existing Tests No changes needed! Tests using `skipIfMissingEnvVars()` will now always pass: ```typescript // Before: Would skip if no external LDAP import { skipIfMissingEnvVars, LDAP_ENV_VARS } from '../../helpers/env'; describe('My test', () => { before(function () { skipIfMissingEnvVars(this, [...LDAP_ENV_VARS]); // Still works! }); }); // Now: Runs with embedded LDAP automatically ``` ### New Tests Simply write tests without any LDAP setup: ```typescript import { DM } from '../../../src/bin'; describe('New plugin', () => { let server: DM; before(async () => { server = new DM(); // That's it! await server.ready; }); it('should work', () => { // Your test here }); }); ``` ## Future Enhancements - [ ] Support for loading custom fixtures per test suite - [ ] B2B organization fixtures automatically loaded - [ ] LDAP server connection pooling across test files - [ ] Parallel test execution with multiple LDAP containers linagora-ldap-rest-a4bb78e/docs/presentations/000077500000000000000000000000001527331160600215275ustar00rootroot00000000000000linagora-ldap-rest-a4bb78e/docs/presentations/en.md000066400000000000000000000352621527331160600224630ustar00rootroot00000000000000--- title: LDAP-Rest sub_title: Lightweight Directory Manager --- # LDAP-Rest ## Lightweight Directory Manager with Plugin Architecture ![LDAP-Rest Logo](../linagora.png) # What is LDAP-Rest? A **lightweight** and **extensible** directory manager for LDAP ## Key Features - 🔌 **Plugin Architecture** - Modular and extensible functionality - 🔄 **Automatic LDAP Consistency** - Data consistency plugins - 🌐 **Complete REST API** - LDAP management via HTTP - 🎨 **Browser Libraries** - Ready-to-use UI components - 🔐 **Configurable Authentication** - Token, OIDC, LLNG, etc. - ⚡ **Lightweight and Fast** - Minimal memory footprint - 📦 **TypeScript** - Strict typing and safety # Architecture ## Technology Stack ``` ┌─────────────────────────────────────┐ │ REST API (Express) │ ├─────────────────────────────────────┤ │ Plugin System │ │ ┌──────────┬──────────┬─────────┐ │ │ │ Auth │ LDAP │ Twake │ │ │ └──────────┴──────────┴─────────┘ │ ├─────────────────────────────────────┤ │ LDAP Client (ldapts) │ └─────────────────────────────────────┘ ``` - **Runtime**: Node.js + TypeScript (ES Modules) - **Build**: Rollup (dual config: server + browser) - **Test**: Mocha + Chai - **LDAP**: ldapts (modern client) # Plugin Architecture ## Event System and Hooks ```typescript export default class MyPlugin extends DmPlugin { name = 'myPlugin'; dependencies = { onChange: 'core/ldap/onChange' }; hooks: Hooks = { onLdapChange: async (dn, changes) => { // React to LDAP changes }, onBeforeResponse: async (req, res, data) => { // Modify API responses }, }; } ``` # Available Plugins ## Authentication - **token** - Bearer Token Authentication - **openidconnect** - OpenID Connect / OAuth2 - **llng** - LemonLDAP::NG SSO - **crowdsec** - Abuse Protection - **rateLimit** - Rate Limiting - **authzPerBranch** - Authorization per LDAP Branch - **authzLinid1** - LinID v1 Authorization # Available Plugins (continued) ## LDAP Core - **onChange** - Change Detection and Notification - **flatGeneric** - Schema-Driven Generic Management - **groups** - LDAP Group Management - **organization** - Organizational Hierarchy - **externalUsersInGroups** - External Users in Groups ## Integrations - **twake/james** - Apache James Synchronization (mail) - **twake/calendar** - Twake Calendar resources and users # Apache James Plugin ## LDAP ↔ Mail Consistency Plugin [Apache James](https://james.apache.org/) is an open source mail server (SMTP, IMAP, POP3) ### Plugin Features - 📧 **Automatic LDAP → James Sync** - 🔄 **Mail Address Changes** - Account renaming + data - 💾 **Quota Management** - Automatic updates - 📨 **Mail Aliases** - mailAlternateAddress → James aliases - 🎯 **WebAdmin API** - REST Communication ### 🔐 Consistency Guarantee **All LDAP modifications are automatically propagated to James** - ✅ No desynchronization - ✅ No manual intervention - ✅ Real-time consistency # James Plugin - Consistency Scenarios ## 1. Mail Address Change ``` LDAP: mail = alice@example.com → alice.smith@example.com ↓ onChange detects the change ↓ Hook onLdapMailChange triggered ↓ James WebAdmin: POST /users/alice@.../rename/alice.smith@... → Account renamed → Mailbox preserved (inbox, sent, folders) → Old alias created automatically ✅ CONSISTENCY GUARANTEED ``` ## 2. Quota Update ``` LDAP: mailQuotaSize = 1000000000 → 5000000000 (1GB → 5GB) ↓ onChange detects the change ↓ Hook onLdapQuotaChange triggered ↓ James WebAdmin: PUT /quota/users/alice@.../size → Quota updated immediately ✅ CONSISTENCY GUARANTEED ``` # LDAP Consistency ## Automatic Consistency Plugins LDAP-Rest automatically maintains **consistency** between LDAP and external systems ### Mechanisms 1. **onChange** detects all LDAP changes 2. Plugins react via hooks 3. Automatic corrective actions 4. **Referential integrity guarantee** ### Examples - LDAP Consistency - **User Deletion** → Automatic removal from groups - **DN Change** → Reference updates - **External Users** → Maintained in groups ### Examples - LDAP ↔ James Consistency - **Mail Change** → Account renaming + James alias - **Quota Modification** → Immediate propagation - **Alias Management** → Bidirectional LDAP/James sync # REST API ## Main Endpoints ```bash # Organizations GET /api/v1/ldap/organizations/:dn GET /api/v1/ldap/organizations/:dn/subnodes GET /api/v1/ldap/organizations/:dn/subnodes/search # Users (flatGeneric) GET /api/v1/ldap/users POST /api/v1/ldap/users GET /api/v1/ldap/users/:dn PUT /api/v1/ldap/users/:dn DELETE /api/v1/ldap/users/:dn # Groups GET /api/v1/ldap/groups POST /api/v1/ldap/groups ``` # JSON Schemas ## Schema-Driven Architecture Schemas define: - LDAP object structure - Data validation - Auto-generated UI (browser) - Automatic documentation ```json { "objectClass": "inetOrgPerson", "fields": { "uid": { "type": "string", "required": true }, "mail": { "type": "string", "format": "email" }, "displayName": { "type": "string" } } } ``` # Available Schemas ## Standard LDAP - **users** - Users (inetOrgPerson) - **groups** - Groups (groupOfNames) - **organizations** - Organizations (organizationalUnit) ## Active Directory - **ad/users** - AD Users - **ad/groups** - AD Groups ## Twake - **twake/users** - Twake Extensions - **twake/groups** - Twake Groups - **twake/positions** - Positions/Functions # Browser Libraries ## Ready-to-Use UI Components ### LdapTreeViewer Interactive tree for navigating LDAP organizations ### LdapUserEditor Complete user management interface - Organizational tree - User list - Edit form # LdapTreeViewer ## Usage ```typescript import LdapTreeViewer from 'ldap-rest/browser-ldap-tree-viewer-index'; const viewer = new LdapTreeViewer({ containerId: 'tree-container', apiBaseUrl: 'http://localhost:8081', onNodeClick: node => { console.log('Selection:', node.dn); }, }); await viewer.init(); ``` # LdapUserEditor ## Usage ```typescript import LdapUserEditor from 'ldap-rest/browser-ldap-user-editor-index'; const editor = new LdapUserEditor({ containerId: 'editor-container', apiBaseUrl: 'http://localhost:8081', onUserSaved: userDn => { console.log('User saved:', userDn); }, }); await editor.init(); ``` # Installation and Quick Start ## Installation ```bash npm install ldap-rest ``` ## Quick Start ```bash npx ldap-rest \ --ldap-base 'dc=example,dc=com' \ --ldap-dn 'cn=admin,dc=example,dc=com' \ --ldap-pwd admin \ --ldap-url ldap://localhost \ --plugin core/ldap/groups \ --plugin core/ldap/organization ``` # Configuration ## Environment Variables ```bash # LDAP Connection DM_LDAP_URL=ldap://localhost:389 DM_LDAP_DN=cn=admin,dc=example,dc=com DM_LDAP_PWD=adminpassword DM_LDAP_BASE=ou=users,dc=example,dc=com # HTTP Server DM_PORT=8081 DM_HOST=0.0.0.0 # Logging DM_LOG_LEVEL=info # debug, info, warn, error ``` # Development ## Main Commands ```bash # Development npm run build:dev # Quick dev build npm run start:dev # Start dev server npm run dev # build + start # Tests npm test # All tests npm run test:one # Single test # Quality npm run check # lint + format check npm run fix # lint + format fix ``` # Build and Deployment ## Production Build ```bash npm run build:prod # → Generates dist/, static/browser/, Dockerfile ``` ## Docker ```bash npm run build:docker # Build image docker run -p 8081:8081 ldap-rest ``` ## Distribution - NPM package with TypeScript exports - CLI binaries: `ldap-rest`, `sync-james`, `cleanup-external-users` - Static files ready for CDN # Use Cases ## Usage Scenarios ✅ **Enterprise Directory** - Centralized user management - **Mail synchronization (Apache James)** - Web management interface - **Automatic data consistency** ✅ **Collaborative Platform (Twake)** - Multi-tenant with authzPerBranch - **Mail, calendar, mailing lists** - Reusable UI components - **Guaranteed referential integrity** ✅ **Provisioning Service** - **Hooks for external synchronization (James, etc.)** - **Automatic LDAP consistency** - Change auditing - **Automatic inconsistency cleanup** # Extensibility ## Create a Custom Plugin ```typescript import DmPlugin from 'ldap-rest/plugin'; import { Hooks } from 'ldap-rest/hooks'; export default class CustomPlugin extends DmPlugin { name = 'custom/myPlugin'; hooks: Hooks = { onLdapChange: async (dn, changes) => { // Your business logic await this.syncToExternalSystem(dn, changes); }, }; routes() { return [ { method: 'get', path: '/api/v1/custom/stats', handler: async (req, res) => { res.json({ stats: await this.getStats() }); }, }, ]; } } ``` # Security ## Security Mechanisms - 🔐 **Multi-Method Authentication** (Token, OIDC, LLNG) - 🛡️ **Granular Authorization** (per branch, per user) - 🚦 **Rate Limiting** (DoS protection) - 🔒 **CrowdSec** (intrusion detection) - 📝 **Change Auditing** (via onChange) - 🔑 **Secure LDAP Bind** (TLS supported) # Performance ## Optimizations - ⚡ **Lazy Loading** - On-demand loading - 🎯 **Smart Cache** - Reduced LDAP queries - 📦 **Optimized Bundle** - Tree-shaking, minification - 🔄 **Persistent Connections** - LDAP pool - 🎨 **Efficient Rendering** - Virtual DOM (browser libs) ## Typical Metrics - Startup: < 500ms - API Request: < 50ms - Memory Footprint: ~50MB # Roadmap ## Upcoming Features - 🔍 **Advanced Search** - Complex LDAP filters - 📊 **Admin Dashboard** - Monitoring and statistics - 🌍 **i18n** - Complete internationalization - 🔔 **Webhooks** - External notifications - 📱 **Mobile-First UI** - Improved responsive design - 🧪 **Interactive Playground** - Online demo # Documentation ## Available Resources 📚 **Guides** - [Developer Guide](./DEVELOPER_GUIDE.md) - [Browser Libraries](./browser/LIBRARIES.md) - [REST API Reference](./api/REST_API.md) 🔌 **Plugins** - [Plugin Development](./plugins/DEVELOPMENT.md) - [Hooks Reference](HOOKS.md) 📦 **Schemas** - [JSON Schemas Guide](./schemas/SCHEMAS.md) # Community ## Contributing - 🐛 **Issues**: https://github.com/linagora/ldap-rest/issues - 💡 **Discussions**: GitHub Discussions - 📖 **Wiki**: https://deepwiki.com/linagora/ldap-rest - 🤝 **Contributions**: See [CONTRIBUTING.md](CONTRIBUTING.md) ## License **AGPL-3.0** - Copyright 2025-present LINAGORA Free and open source software # Concrete Examples ## Twake + Apache James Integration ```bash # Complete configuration npx ldap-rest \ --plugin core/ldap/onChange \ --plugin core/ldap/groups \ --plugin twake/james \ --james-webadmin-url http://james:8000 \ --james-webadmin-token "admin-token" \ --mail-attribute mail \ --quota-attribute mailQuotaSize \ --alias-attribute mailAlternateAddress ``` ### Synchronization Flow ``` LDAP Change → onChange → Hook → James WebAdmin API ↓ Logging + Audit ``` ## Consistency Plugins - Examples ```typescript // 1. LDAP Group Consistency import groups from 'ldap-rest/plugin-ldap-groups'; dm.registerPlugin('groups', groups); // User deletion: // → Automatic removal from all groups // → Update member/uniqueMember attributes // 2. LDAP ↔ James Consistency import james from 'ldap-rest/plugin-twake-james'; dm.registerPlugin('james', james); // LDAP mail change: // → James account renaming // → Alias update // → Quota propagation // → Consistency guaranteed without manual intervention ``` # Concrete Examples (continued) ## Custom Web Interface ```typescript import LdapUserEditor from 'ldap-rest/browser-ldap-user-editor-index'; // Integration in your React/Vue/Angular app const editor = new LdapUserEditor({ containerId: 'users', apiBaseUrl: process.env.API_URL, onUserSaved: dn => { analytics.track('user_updated', { dn }); notifications.success('User saved'); }, onError: err => { errorTracker.capture(err); }, }); ``` # Comparison ## LDAP-Rest vs Alternatives | Feature | LDAP-Rest | LDAP Account Manager | phpLDAPadmin | | ------------------- | --------- | -------------------- | ------------ | | TypeScript | ✅ | ❌ | ❌ | | Plugin Architecture | ✅ | ⚠️ | ❌ | | REST API | ✅ | ⚠️ | ❌ | | Browser Libraries | ✅ | ❌ | ❌ | | Modern Stack | ✅ | ⚠️ | ❌ | | Extensibility | ✅✅ | ⚠️ | ⚠️ | | James Sync | ✅ | ❌ | ❌ | | Auto Consistency | ✅ | ❌ | ❌ | # Why LDAP-Rest? ## Key Benefits 🎯 **Modern** - Modern JavaScript stack - TypeScript first - Native ES Modules 🔧 **Flexible** - Customizable plugins - Extensible hooks - Configurable schemas 🚀 **Productive** - Complete REST API - Ready-to-use UI components - Rich documentation # Q&A & Demo ## Contact - 📧 Email: yadd@debian.org - 🐙 GitHub: https://github.com/linagora/ldap-rest - 🏢 LINAGORA: https://linagora.com ## Live Demo ```bash # Launch demo git clone https://github.com/linagora/ldap-rest cd ldap-rest npm install npm run dev ``` Open http://localhost:8081 # Thank You! ## LDAP-Rest - Lightweight Directory Manager [![Powered by LINAGORA](../linagora.png)](https://linagora.com) **GitHub**: https://github.com/linagora/ldap-rest **License**: AGPL-3.0 --- _Questions?_ linagora-ldap-rest-a4bb78e/docs/presentations/fr.md000066400000000000000000000370531527331160600224700ustar00rootroot00000000000000--- title: LDAP-Rest sub_title: Gestionnaire d'annuaire léger avec architecture à plugins --- # LDAP-Rest ## Gestionnaire d'annuaire léger avec architecture à plugins ![LDAP-Rest Logo](../linagora.png) # Qu'est-ce que LDAP-Rest ? Un gestionnaire d'annuaire **léger** et **extensible** pour LDAP ## Caractéristiques principales - 🔌 **Architecture à plugins** - Fonctionnalités modulaires et extensibles - 🔄 **Cohérence LDAP automatique** - Plugins de cohérence des données - 🌐 **API REST complète** - Gestion LDAP via HTTP - 🎨 **Bibliothèques browser** - Composants UI prêts à l'emploi - 🔐 **Authentification configurable** - Token, OIDC, LLNG, etc. - ⚡ **Léger et rapide** - Empreinte mémoire minimale - 📦 **TypeScript** - Typage strict et sécurité # Architecture ## Stack technique ``` ┌─────────────────────────────────────┐ │ API REST (Express) │ ├─────────────────────────────────────┤ │ Système de Plugins │ │ ┌──────────┬──────────┬─────────┐ │ │ │ Auth │ LDAP │ Twake │ │ │ └──────────┴──────────┴─────────┘ │ ├─────────────────────────────────────┤ │ Client LDAP (ldapts) │ └─────────────────────────────────────┘ ``` - **Runtime**: Node.js + TypeScript (ES Modules) - **Build**: Rollup (dual config: server + browser) - **Test**: Mocha + Chai - **LDAP**: ldapts (client moderne) # Architecture des Plugins ## Système d'événements et hooks ```typescript export default class MyPlugin extends DmPlugin { name = 'myPlugin'; dependencies = { onChange: 'core/ldap/onChange' }; hooks: Hooks = { onLdapChange: async (dn, changes) => { // Réagir aux changements LDAP }, onBeforeResponse: async (req, res, data) => { // Modifier les réponses API }, }; } ``` # Plugins Disponibles ## Authentification - **token** - Authentification Bearer Token - **openidconnect** - OpenID Connect / OAuth2 - **llng** - LemonLDAP::NG SSO - **crowdsec** - Protection contre les abus - **rateLimit** - Limitation de débit - **authzPerBranch** - Autorisation par branche LDAP - **authzLinid1** - Autorisation LinID v1 # Plugins Disponibles (suite) ## LDAP Core - **onChange** - Détection et notification des changements - **flatGeneric** - Gestion générique pilotée par schémas - **groups** - Gestion des groupes LDAP - **organization** - Hiérarchie organisationnelle - **externalUsersInGroups** - Utilisateurs externes dans les groupes ## Intégrations - **twake/james** - Synchronisation Apache James (mail) - **twake/calendar** - Ressources et utilisateurs Twake Calendar # Plugin Apache James ## Plugin de cohérence LDAP ↔ Messagerie [Apache James](https://james.apache.org/) est un serveur de messagerie open source (SMTP, IMAP, POP3) ### Fonctionnalités du plugin - 📧 **Synchronisation automatique LDAP → James** - 🔄 **Changement d'adresse mail** - Renommage compte + données - 💾 **Gestion des quotas** - Mise à jour automatique - 📨 **Alias mail** - mailAlternateAddress → James aliases - 🎯 **WebAdmin API** - Communication via REST ### 🔐 Garantie de cohérence **Toute modification LDAP est automatiquement propagée à James** - ✅ Pas de désynchronisation - ✅ Pas d'intervention manuelle - ✅ Cohérence temps réel # Plugin James - Scénarios de cohérence ## 1. Changement d'adresse mail ``` LDAP: mail = alice@example.com → alice.smith@example.com ↓ onChange détecte le changement ↓ Hook onLdapMailChange déclenché ↓ James WebAdmin: POST /users/alice@.../rename/alice.smith@... → Compte renommé → Boîte mail préservée (inbox, sent, folders) → Ancien alias créé automatiquement ✅ COHÉRENCE GARANTIE ``` ## 2. Mise à jour de quota ``` LDAP: mailQuotaSize = 1000000000 → 5000000000 (1GB → 5GB) ↓ onChange détecte le changement ↓ Hook onLdapQuotaChange déclenché ↓ James WebAdmin: PUT /quota/users/alice@.../size → Quota mis à jour immédiatement ✅ COHÉRENCE GARANTIE ``` # Cohérence LDAP ## Plugins de cohérence automatique LDAP-Rest maintient automatiquement la **cohérence** entre LDAP et les systèmes externes ### Mécanismes 1. **onChange** détecte tous les changements LDAP 2. Les plugins réagissent via hooks 3. Actions correctives automatiques 4. **Garantie de l'intégrité référentielle** ### Exemples - Cohérence LDAP - **Suppression d'utilisateur** → Retrait automatique des groupes - **Changement de DN** → Mise à jour des références - **Utilisateurs externes** → Maintien dans les groupes ### Exemples - Cohérence LDAP ↔ James - **Changement mail** → Renommage compte + alias James - **Modification quotas** → Propagation immédiate - **Gestion alias** → Synchronisation bidirectionnelle LDAP/James # API REST ## Endpoints principaux ```bash # Organisations GET /api/v1/ldap/organizations/:dn GET /api/v1/ldap/organizations/:dn/subnodes GET /api/v1/ldap/organizations/:dn/subnodes/search # Utilisateurs (flatGeneric) GET /api/v1/ldap/users POST /api/v1/ldap/users GET /api/v1/ldap/users/:dn PUT /api/v1/ldap/users/:dn DELETE /api/v1/ldap/users/:dn # Groupes GET /api/v1/ldap/groups POST /api/v1/ldap/groups ``` # Schémas JSON ## Architecture pilotée par schémas Les schémas définissent : - Structure des objets LDAP - Validation des données - UI auto-générée (browser) - Documentation automatique ```json { "objectClass": "inetOrgPerson", "fields": { "uid": { "type": "string", "required": true }, "mail": { "type": "string", "format": "email" }, "displayName": { "type": "string" } } } ``` # Schémas Disponibles ## Standard LDAP - **users** - Utilisateurs (inetOrgPerson) - **groups** - Groupes (groupOfNames) - **organizations** - Organisations (organizationalUnit) ## Active Directory - **ad/users** - Utilisateurs AD - **ad/groups** - Groupes AD ## Twake - **twake/users** - Extensions Twake - **twake/groups** - Groupes Twake - **twake/positions** - Postes/Fonctions # Bibliothèques Browser ## Composants UI prêts à l'emploi ### LdapTreeViewer Arbre interactif de navigation dans les organisations LDAP ### LdapUserEditor Interface complète de gestion d'utilisateurs - Arbre organisationnel - Liste d'utilisateurs - Formulaire d'édition # LdapTreeViewer ## Utilisation ```typescript import LdapTreeViewer from 'ldap-rest/browser-ldap-tree-viewer-index'; const viewer = new LdapTreeViewer({ containerId: 'tree-container', apiBaseUrl: 'http://localhost:8081', onNodeClick: node => { console.log('Sélection:', node.dn); }, }); await viewer.init(); ``` # LdapUserEditor ## Utilisation ```typescript import LdapUserEditor from 'ldap-rest/browser-ldap-user-editor-index'; const editor = new LdapUserEditor({ containerId: 'editor-container', apiBaseUrl: 'http://localhost:8081', onUserSaved: userDn => { console.log('Utilisateur sauvegardé:', userDn); }, }); await editor.init(); ``` # Installation et Démarrage ## Installation ```bash npm install ldap-rest ``` ## Démarrage rapide ```bash npx ldap-rest \ --ldap-base 'dc=example,dc=com' \ --ldap-dn 'cn=admin,dc=example,dc=com' \ --ldap-pwd admin \ --ldap-url ldap://localhost \ --plugin core/ldap/groups \ --plugin core/ldap/organization ``` # Configuration ## Variables d'environnement ```bash # Connexion LDAP DM_LDAP_URL=ldap://localhost:389 DM_LDAP_DN=cn=admin,dc=example,dc=com DM_LDAP_PWD=adminpassword DM_LDAP_BASE=ou=users,dc=example,dc=com # Serveur HTTP DM_PORT=8081 DM_HOST=0.0.0.0 # Logging DM_LOG_LEVEL=info # debug, info, warn, error ``` # Développement ## Commandes principales ```bash # Développement npm run build:dev # Build dev rapide npm run start:dev # Démarrer serveur dev npm run dev # build + start # Tests npm test # Tous les tests npm run test:one # Test unique # Qualité npm run check # lint + format check npm run fix # lint + format fix ``` # Build et Déploiement ## Build Production ```bash npm run build:prod # → Génère dist/, static/browser/, Dockerfile ``` ## Docker ```bash npm run build:docker # Build image docker run -p 8081:8081 ldap-rest ``` ## Distribution - Package NPM avec exports TypeScript - Binaires CLI: `ldap-rest`, `sync-james`, `cleanup-external-users` - Fichiers statiques prêts pour CDN # Cas d'Usage ## Scénarios d'utilisation ✅ **Annuaire d'entreprise** - Gestion centralisée des utilisateurs - **Synchronisation messagerie (Apache James)** - Interface web de gestion - **Cohérence automatique des données** ✅ **Plateforme collaborative (Twake)** - Multi-tenant avec authzPerBranch - **Mail, calendrier, listes de diffusion** - Composants UI réutilisables - **Intégrité référentielle garantie** ✅ **Service de provisioning** - **Hooks pour synchronisation externe (James, etc.)** - **Cohérence LDAP automatique** - Audit des changements - **Nettoyage automatique des incohérences** # Extensibilité ## Créer un plugin personnalisé ```typescript import DmPlugin from 'ldap-rest/plugin'; import { Hooks } from 'ldap-rest/hooks'; export default class CustomPlugin extends DmPlugin { name = 'custom/myPlugin'; hooks: Hooks = { onLdapChange: async (dn, changes) => { // Votre logique métier await this.syncToExternalSystem(dn, changes); }, }; routes() { return [ { method: 'get', path: '/api/v1/custom/stats', handler: async (req, res) => { res.json({ stats: await this.getStats() }); }, }, ]; } } ``` # Sécurité ## Mécanismes de sécurité - 🔐 **Authentification multi-méthodes** (Token, OIDC, LLNG) - 🛡️ **Autorisation granulaire** (par branche, par utilisateur) - 🚦 **Rate limiting** (protection DoS) - 🔒 **CrowdSec** (détection d'intrusion) - 📝 **Audit des changements** (via onChange) - 🔑 **LDAP bind sécurisé** (TLS supporté) # Performance ## Optimisations - ⚡ **Lazy loading** - Chargement à la demande - 🎯 **Cache intelligent** - Réduction des requêtes LDAP - 📦 **Bundle optimisé** - Tree-shaking, minification - 🔄 **Connexions persistantes** - Pool LDAP - 🎨 **Rendering efficace** - Virtual DOM (browser libs) ## Métriques typiques - Démarrage: < 500ms - Requête API: < 50ms - Empreinte mémoire: ~50MB # Roadmap ## Fonctionnalités à venir - 🔍 **Recherche avancée** - Filtres LDAP complexes - 📊 **Dashboard admin** - Monitoring et statistiques - 🌍 **i18n** - Internationalisation complète - 🔔 **Webhooks** - Notifications externes - 📱 **Mobile-first UI** - Responsive design amélioré - 🧪 **Playground interactif** - Démo en ligne # Documentation ## Ressources disponibles 📚 **Guides** - [Developer Guide](./DEVELOPER_GUIDE.md) - [Browser Libraries](./browser/LIBRARIES.md) - [REST API Reference](./api/REST_API.md) 🔌 **Plugins** - [Plugin Development](./plugins/DEVELOPMENT.md) - [Hooks Reference](HOOKS.md) 📦 **Schémas** - [JSON Schemas Guide](./schemas/SCHEMAS.md) # Communauté ## Contribuer - 🐛 **Issues**: https://github.com/linagora/ldap-rest/issues - 💡 **Discussions**: GitHub Discussions - 📖 **Wiki**: https://deepwiki.com/linagora/ldap-rest - 🤝 **Contributions**: Voir [CONTRIBUTING.md](CONTRIBUTING.md) ## License **AGPL-3.0** - Copyright 2025-present LINAGORA Logiciel libre et open source # Exemples Concrets ## Intégration Twake + Apache James ```bash # Configuration complète npx ldap-rest \ --plugin core/ldap/onChange \ --plugin core/ldap/groups \ --plugin twake/james \ --james-webadmin-url http://james:8000 \ --james-webadmin-token "admin-token" \ --mail-attribute mail \ --quota-attribute mailQuotaSize \ --alias-attribute mailAlternateAddress ``` ### Flux de synchronisation ``` Changement LDAP → onChange → Hook → James WebAdmin API ↓ Logging + Audit ``` ## Plugins de cohérence - Exemples ```typescript // 1. Cohérence des groupes LDAP import groups from 'ldap-rest/plugin-ldap-groups'; dm.registerPlugin('groups', groups); // Suppression utilisateur: // → Retrait automatique de tous ses groupes // → Mise à jour des attributs member/uniqueMember // 2. Cohérence LDAP ↔ James import james from 'ldap-rest/plugin-twake-james'; dm.registerPlugin('james', james); // Changement mail LDAP: // → Renommage compte James // → Mise à jour alias // → Propagation quota // → Cohérence garantie sans intervention manuelle ``` # Exemples Concrets (suite) ## Interface Web Custom ```typescript import LdapUserEditor from 'ldap-rest/browser-ldap-user-editor-index'; // Intégration dans votre app React/Vue/Angular const editor = new LdapUserEditor({ containerId: 'users', apiBaseUrl: process.env.API_URL, onUserSaved: dn => { analytics.track('user_updated', { dn }); notifications.success('Utilisateur sauvegardé'); }, onError: err => { errorTracker.capture(err); }, }); ``` # Comparaison ## LDAP-Rest vs Alternatives | Fonctionnalité | LDAP-Rest | LDAP Account Manager | phpLDAPadmin | | -------------------- | --------- | -------------------- | ------------ | | TypeScript | ✅ | ❌ | ❌ | | Architecture Plugins | ✅ | ⚠️ | ❌ | | API REST | ✅ | ⚠️ | ❌ | | Browser Libraries | ✅ | ❌ | ❌ | | Modern Stack | ✅ | ⚠️ | ❌ | | Extensibilité | ✅✅ | ⚠️ | ⚠️ | | Sync James | ✅ | ❌ | ❌ | | Cohérence auto | ✅ | ❌ | ❌ | # Pourquoi LDAP-Rest ? ## Avantages clés 🎯 **Moderne** - Stack JavaScript moderne - TypeScript first - ES Modules natifs 🔧 **Flexible** - Plugins personnalisables - Hooks extensibles - Schémas configurables 🚀 **Productif** - API REST complète - Composants UI prêts - Documentation riche # Questions & Démo ## Contact - 📧 Email: yadd@debian.org - 🐙 GitHub: https://github.com/linagora/ldap-rest - 🏢 LINAGORA: https://linagora.com ## Démo Live ```bash # Lancer la démo git clone https://github.com/linagora/ldap-rest cd ldap-rest npm install npm run dev ``` Ouvrez http://localhost:8081 # Merci ! ## LDAP-Rest - Gestionnaire d'annuaire léger [![Powered by LINAGORA](../linagora.png)](https://linagora.com) **GitHub**: https://github.com/linagora/ldap-rest **License**: AGPL-3.0 --- _Questions ?_ linagora-ldap-rest-a4bb78e/docs/usage/000077500000000000000000000000001527331160600177355ustar00rootroot00000000000000linagora-ldap-rest-a4bb78e/docs/usage/README.md000066400000000000000000000042331527331160600212160ustar00rootroot00000000000000# Usage - Getting Started Getting started and usage guide for LDAP-Rest. ## Installation ```bash npm install ldap-rest ``` ## Quick Start ### Minimal Configuration ```bash ldap-rest \ --plugin core/ldap/flatGeneric \ --ldap-url ldap://localhost:389 \ --ldap-dn "cn=admin,dc=example,dc=com" \ --ldap-pwd "password" \ --ldap-base "dc=example,dc=com" \ --ldap-flat-schema ./schemas/standard/users.json ``` ### With Authentication ```bash ldap-rest \ --plugin core/auth/token \ --plugin core/ldap/flatGeneric \ --auth-token "secret-token" \ --ldap-flat-schema ./schemas/standard/users.json \ ... ``` ### Complete Configuration ```bash ldap-rest \ --plugin core/auth/token \ --plugin core/ldap/flatGeneric \ --plugin core/ldap/groups \ --plugin core/ldap/organization \ --plugin core/ldap/bulkImport \ --plugin core/ldap/externalUsersInGroups \ --plugin core/static \ --plugin core/weblogs \ --ldap-flat-schema ./schemas/standard/users.json \ --bulk-import-schemas "users:./schemas/standard/users.json" \ --auth-token "admin-token" \ --static-path ./static \ ... ``` ## LDAP Connection ### Single URL ```bash --ldap-url ldap://localhost:389 ``` ### Failover (High Availability) ```bash --ldap-url ldap://ldap1.example.com,ldap://ldap2.example.com,ldap://ldap3.example.com ``` The system will: 1. Try each URL in order 2. Use the first successful connection 3. Automatically failover on connection failure ## Log Levels ```bash --log-level error # Errors only --log-level warn # Warnings and errors --log-level notice # Web access logs (recommended for production) --log-level info # General information --log-level debug # Everything, including debug output ``` ## Next Steps - **[Configuration](configuration.md)** - CLI options and environment variables - **[Plugins](plugins/README.md)** - Choose and configure plugins - **[Upgrading](upgrading.md)** - What to check before deploying a new version - **[Directory audit](directory-audit.md)** - What a schema would refuse in an existing directory - **[Operations](operations.md)** - Health and readiness probes - **[Troubleshooting](troubleshooting.md)** - Problem resolution linagora-ldap-rest-a4bb78e/docs/usage/configuration.md000066400000000000000000002166261527331160600231430ustar00rootroot00000000000000# Configuration All LDAP-Rest configuration options. ## Table of Contents - [Environment Variables](#environment-variables) - [Array Options](#array-options) - [General Options](#general-options) - [LDAP Connection](#ldap-connection) - [Special Attributes](#special-attributes) - [Plugin Options](#plugin-options) - [LDAP Plugins](#ldap-plugins) - [core/ldap/organizations](#coreldaporganizations) - [core/ldap/groups](#coreldapgroups) - [core/ldap/externalUsersInGroups](#coreldapexternalusersingroups) - [core/ldap/flatGeneric](#coreldapflatgeneric) - [core/ldap/bulkImport](#coreldapbulkimport) - [core/ldap/trash](#coreldaptrash) - [core/ldap/onChange](#coreldaponchange) - [core/ldap/departmentSync](#coreldapdepartmentsync) - [Authentication Plugins](#authentication-plugins) - [core/auth/token](#coreauthtoken) - [core/auth/totp](#coreauthtotp) - [core/auth/hmac](#coreauthhmac) - [core/auth/llng](#coreauthllng) - [core/auth/openidconnect](#coreauthopenidconnect) - [core/auth/fake](#coreauthfake) - [core/bcl](#corebcl) - [Authorization Plugins](#authorization-plugins) - [Common to every authorization plugin](#common-to-every-authorization-plugin) - [core/auth/authzPerBranch](#coreauthauthzperbranch) - [core/auth/authzScope](#coreauthauthzscope) - [core/auth/authzPerRoute](#coreauthauthzperroute) - [core/auth/authzLinid1](#coreauthauthzlinid1) - [Security Plugins](#security-plugins) - [core/auth/rateLimit](#coreauthratelimit) - [core/auth/crowdsec](#coreauthcrowdsec) - [core/auth/trustedProxy](#coreauthtrustedproxy) - [Twake Integration Plugins](#twake-integration-plugins) - [core/twake/james](#coretwakejames) - [core/twake/migrationProxy](#coretwakemigrationproxy) - [core/twake/calendar](#coretwakecalendar) - [core/twake/applicativeAccounts](#coretwakeapplicativeaccounts) - [core/twake/appAccountsConsistency](#coretwakeappaccountsconsistency) - [Utility Plugins](#utility-plugins) - [core/static](#corestatic) - [core/weblogs](#coreweblogs) - [core/metrics](#coremetrics) - [core/storage](#corestorage) - [core/configApi](#coreconfigapi) - [Configuration File](#configuration-file) - [LDAP Failover](#ldap-failover) - [Log Levels (`--log-level`)](#log-levels---log-level) - [Logger (`--logger`)](#logger---logger) ## Environment Variables All CLI options can be set via environment variables with the `DM_` prefix. ### Array Options Some options accept multiple values (e.g., `--plugin`, `--auth-token`, `--ldap-url`). These can be configured in several ways: **Via CLI - repeat the option, or give it comma-separated values:** ```bash ldap-rest --plugin core/auth/token --plugin core/ldap/flatGeneric,core/ldap/groups ``` Only options holding identifiers split on commas: `--plugin`, `--mail-domain`, `--ldap-url`, the object class options (`--user-class`, `--group-class`, `--ldap-organization-class`, `--external-branch-class`, `--scim-user-object-class`, `--scim-group-object-class`, `--twake-space-class`), the attribute options (`--ldap-raw-hidden-attribute`, `--applicative-account-attribute`, `--ldap-operational-attribute`, `--twake-tombstone-clear-attributes`), `--twake-tombstone-reasons`, `--authz-for`, `--authz-dynamic-bypass` and `--trusted-proxy`. Any other option takes each value whole, so a DN or a secret keeps its commas. **Via CLI - use plural form with comma-separated values:** ```bash ldap-rest --plugins core/auth/token,core/ldap/flatGeneric,core/ldap/groups ``` The plural form splits like the environment variable, see below. Give a DN with the repeated singular option, or separate DNs with `;` in the plural form: ```bash ldap-rest --ldap-raw-base ou=users,dc=example,dc=com --ldap-raw-base ou=groups,dc=example,dc=com ``` **Via environment variable - use `;` or `,` as separator:** ```bash # Semicolon separator (preferred for values containing commas) export DM_PLUGINS="core/auth/token;core/ldap/flatGeneric;core/ldap/groups" # Comma separator export DM_PLUGINS="core/auth/token,core/ldap/flatGeneric,core/ldap/groups" ``` The split depends on what the option holds. The plural form splits the same way: - Options holding identifiers (the list above): a value containing `;` is split on `;` and spaces, any other on `,` and spaces. A value starting with `;` is split on `;` too. - Options holding DNs or DN expressions (`--ldap-raw-base`, `--james-mailing-list-branch`, `--twake-tombstone-dn`, `--twake-tombstone-group-bases`, `--twake-instance-dn`, `--authz-global-branches`, `--authz-global-write-branches`): split on `;` only, never on `,` nor spaces, as a DN holds both. `DM_LDAP_RAW_BASE="ou=My Unit,dc=example,dc=com"` is one DN; separate several DNs with `;`. - `--auth-hmac` (`id:secret:name`) and `--auth-totp` (`secret:name[:digits]`): split on `;` if the value contains one, else on `,`, and on newlines (one entry per line), never on spaces, so a name keeps its spaces. A list separated by spaces is read as one entry: for HMAC the services after the first cannot authenticate, and for TOTP the entry is refused. Any entry that cannot work stops the server: an HMAC one with an empty id, secret or name or fewer than 3 fields, a TOTP one with an empty secret or name, too many fields, a secret that is not Base32 or digits that are not an integer from 6 to 10. - Any other option (tokens, paths, rules...): split on `;` if the value contains one, else on `,`, and on spaces. `DM_AUTH_TOKENS="t1 t2"` and `DM_AUTH_TOKENS="t1,t2"` are two tokens, and padded base64 secrets (`YWJj=,ZGVm=`) split on the comma. Empty items are dropped, and each item is trimmed. A variable that is empty, only holds whitespace or only holds separators (`;`, `${A:-};${B:-}`), such as `${VAR:-}` in a compose file, counts as unset: the default applies, so an environment variable cannot give an empty list to an option whose default is not empty. The exception is `DM_LDAP_URL`, whose default `ldap://localhost` is a guess: an empty value stops the server. The same goes for an empty number or JSON value such as `DM_PORT=`. A number option takes an integer only: `DM_PORT=abc`, `DM_PORT=1e3`, `--port 80abc` and `--port --log-level debug` stop the server, and so does an empty number on the command line, as an empty array value does. The plural form of an option takes `;` before `,` too: `--auth-tokens 'a;b,c'` is `a` and `b,c`. Values given on the command line replace the default, and are added to those of the environment variable. An empty value on the command line, such as an unset variable expanding to nothing, stops the server: leave the option out to keep the default. An option or `DM_*` variable that no declared option reads is named in a warning at startup. `--strict-options` refuses it instead. Kubernetes injects `DM_*` variables for services whose name starts with `dm`; leave the flag off where those names are in use. ## General Options | CLI | Plural | Env | Default | Description | | -------------------- | ---------------- | --------------------- | ---------------- | ------------------------------------------------------------------------------------------------- | | `--port` | | `DM_PORT` | `8081` | Listen port | | `--shutdown-timeout` | | `DM_SHUTDOWN_TIMEOUT` | `20000` | How long a stop may take, in ms, see [shutdown](operations.md#shutdown) | | `--plugin` | `--plugins` | `DM_PLUGINS` | `[]` | Plugins to load | | `--log-level` | | `DM_LOG_LEVEL` | `notice` | Log level: error, warn, notice, info, debug | | `--logger` | | `DM_LOGGER` | `console` | `console` or `file`, see [Logger](#logger---logger) | | `--log-file` | | `DM_LOG_FILE` | `error.log` | File `--logger file` writes to | | `--debug` | | `DM_DEBUG` | `false` | Name the failure in the 5xx answers of the error middleware and `serverError`; not for production | | `--strict-options` | | `DM_STRICT_OPTIONS` | `false` | Refuse an option or `DM_*` variable no declared option reads | | `--api-prefix` | | `DM_API_PREFIX` | `/api` | API URL prefix | | `--mail-domain` | `--mail-domains` | `DM_MAIL_DOMAIN` | `[]` | Mail domains | | `--schemas-path` | | `DM_SCHEMAS_PATH` | `static/schemas` | Path to JSON schemas | A schema file named by an option may extend another one, shipped or not, and hold only the differences: [extending a schema](../client-development/schemas/README.md#extending-a-schema). ## LDAP Connection | CLI | Plural | Env | Default | Description | | ---------------------------- | ---------------- | ------------------------ | ---------------------------------- | ------------------------------------------- | | `--ldap-url` | `--ldap-urls` | `DM_LDAP_URL` | `ldap://localhost` | LDAP server URL(s) | | `--ldap-dn` | | `DM_LDAP_DN` | | Bind DN (required) | | `--ldap-pwd` | | `DM_LDAP_PWD` | | Password (required) | | `--ldap-base` | | `DM_LDAP_BASE` | | Base DN for searches (required) | | `--ldap-user-main-attribute` | | `DM_LDAP_USER_ATTRIBUTE` | `uid` | User identifier attribute | | `--ldap-cache-max` | | `DM_LDAP_CACHE_MAX` | `1000` | Max cache entries | | `--ldap-cache-ttl` | | `DM_LDAP_CACHE_TTL` | `0` | Search cache TTL (seconds), `0` disables it | | `--ldap-pool-size` | | `DM_LDAP_POOL_SIZE` | `5` | Connection pool size | | `--ldap-connection-ttl` | | `DM_LDAP_CONNECTION_TTL` | `60` | Connection TTL (seconds) | | `--user-class` | `--user-classes` | `DM_USER_CLASSES` | `top,twakeAccount,twakeWhitePages` | Default user objectClasses | ### The search cache `--ldap-cache-ttl` holds the answers of non-paginated, base-scope searches — single-entry lookups by DN — for that many seconds. It is **off by default**: until the branch storing a result was repaired, nothing was ever cached, so no deployment has run with it on, and a cache that silently serves stale entries is not something to switch on for everyone at once. Writes made through this service (add, modify, rename, move, delete) drop what they change, the moved subtree included, and what the directory's overlays change with them: the `memberOf` of the members a group gains or loses, and the entries naming an entry renamed or deleted. Writes made anywhere else do not: another replica of this service, an LSC synchronisation, a hand-run `ldapmodify`. Each process caches on its own, and only its own writes drop what it cached. Turn the cache on when this service is the only writer, or when serving an entry up to `--ldap-cache-ttl` seconds old is acceptable; leave it at `0` otherwise. ## Special Attributes | CLI | Env | Default | Description | | -------------------------- | --------------------------- | ----------------------- | ---------------------- | | `--mail-attribute` | `DM_MAIL_ATTRIBUTE` | `mail` | Email attribute | | `--quota-attribute` | `DM_QUOTA_ATTRIBUTE` | `mailQuotaSize` | Quota attribute | | `--delegation-attribute` | `DM_DELEGATION_ATTRIBUTE` | `twakeDelegatedUsers` | Delegation attribute | | `--alias-attribute` | `DM_ALIAS_ATTRIBUTE` | `mailAlternateAddress` | Email alias attribute | | `--forward-attribute` | `DM_FORWARD_ATTRIBUTE` | `mailForwardingAddress` | Forward attribute | | `--display-name-attribute` | `DM_DISPLAY_NAME_ATTRIBUTE` | `displayName` | Display name attribute | ## Plugin Options ### LDAP Plugins #### `core/ldap/organizations` | CLI | Plural | Env | Default | Description | | ------------------------------------ | ----------------------------- | ------------------------------------- | ---------------------------------------- | -------------------------- | | `--ldap-top-organization` | | `DM_LDAP_TOP_ORGANIZATION` | | Top organization DN | | `--ldap-organization-class` | `--ldap-organization-classes` | `DM_LDAP_ORGANIZATION_CLASSES` | `top,organizationalUnit,twakeDepartment` | Organization objectClasses | | `--ldap-organization-link-attribute` | | `DM_LDAP_ORGANIZATION_LINK_ATTRIBUTE` | `twakeDepartmentLink` | Link attribute | | `--ldap-organization-path-attribute` | | `DM_LDAP_ORGANIZATION_PATH_ATTRIBUTE` | `twakeDepartmentPath` | Path attribute | | `--ldap-organization-path-separator` | | `DM_LDAP_ORGANIZATION_PATH_SEPARATOR` | `/` | Path separator | | `--ldap-organization-max-subnodes` | | `DM_LDAP_ORGANIZATION_MAX_SUBNODES` | `50` | Max subnodes returned | #### `core/ldap/groups` | CLI | Plural | Env | Default | Description | | ----------------------------------- | ----------------- | ------------------------------- | ---------------------------------- | --------------------------- | | `--ldap-group-base` | | `DM_LDAP_GROUP_BASE` | | Groups base DN | | `--ldap-groups-main-attribute` | | `DM_LDAP_GROUPS_MAIN_ATTRIBUTE` | `cn` | Group identifier attribute | | `--group-class` | `--group-classes` | `DM_GROUP_CLASSES` | `top,groupOfNames` | Group objectClasses | | `--groups-allow-unexistent-members` | | `DM_ALLOW_UNEXISTENT_MEMBERS` | `false` | Allow non-existent members | | `--group-default-attributes` | | `DM_GROUP_DEFAULT_ATTRIBUTES` | `{}` | Default attributes (JSON) | | `--group-dummy-user` | | `DM_GROUP_DUMMY_USER` | `cn=fakeuser` | Dummy user for empty groups | | `--group-schema` | | `DM_GROUP_SCHEMA` | `static/schemas/twake/groups.json` | Group JSON schema path | #### `core/ldap/externalUsersInGroups` | CLI | Plural | Env | Default | Description | | --------------------------- | --------------------------- | ---------------------------- | ------------------------------- | --------------------------- | | `--external-members-branch` | | `DM_EXTERNAL_MEMBERS_BRANCH` | `ou=contacts,dc=example,dc=com` | External contacts branch | | `--external-branch-class` | `--external-branch-classes` | `DM_EXTERNAL_BRANCH_CLASSES` | `top,inetOrgPerson` | External user objectClasses | #### `core/ldap/flatGeneric` | CLI | Plural | Env | Default | Description | | ------------------------- | --------------------- | -------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `--ldap-flat-schema` | `--ldap-flat-schemas` | `DM_LDAP_FLAT_SCHEMA` | `[]` | Entity schema path(s) | | `--ldap-flat-auto-repair` | | `DM_LDAP_FLAT_AUTO_REPAIR` | `true` | Add the auxiliary class a write needs to an entry lacking it — [details](plugins/ldap/flat-generic.md#entries-created-by-other-tools) | #### `core/ldap/bulkImport` | CLI | Env | Default | Description | | ----------------------------- | ------------------------------ | ---------- | ------------------------ | | `--bulk-import-schemas` | `DM_BULK_IMPORT_SCHEMAS` | | Bulk import schemas path | | `--bulk-import-max-file-size` | `DM_BULK_IMPORT_MAX_FILE_SIZE` | `10485760` | Max file size (bytes) | | `--bulk-import-batch-size` | `DM_BULK_IMPORT_BATCH_SIZE` | `100` | Batch size | #### `core/ldap/trash` | CLI | Env | Default | Description | | ----------------------- | ------------------------ | ------- | --------------------------- | | `--trash-base` | `DM_TRASH_BASE` | | Trash container DN | | `--trash-watched-bases` | `DM_TRASH_WATCHED_BASES` | | `;`-separated DNs to watch | | `--trash-add-metadata` | `DM_TRASH_ADD_METADATA` | `true` | Add deletion metadata | | `--trash-auto-create` | `DM_TRASH_AUTO_CREATE` | `true` | Auto-create trash container | #### `core/ldap/passwordPolicy` Policy management, password status and lockouts, see [password policy](plugins/ldap/password-policy.md). | CLI | Env | Default | Description | | ------------------------------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------- | | `--ppolicy-default-dn` | `DM_PPOLICY_DEFAULT_DN` | | Default policy, [needed with several](plugins/ldap/password-policy.md#which-policy-applies) | | `--ppolicy-base` | `DM_PPOLICY_BASE` | `--ldap-base` | Where policies are looked for and created | | `--ppolicy-warn-days` | `DM_PPOLICY_WARN_DAYS` | `14` | Days before expiration to flag as "expiring soon" | | `--ppolicy-validate-complexity` | `DM_PPOLICY_VALIDATE_COMPLEXITY` | `false` | Enable `POST /password/validate` | | `--ppolicy-min-length` | `DM_PPOLICY_MIN_LENGTH` | `12` | Minimum length checked by `/password/validate` | | `--ppolicy-require-uppercase` | `DM_PPOLICY_REQUIRE_UPPERCASE` | `true` | Require an uppercase letter | | `--ppolicy-require-lowercase` | `DM_PPOLICY_REQUIRE_LOWERCASE` | `true` | Require a lowercase letter | | `--ppolicy-require-digit` | `DM_PPOLICY_REQUIRE_DIGIT` | `true` | Require a digit | | `--ppolicy-require-special` | `DM_PPOLICY_REQUIRE_SPECIAL` | `true` | Require a special character | #### `core/ldap/onChange` Monitors LDAP modifications and triggers hooks for attribute changes. No configuration options - uses [Special Attributes](#special-attributes) settings. **Hooks triggered:** - `onLdapChange` - Any LDAP modification - `onLdapMailChange` - Mail attribute changes - `onLdapQuotaChange` - Quota attribute changes - `onLdapAliasChange` - Alias attribute changes - `onLdapForwardChange` - Forward attribute changes - `onLdapDisplayNameChange` - Display name changes (`--display-name-attribute`, else cn, else givenName and sn) #### `core/ldap/departmentSync` Maintains consistency of department links when organizations are renamed/moved: it recomputes the path of the organization and of its sub-organizations (those already holding one), then updates the link and path of the entries linked to any of them. The attributes are those the loaded schemas declare through the `organizationPath` and `organizationLink` roles, then the configured `--ldap-organization-path-attribute` and `--ldap-organization-link-attribute`. No configuration options of its own - uses [core/ldap/organizations](#coreldaporganizations) settings. #### `core/ldap/raw` Read-only low-level browsing (root DSE, schema, entries). See [raw plugin](plugins/ldap/raw.md). | CLI | Plural | Env | Default | Description | | ----------------------------- | ------------------------------ | ------------------------------- | ------------- | -------------------------------------------------------------------- | | `--ldap-raw-base` | `--ldap-raw-bases` | `DM_LDAP_RAW_BASE` | `--ldap-base` | Subtrees exposed by the API | | `--ldap-raw-hidden-attribute` | `--ldap-raw-hidden-attributes` | `DM_LDAP_RAW_HIDDEN_ATTRIBUTES` | `[]` | Extra attributes never returned | | `--ldap-raw-show-secrets` | | `DM_LDAP_RAW_SHOW_SECRETS` | `false` | Serve credential attributes | | `--ldap-raw-max-results` | | `DM_LDAP_RAW_MAX_RESULTS` | `200` | Max entries per search/listing | | `--ldap-raw-schema-cache-ttl` | | `DM_LDAP_RAW_SCHEMA_CACHE_TTL` | `3600` | Schema cache lifetime (seconds), shared with the object class repair | ### Authentication Plugins #### Common to every authentication plugin | CLI | Plural | Env | Default | Description | | -------------------- | ---------------------- | --------------------- | ------- | ------------------------------------------- | | `--auth-path-prefix` | `--auth-path-prefixes` | `DM_AUTH_PATH_PREFIX` | `[]` | Restrict this plugin to these path prefixes | Empty means the plugin guards every path. Scoping several instances to different prefixes lets one server serve populations that authenticate differently — see [Serving several populations from one server](plugins/auth/README.md#serving-several-populations-from-one-server), including the routes it can leave unauthenticated. #### `core/auth/token` | CLI | Plural | Env | Default | Description | | -------------- | --------------- | ---------------- | ------- | --------------------- | | `--auth-token` | `--auth-tokens` | `DM_AUTH_TOKENS` | `[]` | Authentication tokens | #### `core/auth/fake` | CLI | Env | Default | Description | | ------------------ | ------------------- | ------- | ---------------------------------------------- | | `--auth-fake-user` | `DM_AUTH_FAKE_USER` | | Identity every request is served as (required) | Development only, refused with `NODE_ENV=production` — see [Fake Authentication](plugins/auth/fake.md). #### `core/auth/totp` | CLI | Plural | Env | Default | Description | | -------------------- | -------------- | --------------------- | ------- | -------------------------------- | | `--auth-totp` | `--auth-totps` | `DM_AUTH_TOTP` | `[]` | TOTP config (secret:name:digits) | | `--auth-totp-window` | | `DM_AUTH_TOTP_WINDOW` | `1` | Validation window | | `--auth-totp-step` | | `DM_AUTH_TOTP_STEP` | `30` | Time step (seconds) | #### `core/auth/hmac` | CLI | Plural | Env | Default | Description | | -------------------- | -------------- | --------------------- | -------- | ------------------------------------ | | `--auth-hmac` | `--auth-hmacs` | `DM_AUTH_HMAC` | `[]` | HMAC config (service-id:secret:name) | | `--auth-hmac-window` | | `DM_AUTH_HMAC_WINDOW` | `120000` | Time window (ms) | #### `core/auth/llng` | CLI | Env | Default | Description | | ------------------------ | ------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------- | | `--llng-ini` | `DM_LLNG_INI` | `/etc/lemonldap-ng/lemonldap-ng.ini` | LemonLDAP::NG config path | | `--llng-username-header` | `DM_LLNG_USERNAME_HEADER` | | Header LLNG exports with the login, published as `req.userName` (see [llng](plugins/auth/llng.md)) | #### `core/auth/openidconnect` | CLI | Env | Default | Description | | ----------------------- | ------------------------ | ------- | --------------------------------------------------------------------------------------- | | `--oidc-server` | `DM_OIDC_SERVER` | | OIDC server URL | | `--oidc-client-id` | `DM_OIDC_CLIENT_ID` | | OIDC Client ID | | `--oidc-client-secret` | `DM_OIDC_CLIENT_SECRET` | | OIDC Client Secret | | `--oidc-username-claim` | `DM_OIDC_USERNAME_CLAIM` | `sub` | Claim naming the caller, published as `req.userName` (see [OIDC](plugins/auth/oidc.md)) | | `--oidc-logout-origins` | `DM_OIDC_LOGOUT_ORIGINS` | `[]` | Other origins a console may log out from (see [OIDC](plugins/auth/oidc.md#logout)) | | `--base-url` | `DM_BASE_URL` | | Public URL for callbacks | #### `core/bcl` Back-Channel Logout: a logout performed at the provider ends the session here on the next request. Needs `core/storage`, and refuses to start without it — see [Back-Channel Logout](plugins/auth/back-channel-logout.md). | CLI | Env | Default | Description | | ----------------- | ------------------ | -------- | --------------------------- | | `--bcl-retention` | `DM_BCL_RETENTION` | `604800` | Seconds a tombstone is kept | ### Authorization Plugins #### Common to every authorization plugin | CLI | Env | Default | Description | | ------------------------- | -------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--authz-unresolved-user` | `DM_AUTHZ_UNRESOLVED_USER` | `deny` | What an authenticated identity the plugin cannot resolve means: `deny` refuses the operation, `allow` lets it through with a warning | | `--authz-identity` | `DM_AUTHZ_IDENTITY` | `req.user` | Which value rules are keyed on: `req.user`, this server's identifier for the caller, or `req.userName`, the caller under a name a person would use. See [what a rule is keyed on](plugins/auth/README.md#what-a-rule-is-keyed-on) | | `--authz-for` | `DM_AUTHZ_FOR` | | The authentication plugins, by instance name, whose requests the plugin judges; unset, every authenticated request. Meant for a plugin's own overrides: `{"authz_for":["oidc"]}` | | `--authz-combine` | `DM_AUTHZ_COMBINE` | `false` | Let two plugins judging the LDAP operations of the same requests start together, as an AND | `--authz-unresolved-user` is read by the plugins that resolve an identity before judging it — `core/auth/authzPerBranch` and `core/auth/authzLinid1`. `core/auth/authzPerRoute` matches the identity as it stands and `core/auth/authzDynamic` judges a token, so neither resolves anything and neither reads it. A value other than `deny` or `allow` is refused at startup. An **anonymous** request is not this case: it carries no identity, and every authorization plugin skips it as before. This is about a caller the authenticator admitted whose identity the authorization model cannot place — `authzLinid1` looking up a `uid` that does not exist, typically because the authenticator publishes something else. `allow` is the behaviour every plugin had before 0.8.3, and it is an open door where `authzLinid1` is loaded: see [the note](upgrading.md#an-identity-that-does-not-resolve-is-refused). `--authz-for` and `--authz-combine` say how plugins loaded together compose: without them, two plugins judging the LDAP operations of the same requests are refused at startup — see [several authorization plugins](plugins/auth/README.md#several-authorization-plugins). `--authz-for` takes one name per occurrence: repeat it (`--authz-for oidc --authz-for authToken`), or give the environment variable a list (`DM_AUTHZ_FOR="oidc authToken"`). A second word after the value — `--authz-for oidc authToken` — is refused rather than dropped, as it is after every option taking a list. `--authz-identity` is a different question, and the paragraphs above are not about it: `core/auth/authzPerRoute` and the SCIM base map read it as well, since a route rule and the base a SCIM operation is served from are keyed on whichever of the two names it selects. A value other than `req.user` or `req.userName` is refused at startup. #### `core/auth/authzPerBranch` | CLI | Env | Default | Description | | ---------------------------------- | ----------------------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--authz-per-branch-config` | `DM_AUTHZ_PER_BRANCH_CONFIG` | `{default:{read:true,write:false,delete:false}}` | Authorization config (JSON) | | `--authz-per-branch-cache-ttl` | `DM_AUTHZ_PER_BRANCH_CACHE_TTL` | `60` | Cache TTL (seconds) | | `--ldap-group-member-attribute` | `DM_LDAP_GROUP_MEMBER_ATTRIBUTE` | `member` | Attribute a group lists its members in | | `--authz-filter-attached-entries` | `DM_AUTHZ_FILTER_ATTACHED_ENTRIES` | `false` | Judge an attached entry by the organization it hangs off, and filter listings accordingly. Honoured by `authzPerBranch`; always on under `authzLinid1`; does nothing under `authzDynamic`. | | `--authz-transit-branch` | `DM_AUTHZ_TRANSIT_BRANCH` | | Organization entries are handed over through when they are judged by attachment: every administrator sees and may claim what is there, and puts an entry there with write on the organization it leaves. An entry attached to no organization is in transit too. See [authzLinid1](plugins/auth/authz-linid1.md#transit-handing-an-entry-over). | | `--authz-transit-origin-attribute` | `DM_AUTHZ_TRANSIT_ORIGIN_ATTRIBUTE` | | Attribute an entry put in transit keeps the organization it leaves in, removed when it is claimed. Empty records nothing; it must exist in the directory's schema. See [authzLinid1](plugins/auth/authz-linid1.md#where-an-entry-comes-from). | #### `core/auth/authzScope` | CLI | Env | Default | Description | | ---------------------- | ----------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--authz-scope-source` | `DM_AUTHZ_SCOPE_SOURCE` | | The plugin, by instance name, that describes a caller's scope when several judge them; unset, the first loaded. See [authz-scope](plugins/auth/authz-scope.md) | #### `core/auth/authzPerRoute` | CLI | Env | Default | Description | | ------------------- | --------------------- | ------- | ---------------------------------------------------------------------- | | `--authz-per-route` | `DM_AUTHZ_PER_ROUTES` | `[]` | Per-user route ACL rules (see [docs](plugins/auth/authz-per-route.md)) | #### `core/auth/authzLinid1` | CLI | Env | Default | Description | | --------------------------------------------------------------- | -------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--authz-local-admin-attribute` | `DM_AUTHZ_LOCAL_ADMIN_ATTRIBUTE` | `twakeLocalAdminLink` | Local admin attribute | | `--authz-global-branch` / `--authz-global-branches` | `DM_AUTHZ_GLOBAL_BRANCHES` | | Branches outside the tree only the administrators of `--authz-global-organization` read and write, [details](plugins/auth/authz-linid1.md#global-branches) | | `--authz-global-write-branch` / `--authz-global-write-branches` | `DM_AUTHZ_GLOBAL_WRITE_BRANCHES` | | Branches outside the tree every administrator reads and only the administrators of `--authz-global-organization` write and delete in, [details](plugins/auth/authz-linid1.md#global-branches) | | `--authz-global-organization` | `DM_AUTHZ_GLOBAL_ORGANIZATION` | `--ldap-top-organization` | The organization holding the global branches and global write branches | ### Security Plugins #### `core/auth/rateLimit` | CLI | Env | Default | Description | | ------------------------ | ------------------------- | -------- | ------------------------ | | `--rate-limit-window-ms` | `DM_RATE_LIMIT_WINDOW_MS` | `900000` | Time window (ms, 15 min) | | `--rate-limit-max` | `DM_RATE_LIMIT_MAX` | `100` | Max requests per window | #### `core/auth/crowdsec` | CLI | Env | Default | Description | | ---------------------- | ----------------------- | ------------------------------------ | ------------------- | | `--crowdsec-url` | `DM_CROWDSEC_URL` | `http://localhost:8080/v1/decisions` | CrowdSec API URL | | `--crowdsec-api-key` | `DM_CROWDSEC_API_KEY` | | CrowdSec API key | | `--crowdsec-cache-ttl` | `DM_CROWDSEC_CACHE_TTL` | `60` | Cache TTL (seconds) | #### `core/auth/trustedProxy` | CLI | Plural | Env | Default | Description | | ----------------------------- | ------------------- | ------------------------------ | ----------- | ---------------------- | | `--trusted-proxy` | `--trusted-proxies` | `DM_TRUSTED_PROXIES` | `[]` | Trusted proxy IPs/CIDR | | `--trusted-proxy-auth-header` | | `DM_TRUSTED_PROXY_AUTH_HEADER` | `Auth-User` | User header name | ### Twake Integration Plugins #### `core/twake/james` | CLI | Plural | Env | Default | Description | | --------------------------------- | ------------------------------- | ---------------------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `--james-webadmin-url` | | `DM_JAMES_WEBADMIN_URL` | `http://localhost:8000` | James WebAdmin API URL | | `--james-webadmin-token` | | `DM_JAMES_WEBADMIN_TOKEN` | | James authentication token | | `--james-signature-template` | | `DM_JAMES_SIGNATURE_TEMPLATE` | | Email signature template | | `--james-concurrency` | | `DM_JAMES_CONCURRENCY` | `10` | James API concurrency | | `--james-init-delay` | | `DM_JAMES_INIT_DELAY` | `1000` | Init delay (ms) | | `--james-retry-attempts` | | `DM_JAMES_RETRY_ATTEMPTS` | `5` | Attempts per user call | | `--james-retry-delay` | | `DM_JAMES_RETRY_DELAY` | `1000` | Base retry delay (ms) | | `--james-mailing-list-branch` | `--james-mailing-list-branches` | `DM_JAMES_MAILING_LIST_BRANCHES` | `[]` | Branches refused to team mailboxes | | `--james-mailbox-type-attribute` | | `DM_JAMES_MAILBOX_TYPE_ATTRIBUTE` | `twakeMailboxType` | Mailbox type attribute | | `--james-sync-forwards` | | `DM_JAMES_SYNC_FORWARDS` | `false` | Copy LDAP forwards to Twake Mail — [details](plugins/integrations/james-mail.md#forwards) | | `--james-team-mailbox-any-domain` | | `DM_JAMES_TEAM_MAILBOX_ANY_DOMAIN` | `false` | Let team mailboxes hold members of any domain — [details](plugins/integrations/james-mailboxes.md#members-belong-to-the-mailboxs-domain) | | `--ldap-concurrency` | | `DM_LDAP_CONCURRENCY` | `10` | LDAP operations concurrency | The wait before each retry grows with the attempt, so the total wait is `--james-retry-delay` × n(n−1)/2 for n attempts: 10 s with the defaults, 190 s with 20 attempts. #### `core/twake/migrationProxy` Keeps the migrated-users list of the Twake Mail migration proxy in step with the accounts, [details](plugins/integrations/mail-migration.md). | CLI | Plural | Env | Default | Description | | ---------------------------------- | ---------------------------- | ----------------------------------- | ------- | ---------------------------------------------------------------- | | `--migration-proxy-url` | | `DM_MIGRATION_PROXY_URL` | | Webadmin URL of the proxy (required) | | `--migration-proxy-token` | | `DM_MIGRATION_PROXY_TOKEN` | | Bearer token | | `--migration-proxy-branch` | `--migration-proxy-branches` | `DM_MIGRATION_PROXY_BRANCHES` | `[]` | Branches whose entries are followed, `;`-separated (required) | | `--migration-proxy-concurrency` | | `DM_MIGRATION_PROXY_CONCURRENCY` | `10` | Calls to the proxy at once | | `--migration-proxy-retry-attempts` | | `DM_MIGRATION_PROXY_RETRY_ATTEMPTS` | `5` | Attempts in all of a call the proxy answers 500, 502, 503 or 504 | | `--migration-proxy-retry-delay` | | `DM_MIGRATION_PROXY_RETRY_DELAY` | `1000` | Base retry delay (ms), growing with the attempt | #### `core/twake/calendar` Formerly `core/twake/calendarResources`, still accepted as a deprecated alias. | CLI | Env | Default | Description | | --------------------------------- | ---------------------------------- | ----------------------- | ----------------------------- | | `--calendar-webadmin-url` | `DM_CALENDAR_WEBADMIN_URL` | `http://localhost:8080` | Calendar API URL | | `--calendar-webadmin-token` | `DM_CALENDAR_WEBADMIN_TOKEN` | | Calendar authentication token | | `--calendar-concurrency` | `DM_CALENDAR_CONCURRENCY` | `10` | API concurrency | | `--calendar-resource-base` | `DM_CALENDAR_RESOURCE_BASE` | | Resource base DN | | `--calendar-resource-objectclass` | `DM_CALENDAR_RESOURCE_OBJECTCLASS` | | Resource objectClass | | `--calendar-resource-creator` | `DM_CALENDAR_RESOURCE_CREATOR` | `admin@example.com` | Resource creator | | `--calendar-resource-domain` | `DM_CALENDAR_RESOURCE_DOMAIN` | | Resource domain | | `--calendar-firstname-attribute` | `DM_CALENDAR_FIRSTNAME_ATTRIBUTE` | `givenName` | Registered user first name | | `--calendar-lastname-attribute` | `DM_CALENDAR_LASTNAME_ATTRIBUTE` | `sn` | Registered user last name | #### `core/twake/applicativeAccounts` | CLI | Plural | Env | Default | Description | | --------------------------------- | ---------------------------------- | ----------------------------------- | ------------------ | ------------------------------------------------------ | | `--applicative-account-base` | | `DM_APPLICATIVE_ACCOUNT_BASE` | | Applicative accounts base DN | | `--max-app-accounts` | | `DM_MAX_APP_ACCOUNTS` | `5` | Max accounts per user | | `--app-accounts-user-attribute` | | `DM_APP_ACCOUNTS_USER_ATTRIBUTE` | the mail attribute | Attribute the `:user` of the routes is matched against | | `--applicative-account-attribute` | `--applicative-account-attributes` | `DM_APPLICATIVE_ACCOUNT_ATTRIBUTES` | _(see below)_ | Attributes copied from the user entry | | `--ldap-operational-attribute` | `--ldap-operational-attributes` | `DM_LDAP_OPERATIONAL_ATTRIBUTES` | _(see below)_ | Operational attributes to exclude | Default copied attributes: `objectClass`, `cn`, `sn`, `givenName`, `displayName`, `description` — plus the configured mail attribute, always. This is an **allowlist**: an attribute the user entry carries but that is not named here is not copied into the applicative branch. That branch is only ever read to bind with `uid` and `userPassword`, so anything else has no reason to be duplicated there — and a new attribute added to the user schema later stays out unless someone opts in. Default operational attributes: `dn`, `controls`, `structuralObjectClass`, `entryUUID`, `entryDN`, `subschemaSubentry`, `modifyTimestamp`, `modifiersName`, `createTimestamp`, `creatorsName`, `userPassword` Operational attributes are only used when an applicative entry is **recreated from its own current state** (on a mail change): they are what the directory generates and would refuse on an `add`. `userPassword` is in that list on purpose — a mail change forces every client to be reconfigured anyway, so app accounts are deliberately reissued without a password. #### `core/twake/appAccountsConsistency` Automatically creates/updates/deletes applicative account entries when users are created/modified/deleted. No configuration options - uses [core/twake/applicativeAccounts](#coretwakeapplicativeaccounts) settings. **Requires:** `core/ldap/onChange` #### `core/twake/lifecycleEvents` | CLI | Env | Default | Description | | ---------------------------------------- | ----------------------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------- | | `--twake-lifecycle-role-attribute` | `DM_TWAKE_LIFECYCLE_ROLE_ATTRIBUTE` | | Role attribute | | `--twake-lifecycle-lock-attribute` | `DM_TWAKE_LIFECYCLE_LOCK_ATTRIBUTE` | `--scim-user-lock-attribute` | Lock attribute | | `--twake-lifecycle-lock-value` | `DM_TWAKE_LIFECYCLE_LOCK_VALUE` | see description | Value marking an account locked: SCIM's with SCIM's attribute, required with another than `pwdAccountLockedTime` | | `--twake-lifecycle-deleted-attribute` | `DM_TWAKE_LIFECYCLE_DELETED_ATTRIBUTE` | | Attribute marking a tombstone | | `--twake-lifecycle-deleted-value` | `DM_TWAKE_LIFECYCLE_DELETED_VALUE` | `TRUE` | Value marking a tombstone | | `--twake-lifecycle-deleted-at-attribute` | `DM_TWAKE_LIFECYCLE_DELETED_AT_ATTRIBUTE` | | Deletion date attribute | | `--twake-lifecycle-deleted-at-format` | `DM_TWAKE_LIFECYCLE_DELETED_AT_FORMAT` | `iso8601` | `iso8601` or `generalizedTime` | | `--twake-lifecycle-rules` | `DM_TWAKE_LIFECYCLE_RULES` | | Rules: a JSON file, or the JSON itself | | `--twake-lifecycle-member-attribute` | `DM_TWAKE_LIFECYCLE_MEMBER_ATTRIBUTE` | `member` | Attribute holding a group's members | See [lifecycle events](plugins/integrations/lifecycle-events.md). **Requires:** `core/ldap/onChange`, `core/rabbitmq` #### `core/twake/instances` | CLI | Env | Default | Description | | ------------------------------------------------ | ------------------------------------------------- | ------------------------------------- | --------------------------------------------------------- | | `--twake-instance-dn` | `DM_TWAKE_INSTANCE_DN` | `[]` | DN expressions of the accounts to equip | | `--twake-instance-skip-attribute` | `DM_TWAKE_INSTANCE_SKIP_ATTRIBUTE` | | Attribute marking an account with no instance | | `--twake-instance-skip-value` | `DM_TWAKE_INSTANCE_SKIP_VALUE` | | Its value | | `--twake-instance-provider` | `DM_TWAKE_INSTANCE_PROVIDER` | `cloudery` | `cloudery` or `cozy-stack` | | `--twake-instance-id` | `DM_TWAKE_INSTANCE_ID` | `{uid}` | Instance id template | | `--twake-instance-fqdn-attribute` | `DM_TWAKE_INSTANCE_FQDN_ATTRIBUTE` | `twakeWorkspaceUrl` | Address attribute | | `--twake-instance-sent-attribute` | `DM_TWAKE_INSTANCE_SENT_ATTRIBUTE` | `twakeCreatedEventAt` | Date the creation event was sent | | `--twake-instance-locale` | `DM_TWAKE_INSTANCE_LOCALE` | `en`, `fr` with cozy-stack | Locale when the entry has none | | `--twake-instance-timeout` | `DM_TWAKE_INSTANCE_TIMEOUT` | `30000` | Milliseconds a provider call may take | | `--twake-instance-cloudery-url` | `DM_TWAKE_INSTANCE_CLOUDERY_URL` | | Cloudery API | | `--twake-instance-cloudery-token` | `DM_TWAKE_INSTANCE_CLOUDERY_TOKEN` | | Its token | | `--twake-instance-cloudery-domain` | `DM_TWAKE_INSTANCE_CLOUDERY_DOMAIN` | | Domain of the instances | | `--twake-instance-cloudery-offer` | `DM_TWAKE_INSTANCE_CLOUDERY_OFFER` | `b2b_twake_default` | Offer of an account's instance, or one per DN expression | | `--twake-instance-cloudery-organization-offer` | `DM_TWAKE_INSTANCE_CLOUDERY_ORGANIZATION_OFFER` | | Offer of an organization's instance | | `--twake-instance-cozy-url` | `DM_TWAKE_INSTANCE_COZY_URL` | | cozy-stack admin API | | `--twake-instance-cozy-user` | `DM_TWAKE_INSTANCE_COZY_USER` | `admin` | Its user | | `--twake-instance-cozy-passphrase` | `DM_TWAKE_INSTANCE_COZY_PASSPHRASE` | | Its passphrase | | `--twake-instance-cozy-domain` | `DM_TWAKE_INSTANCE_COZY_DOMAIN` | | Domain of the instances | | `--twake-instance-cozy-context` | `DM_TWAKE_INSTANCE_COZY_CONTEXT` | `default` | cozy-stack context | | `--twake-instance-cozy-apps` | `DM_TWAKE_INSTANCE_COZY_APPS` | `home,drive,settings,notes,dataproxy` | Apps installed on a new instance | | `--twake-instance-cozy-org-id` | `DM_TWAKE_INSTANCE_COZY_ORG_ID` | | Organization id when the DN gives none | | `--twake-instance-cozy-org-domain` | `DM_TWAKE_INSTANCE_COZY_ORG_DOMAIN` | | Organization domain when the DN gives none | | `--twake-instance-auth-exchange` | `DM_TWAKE_INSTANCE_AUTH_EXCHANGE` | `auth` | Exchange of account events | | `--twake-instance-b2b-exchange` | `DM_TWAKE_INSTANCE_B2B_EXCHANGE` | `b2b` | Exchange of organization events | | `--twake-instance-user-created-key` | `DM_TWAKE_INSTANCE_USER_CREATED_KEY` | `user.created` | Routing key of an account's creation | | `--twake-instance-organization-created-key` | `DM_TWAKE_INSTANCE_ORGANIZATION_CREATED_KEY` | `organization.created` | Routing key of an organization's creation | | `--twake-instance-workplace-created-key` | `DM_TWAKE_INSTANCE_WORKPLACE_CREATED_KEY` | `workplace.created` | Routing key the Cloudery announces on | | `--twake-instance-queue` | `DM_TWAKE_INSTANCE_QUEUE` | `workplace.created.ldap-rest` | Queue shared by the replicas | | `--twake-instance-organization-base` | `DM_TWAKE_INSTANCE_ORGANIZATION_BASE` | | Branch of the organization entries | | `--twake-instance-organization-id-attribute` | `DM_TWAKE_INSTANCE_ORGANIZATION_ID_ATTRIBUTE` | `ou` | Attribute holding an organization's id | | `--twake-instance-organization-domain-attribute` | `DM_TWAKE_INSTANCE_ORGANIZATION_DOMAIN_ATTRIBUTE` | `twakeDomain` | Attribute holding an organization's domain | | `--twake-instance-organization-name-attribute` | `DM_TWAKE_INSTANCE_ORGANIZATION_NAME_ATTRIBUTE` | `description` | Attribute holding an organization's name | | `--twake-instance-organization-fqdn-attribute` | `DM_TWAKE_INSTANCE_ORGANIZATION_FQDN_ATTRIBUTE` | | Address attribute of an organization (empty: not written) | See [instances](plugins/integrations/instances.md). **Requires:** `core/rabbitmq` #### `core/twake/tombstone` Reads the `--twake-lifecycle-*` attributes above, and: | CLI | Env | Default | Description | | ------------------------------------ | ------------------------------------- | ------------------- | ----------------------------------------- | | `--twake-lifecycle-reason-attribute` | `DM_TWAKE_LIFECYCLE_REASON_ATTRIBUTE` | | Deletion reason attribute | | `--twake-tombstone-dn` | `DM_TWAKE_TOMBSTONE_DN` | `[]` | DN patterns of entries kept as tombstones | | `--twake-tombstone-default-reason` | `DM_TWAKE_TOMBSTONE_DEFAULT_REASON` | `deleted` | Reason when none is given | | `--twake-tombstone-reason-header` | `DM_TWAKE_TOMBSTONE_REASON_HEADER` | `x-deletion-reason` | Request header carrying the reason | | `--twake-tombstone-reasons` | `DM_TWAKE_TOMBSTONE_REASONS` | `[]` | Accepted reasons (empty: any) | | `--twake-tombstone-clear-attributes` | `DM_TWAKE_TOMBSTONE_CLEAR_ATTRIBUTES` | `[]` | Attributes removed from a tombstone | | `--twake-tombstone-erase-min-age` | `DM_TWAKE_TOMBSTONE_ERASE_MIN_AGE` | `2592000` | Seconds before a tombstone may be erased | | `--twake-tombstone-group-bases` | `DM_TWAKE_TOMBSTONE_GROUP_BASES` | `--ldap-group-base` | Where memberships are removed at erase | See [tombstone](plugins/integrations/tombstone.md). #### `core/twake/groups` Extends `core/ldap/groups`, whose options it reads too. | CLI | Env | Default | Description | | --------------------------------------------- | ---------------------------------------------- | ------------------ | ----------------------------------------------------- | | `--twake-group-base` | `DM_TWAKE_GROUP_BASE` | | Group branch of an organization, holding `{org}` once | | `--twake-group-user-base` | `DM_TWAKE_GROUP_USER_BASE` | | User branch of an organization, holding `{org}` once | | `--twake-group-organization-dn` | `DM_TWAKE_GROUP_ORGANIZATION_DN` | | Organization entry, with `{org}` (empty: not checked) | | `--twake-group-organization-status-attribute` | `DM_TWAKE_GROUP_ORGANIZATION_STATUS_ATTRIBUTE` | `twakeOrgStatus` | Organization status attribute | | `--twake-group-organization-deleted-value` | `DM_TWAKE_GROUP_ORGANIZATION_DELETED_VALUE` | `deleted` | Status of a deleted organization | | `--twake-group-display-name-attribute` | `DM_TWAKE_GROUP_DISPLAY_NAME_ATTRIBUTE` | `twakeDisplayName` | Group display name attribute | | `--twake-group-color-attribute` | `DM_TWAKE_GROUP_COLOR_ATTRIBUTE` | `twakeGroupColor` | Group color attribute | | `--twake-group-created-at-attribute` | `DM_TWAKE_GROUP_CREATED_AT_ATTRIBUTE` | `twakeCreatedAt` | Group creation date attribute | | `--twake-group-max-page-limit` | `DM_TWAKE_GROUP_MAX_PAGE_LIMIT` | `1000` | Largest page, and most users added at once | | `--twake-group-member-fields` | `DM_TWAKE_GROUP_MEMBER_FIELDS` | `{}` | Member profile fields over the defaults, as JSON | See [groups](plugins/integrations/groups.md). #### `core/twake/spaces` Requires `core/twake/groups`, whose options it reads for users, groups and the organization entry, and `core/ldap/onChange`; with `--rabbitmq-url`, it requires `core/rabbitmq` and reads the organization domain from `--twake-instance-organization-domain-attribute`. | CLI | Env | Default | Description | | -------------------------------------- | --------------------------------------- | -------------------------------------- | ----------------------------------------------------- | | `--twake-space-base` | `DM_TWAKE_SPACE_BASE` | | Space branch of an organization, holding `{org}` once | | `--twake-space-class` | `DM_TWAKE_SPACE_CLASS` | `top,twakeSpace` | Object classes of a space | | `--twake-space-display-name-attribute` | `DM_TWAKE_SPACE_DISPLAY_NAME_ATTRIBUTE` | `twakeDisplayName` | Space name attribute | | `--twake-space-admin-attribute` | `DM_TWAKE_SPACE_ADMIN_ATTRIBUTE` | `twakeSpaceAdmin` | DNs holding the admin role | | `--twake-space-editor-attribute` | `DM_TWAKE_SPACE_EDITOR_ATTRIBUTE` | `twakeSpaceEditor` | DNs holding the editor role | | `--twake-space-viewer-attribute` | `DM_TWAKE_SPACE_VIEWER_ATTRIBUTE` | `twakeSpaceViewer` | DNs holding the viewer role | | `--twake-space-user-role-attribute` | `DM_TWAKE_SPACE_USER_ROLE_ATTRIBUTE` | | User attribute holding `:`, if set | | `--twake-space-exchange` | `DM_TWAKE_SPACE_EXCHANGE` | `space` | Topic exchange of the space events | | `--twake-space-sync-queue` | `DM_TWAKE_SPACE_SYNC_QUEUE` | `twake.space.sync.requested.ldap-rest` | Queue of the space sync requests | See [spaces](plugins/integrations/spaces.md). ### Utility Plugins #### `core/static` | CLI | Env | Default | Description | | ------------------------ | ------------------------- | ------------------------ | ------------------------------------------------------ | | `--static-path` | `DM_STATIC_PATH` | `static` | Static files directory | | `--static-name` | `DM_STATIC_NAME` | `static` | URL path prefix | | `--static-csp` | `DM_STATIC_CSP` | `frame-ancestors 'self'` | `Content-Security-Policy` of the files; `off` for none | | `--static-frame-options` | `DM_STATIC_FRAME_OPTIONS` | `SAMEORIGIN` | `X-Frame-Options` of the files; `off` for none | See [security headers](plugins/utilities/static.md#security-headers). #### `core/weblogs` Web access logging plugin. No configuration options - just add the plugin to enable access logs. #### `core/metrics` Prometheus metrics on a port of their own, see [metrics](operations.md#metrics). | CLI | Env | Default | Description | | ---------------- | ----------------- | ------- | ---------------------------------------------------------- | | `--metrics-port` | `DM_METRICS_PORT` | `9464` | Port `GET /metrics` is served on; keep it off the internet | #### `core/storage` Keyed storage with a deadline, for the plugins that need to keep something — see [Storage](plugins/utilities/storage.md). | CLI | Env | Default | Description | | ----------------------------- | ------------------------------ | -------------------- | ------------------------------------------------ | | `--storage-backend` | `DM_STORAGE_BACKEND` | | `ldap`, `file`, `postgres` or `valkey`; required | | `--storage-sweep-interval` | `DM_STORAGE_SWEEP_INTERVAL` | `600` | Seconds between two expiry passes | | `--storage-ldap-base` | `DM_STORAGE_LDAP_BASE` | | Branch the `ldap` backend writes to | | `--storage-ldap-object-class` | `DM_STORAGE_LDAP_OBJECT_CLASS` | `applicationProcess` | Object class of the entries it writes | | `--storage-file-directory` | `DM_STORAGE_FILE_DIRECTORY` | | Directory the `file` backend writes to | | `--storage-postgres-url` | `DM_STORAGE_POSTGRES_URL` | | Connection string of the `postgres` backend | | `--storage-postgres-table` | `DM_STORAGE_POSTGRES_TABLE` | `ldap_rest_storage` | Table it writes to | | `--storage-valkey-url` | `DM_STORAGE_VALKEY_URL` | | URL of the `valkey` backend | | `--storage-valkey-prefix` | `DM_STORAGE_VALKEY_PREFIX` | `ldap-rest:` | Prefix of the keys it writes | #### `core/configApi` Exposes API configuration at `/api/v1/config` for client applications. Returns available features, schemas, endpoints, and the server's `version` — read from the `package.json` that ships with it, so a client can tell an administrator the server is older than it supports. No configuration options. ## Configuration File Use a `.env` file or shell script: ```bash # ~/.ldap-rest-config export DM_LDAP_URL="ldap://localhost:389" export DM_LDAP_DN="cn=admin,dc=example,dc=com" export DM_LDAP_PWD="password" export DM_LDAP_BASE="dc=example,dc=com" export DM_PLUGINS="core/auth/token,core/ldap/flatGeneric" export DM_AUTH_TOKENS="secret-token" export DM_LOG_LEVEL="notice" ``` ```bash # Load and start source ~/.ldap-rest-config ldap-rest ``` ## LDAP Failover For high availability, specify multiple servers: ```bash DM_LDAP_URL="ldap://ldap1.example.com,ldap://ldap2.example.com,ldap://ldap3.example.com" ``` The system will: 1. Try each URL in order 2. Use the first successful connection 3. Automatically failover if connection fails 4. Log failover events ## Log Levels _(`--log-level`)_ | Level | Description | | -------- | -------------------------------------------- | | `error` | Errors only | | `warn` | Warnings and errors | | `notice` | Web access logs (recommended for production) | | `info` | General information | | `debug` | Everything, including debug output | The `notice` level is ideal for production as it shows web access logs without flooding with general info messages. ## Logger _(`--logger`)_ | Logger | Output | | --------- | ---------------------------------------------------------------------------------------------------- | | `console` | One JSON object per line on standard output (errors and warnings on standard error) | | `file` | Text lines appended to `--log-file` (`DM_LOG_FILE`), `error.log` in the working directory by default | An empty value is `console`. Any other value, or `file` with an empty `--log-file`, stops the server at startup. linagora-ldap-rest-a4bb78e/docs/usage/directory-audit.md000066400000000000000000000116021527331160600233670ustar00rootroot00000000000000# Auditing a directory before a migration Tightening a validation pattern changes what the server accepts **from now on**. It says nothing about the entries already stored: a directory whose addresses were never anchored, or whose organization names predate the rule that now governs them, loads fine and then refuses the first update of every offending entry — which is discovered one support ticket at a time. `npm run audit:directory` reads a branch as it is and reports what the schema would refuse, so the work is known before the switch rather than after it. ## Running it ```sh export DM_LDAP_URL=ldap://directory.example.org export DM_LDAP_DN='cn=admin,dc=example,dc=com' export DM_LDAP_PWD='…' export DM_LDAP_BASE='dc=example,dc=com' npm run audit:directory -- --schema static/schemas/twake/users.json \ --plugin core/ldap/enterpriseRules ``` | Option | Default | Purpose | | ----------------- | ---------------------------- | ----------------------------------- | | `--schema` | _(required)_ | Schema to check against | | `--base` | the schema's own entity base | Branch to read | | `--filter` | `(objectClass=*)` | Narrows what is audited | | `--url` | `DM_LDAP_URL` | Directory to read | | `--bind-dn` | `DM_LDAP_DN` | Identity to read with | | `--bind-password` | `DM_LDAP_PWD` | Its password | | `--samples` | `5` | Offending entries named per finding | | `--plugin` | _(none)_ | A plugin the server loads, repeated | `--plugin` matters for the attributes a schema marks both `required` and `generated`. The server exempts one from its check only when something fills it: the entity itself (`generatedFrom`), or a loaded plugin. Name the plugins the server loads and the audit exempts what they fill; without them it reports those attributes as `missing, and no --plugin fills it` — which is what every creation would be refused for on a server that does not load them. It exits `0` when the branch is clean, `1` when the schema would refuse something, and `2` when the audit itself could not run — an unreadable schema, a directory that would not answer — so a pipeline can tell "dirty" from "broken" rather than gating on both alike. `--bind-password` puts the password in `ps` and in the shell history of whoever runs it. `DM_LDAP_PWD` is the path to prefer, and the flag is there for parity with the server's own `--ldap-pwd`. ## What it checks, and what only the server can The audit reads a schema and an entry, so it answers what an entry alone can answer: required attributes, the `test` pattern of a value and of an array's elements, and the branch a DN must land in. It replaces `__KEY__` placeholders and `{config_key}` in `entity.base` from the `DM_*` environment exactly as the server does, leaving a placeholder the configuration does not name. What it does not answer needs the rest of the directory, and belongs to a dry run rather than here: uniqueness across a namespace, the mail domains an organization owns, and anything a plugin computes after validation. A clean audit is not a promise that every entry will survive every rule — it is a promise that none of them fails on its own contents. ## Reading the report ``` 12632 entries read, 47 value(s) the schema would refuse: mail: does not match — 41 expected: Expected an address in the example.org domain, at most 64 characters before the @ uid=jean.dupont,ou=users,dc=example,dc=com → "jean.dupont@old-domain.example" …and 36 more postalCode: does not match — 6 expected: Expected pattern 9999, A9999 or R9999 uid=marie.martin,ou=users,dc=example,dc=com → "1234" ``` Each finding names the attribute, why it was refused, and — from the schema's `hint` — what a valid value looks like. The same wording reaches the person filling the form, so the fix and the rule are described identically. ## What it checks, and what it cannot Checked, because an entry answers for itself: - `test` patterns, on single and multi-valued attributes - `required` attributes that are absent — except the ones the server computes (`generated`), which an entry is not expected to carry yet - the `branch` a `pointer` must land in Not checked, because it takes the whole directory or a live server: - uniqueness across a shared namespace - mail addresses against the domains an organization owns - anything a hook computes rather than validates For those, run the target configuration against a copy of the directory and replay the writes. ## See also - [flat-generic](plugins/ldap/flat-generic.md) — where `test` and `hint` live - [enterprise-rules](plugins/ldap/enterprise-rules.md) — the rules a dry run covers linagora-ldap-rest-a4bb78e/docs/usage/operations.md000066400000000000000000000415351527331160600224520ustar00rootroot00000000000000# Operations ## Health and readiness Two routes answer outside the API prefix, without authentication, and are never cached: | Route | Answers | | -------------- | ------------------------------------------------------------- | | `GET /healthz` | `200 {"status":"ok"}` as long as the process serves | | `GET /readyz` | `200` when the server can serve, `503` when it cannot (below) | `/readyz` answers a JSON body: | `status` | Code | Meaning | | ------------- | ---- | ------------------------------------------------------------------------------------------------------ | | `starting` | 503 | The plugins are still loading (a host serving before `ready`; the shipped binary opens the port after) | | `stopping` | 503 | The server was told to stop and finishes what is in flight; a new connection is refused | | `unavailable` | 503 | The directory does not answer a read of its root DSE in 2 s (`ldap: failed`) | | `ok` | 200 | Everything answers | | `degraded` | 200 | The directory answers, a plugin reports a resource of its own down | ```json { "status": "degraded", "ldap": "ok", "plugins": { "rabbitmq": "degraded" } } ``` `plugins` lists the plugins that report their health; `failed` means the report threw or did not answer in time. Only the directory takes a replica out of rotation: a broken outbound service is visible in the body, and the server keeps serving what does not need it. The directory and the plugins are asked at once, so the answer takes 2 s at most. The probe reads the root DSE, which any bound account may: a wrong `--ldap-base` is not what it reports. The directory is probed on a connection of its own, bound as the server, outside the connection pool and the search cache. One answer serves 5 seconds whatever the number of probes; the probes arriving meanwhile share it. Both routes are served before the authentication and the rate limiting, and `/readyz` names the loaded plugins that report: on an ingress exposing the server to the internet, keep the two paths to the cluster. The Helm chart probes both routes over HTTP (`livenessProbe` and `readinessProbe` in `values.yaml`). The published image also declares a `HEALTHCHECK` calling `GET /healthz` from inside the container, on `DM_PORT`: `docker ps` reports the container unhealthy when it stops answering, and a `--restart` policy restarts it as it would after a crash, while an orchestrator with probes of its own ignores the declaration. A port given any other way (`--port` in a configuration file, a plugin) leaves the check on 8081. The image runs as its unprivileged `node` user: with `docker run`, a `DM_PORT` below 1024 stops the server at startup, where the default 8081 changes nothing. The image is built in two stages: a builder produces the package and installs its production dependencies, the runtime stage copies them and carries no compiler nor package manager cache. The runtime stage upgrades the Alpine packages of its base image with `apk upgrade --no-cache`, so a security fix published after the base image was built still reaches the image: two builds from the same digest may differ in those packages. Its base image is pinned by digest in `scripts/buildDockerfile.ts`, so the image built is the one reviewed even if the tag moves; the digest follows the tag it names, taken from the [Docker Hub tag page](https://hub.docker.com/_/node/tags?name=25-alpine) and updated by hand with it. Dependabot ignores the image: it would open a change to the generated `Dockerfile`, which the drift check refuses. ### What the shipped plugins report | Plugin | `degraded` when | | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `storage` | the backend refuses a `SELECT 1` or a `PING`, or the `file` backend's directory is not a directory this user may write in; the `ldap` backend has no answer of its own, the directory check above covers it | | `rabbitmq` | a broker is configured and no connection to it stands: until the first publish or subscription opens one, and while the client reconnects | | `james` | James answers its `GET /healthcheck` with 503, one of its own checks unhealthy, or not within 1.5 s; a James whose checks are only degraded answers 200, so `ok` here | | `migrationProxy` | the migration proxy answers its `GET /healthcheck` with an error, or not within 1.5 s | A check that leaves the probe to time out reads as `failed`. ### For plugin authors A plugin reports its own resources with an optional `health()` method returning `ok` or `degraded`, called at most once every 5 seconds; it must answer within 2 s and never throw for an ordinary outage. The probe stops waiting at 2 s but cancels nothing: a plugin bounds its own call, as `james` does with its 1.5 s. ## Request id Every response the API sends carries an `X-Request-Id` header: an identifier the server generates for the request, the same one its writes hand to the plugins and its log lines carry. An `X-Request-Id` the client sends is not taken: a caller could otherwise make the writes of two requests look like one. A proxy or an ingress generating an identifier of its own does not pass it on either: log the answer's `X-Request-Id` there to match its lines with the server's. Every line logged while a request is served carries `requestId`, the lines of the hooks it ran included, as a field of the JSON console output and as `[]` after the level in the log file. The line of `core/weblogs` carries it too. A line without one was written outside any request: at startup, by a scheduled task, by a message consumer, by a connection the server keeps. Work shared between requests, a schema load or a readiness answer, logs under the request that started it. A line a hook writes carries `plugin`, the plugin that registered the hook, and `op`, the hook's name, the `Hook error` line of a hook that threw included: in the JSON console output as fields, in the log file as `[ ]` after the request id. A line naming another `plugin` itself keeps it, with no `op` from the hook, and reads `[]` in the file wherever it is written. These are the labels of `ldap_rest_hook_failures_total`. A line carrying an `error`, the reason of a failure, has it as a field of the JSON console output and in parentheses after the message in the log file; the file keeps no field other than these. ## Metrics `core/metrics` serves Prometheus metrics on `GET /metrics`, on `--metrics-port` (9464): a port of its own, outside the authentication, the rate limiting and the request id, to keep to the cluster or the host. ```bash --plugin core/metrics --metrics-port 9464 ``` | Metric | Type | Labels | Meaning | | --------------------------------------------- | --------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ldap_rest_http_request_duration_seconds` | histogram | `method`, `route`, `status` | Time to answer, by route template (`/api/v1/users/:id`; `none` unmatched or answered by a middleware, `core/static` included) | | `ldap_rest_ldap_operation_duration_seconds` | histogram | `operation`, `result` | Time the directory takes: `bind`, `search`, `add`, `modify`, `rename`, `delete`; `ok`, the LDAP result code of a refusal (`32`: no such entry), `error` when it did not answer | | `ldap_rest_ldap_pool_wait_seconds` | histogram | | Time to get a pooled connection, opening one included | | `ldap_rest_ldap_pool_connections` | gauge | `state` | Pooled connections, `busy` or `idle` | | `ldap_rest_ldap_pool_waiting` | gauge | | Operations waiting for a connection, the pool being full | | `ldap_rest_ldap_pool_opening` | gauge | | Connections being opened, within `--ldap-pool-size` | | `ldap_rest_ldap_pool_max` | gauge | | `--ldap-pool-size` | | `ldap_rest_ldap_search_cache_total` | counter | `result` | Cacheable searches, `hit` or `miss`; nothing when the cache is off | | `ldap_rest_hook_failures_total` | counter | `hook`, `plugin` | Notification hooks (`*done`, `onLdap*`) that threw, which the server logs and goes on | | `ldap_rest_outbound_request_duration_seconds` | histogram | `target`, `status` | Time a service the server calls takes to answer: the calling plugin (`james`, `migrationProxy`, `calendar` or its old name `calendarResources`, `drive`, `cozyProvision`, `clouderyProvision`, `crowdsec`) or the instance provider (`cloudery`, `cozy-stack`); the HTTP status, `error` when it did not answer | | `ldap_rest_unhandled_rejections_total` | counter | | Promise rejections nothing handled; counted by the binary, a host skipping `exitOnCrash()` leaves it at zero | | `process_*`, `nodejs_*` | | | The process: CPU, memory, event loop lag, handles, GC | A burst of requests that has to open connections no longer opens one per request: a connection being opened takes its place in the pool, and the requests over `--ldap-pool-size` wait for a free one. A search the caller pages is timed page by page, what the caller does between two pages left out; the readiness ping is not timed. The route label is the template a route was declared with, never the URL: a label per URL would grow without bound; under a router a plugin mounted, the mount reads `*`. An outbound call is timed from the moment it is sent, after the plugin's own queue, to the answer's headers, each attempt of a retried call on its own; a plugin's health check that asks its service, James's does, is a call like another. A plugin calling a service through `this.fetch` has its calls counted under its name; the OpenID Connect client's calls to its provider are made by its library, and not timed. The instruments come from `prom-client` 15, which `npm install` reports as deprecated: its successor `@prometheus-io/client` is the migration to make, and the notice is expected until it is done. The port listens on every interface. What to alert on: `/readyz` for a replica out of rotation, `result="error"` in `ldap_rest_ldap_operation_duration_seconds` (the directory not answering; a refusal is a result code), a `ldap_rest_ldap_pool_waiting` that stays above zero (raise `--ldap-pool-size`), `ldap_rest_hook_failures_total` rising (a synchronization that fails only in the log otherwise), `status="error"` or the 5xx share in `ldap_rest_outbound_request_duration_seconds` by `target` (a service the server depends on failing), the 5xx share of the requests. The Helm chart exposes the port on the Service with `metrics.enabled`, and renders a `ServiceMonitor` for the Prometheus operator with `metrics.serviceMonitor.enabled`; `core/metrics` still has to be in `DM_PLUGINS`. ## Shutdown On `SIGTERM` or `SIGINT`, the binary stops in order: 1. the listening socket closes: a new connection is refused, and `/readyz` answers 503 `stopping` on the connections still open; 2. idle keep-alive connections close at once; the others close with the response they carry, sent with `Connection: close`, or are cut when the drain is spent: `--shutdown-timeout` (20 s) less the 5 s kept for the steps below, 1 s at least; 3. every plugin's `close()` runs, at once: the broker connection, the storage backend; 4. the directory connections are closed; 5. the signal ends the process, with its usual code (143 on `SIGTERM`). The whole takes `--shutdown-timeout` at most: a `close()` that does not settle in what remains of the budget is logged and left behind, by `shutdown()` itself, so a host gets the same bound, and the directory connections are closed anyway. The drain and the closes get 1 s each at least, so a budget under 2 s is exceeded. Kubernetes takes the pod out of its endpoints as it sends the signal, and the proxies follow a moment later: a request routed meanwhile is refused. A `preStop` hook sleeping a few seconds, before the signal, closes that window; the chart's `lifecycle` value takes one. The sleep and `--shutdown-timeout` together must stay under `terminationGracePeriodSeconds`, 30 s by default, which the chart leaves to Kubernetes: 5 s and 20 s fit. A host assembling the server in its own process calls `shutdown()` itself, `shutdownOnSignals()` when it wants the same behaviour on a signal, and `exitOnCrash()` for the policy below; without the latter, Node's own action on an uncaught exception applies. An exception nothing caught leaves the process in a state nobody knows: it is logged, the shutdown above runs, and the process exits with 1, within `--shutdown-timeout` whatever hangs, for its supervisor to start it again. A promise rejection nothing handled is logged and counted, and the process keeps serving. ### For plugin authors A plugin holding a connection, a timer or a queue implements `close()`, called once after the server stopped serving; it must settle, a failure is logged and does not hold the others. linagora-ldap-rest-a4bb78e/docs/usage/options.snapshot.json000066400000000000000000001200431527331160600241610ustar00rootroot00000000000000[ { "cli": "--alias-attribute", "type": "string", "default": "mailAlternateAddress", "env": "DM_ALIAS_ATTRIBUTE" }, { "cli": "--api-prefix", "type": "string", "default": "/api", "env": "DM_API_PREFIX" }, { "cli": "--app-accounts-user-attribute", "type": "string", "default": "", "env": "DM_APP_ACCOUNTS_USER_ATTRIBUTE" }, { "cli": "--applicative-account-attribute", "type": "array", "default": [ "objectClass", "cn", "sn", "givenName", "displayName", "description" ], "plural": "--applicative-account-attributes", "env": "DM_APPLICATIVE_ACCOUNT_ATTRIBUTES", "kind": "identifiers" }, { "cli": "--applicative-account-base", "type": "string", "default": "", "env": "DM_APPLICATIVE_ACCOUNT_BASE" }, { "cli": "--auth-fake-user", "type": "string", "default": "", "env": "DM_AUTH_FAKE_USER" }, { "cli": "--auth-hmac", "type": "array", "default": [], "plural": "--auth-hmacs", "env": "DM_AUTH_HMAC", "kind": "phrases" }, { "cli": "--auth-hmac-window", "type": "number", "default": 120000, "env": "DM_AUTH_HMAC_WINDOW" }, { "cli": "--auth-path-prefix", "type": "array", "default": [], "plural": "--auth-path-prefixes", "env": "DM_AUTH_PATH_PREFIX" }, { "cli": "--auth-token", "type": "array", "default": [], "plural": "--auth-tokens", "env": "DM_AUTH_TOKENS" }, { "cli": "--auth-totp", "type": "array", "default": [], "plural": "--auth-totps", "env": "DM_AUTH_TOTP", "kind": "phrases" }, { "cli": "--auth-totp-step", "type": "number", "default": 30, "env": "DM_AUTH_TOTP_STEP" }, { "cli": "--auth-totp-window", "type": "number", "default": 1, "env": "DM_AUTH_TOTP_WINDOW" }, { "cli": "--authz-combine", "type": "boolean", "default": false, "env": "DM_AUTHZ_COMBINE" }, { "cli": "--authz-dynamic-base", "type": "string", "default": "", "env": "DM_AUTHZ_DYNAMIC_BASE" }, { "cli": "--authz-dynamic-bypass", "type": "array", "default": [], "env": "DM_AUTHZ_DYNAMIC_BYPASS", "kind": "identifiers" }, { "cli": "--authz-dynamic-cache-ttl", "type": "number", "default": 60, "env": "DM_AUTHZ_DYNAMIC_CACHE_TTL" }, { "cli": "--authz-dynamic-config-attribute", "type": "string", "default": "description", "env": "DM_AUTHZ_DYNAMIC_CONFIG_ATTRIBUTE" }, { "cli": "--authz-dynamic-reload-endpoint", "type": "boolean", "default": false, "env": "DM_AUTHZ_DYNAMIC_RELOAD_ENDPOINT" }, { "cli": "--authz-dynamic-tenant-attribute", "type": "string", "default": "cn", "env": "DM_AUTHZ_DYNAMIC_TENANT_ATTRIBUTE" }, { "cli": "--authz-dynamic-token-attribute", "type": "string", "default": "userPassword", "env": "DM_AUTHZ_DYNAMIC_TOKEN_ATTRIBUTE" }, { "cli": "--authz-filter-attached-entries", "type": "boolean", "default": false, "env": "DM_AUTHZ_FILTER_ATTACHED_ENTRIES" }, { "cli": "--authz-for", "type": "array", "default": [], "env": "DM_AUTHZ_FOR", "kind": "identifiers" }, { "cli": "--authz-global-branch", "type": "array", "default": [], "plural": "--authz-global-branches", "env": "DM_AUTHZ_GLOBAL_BRANCHES", "kind": "dns" }, { "cli": "--authz-global-organization", "type": "string", "default": "", "env": "DM_AUTHZ_GLOBAL_ORGANIZATION" }, { "cli": "--authz-global-write-branch", "type": "array", "default": [], "plural": "--authz-global-write-branches", "env": "DM_AUTHZ_GLOBAL_WRITE_BRANCHES", "kind": "dns" }, { "cli": "--authz-identity", "type": "string", "default": "req.user", "env": "DM_AUTHZ_IDENTITY" }, { "cli": "--authz-local-admin-attribute", "type": "string", "default": "twakeLocalAdminLink", "env": "DM_AUTHZ_LOCAL_ADMIN_ATTRIBUTE" }, { "cli": "--authz-per-branch-cache-ttl", "type": "number", "default": 60, "env": "DM_AUTHZ_PER_BRANCH_CACHE_TTL" }, { "cli": "--authz-per-branch-config", "type": "json", "default": { "default": { "delete": false, "read": true, "write": false } }, "env": "DM_AUTHZ_PER_BRANCH_CONFIG" }, { "cli": "--authz-per-route", "type": "array", "default": [], "plural": "--authz-per-routes", "env": "DM_AUTHZ_PER_ROUTES" }, { "cli": "--authz-scope-source", "type": "string", "default": "", "env": "DM_AUTHZ_SCOPE_SOURCE" }, { "cli": "--authz-transit-branch", "type": "string", "default": "", "env": "DM_AUTHZ_TRANSIT_BRANCH" }, { "cli": "--authz-transit-origin-attribute", "type": "string", "default": "", "env": "DM_AUTHZ_TRANSIT_ORIGIN_ATTRIBUTE" }, { "cli": "--authz-unresolved-user", "type": "string", "default": "deny", "env": "DM_AUTHZ_UNRESOLVED_USER" }, { "cli": "--base-url", "type": "string", "default": "", "env": "DM_BASE_URL" }, { "cli": "--bcl-retention", "type": "number", "default": 604800, "env": "DM_BCL_RETENTION" }, { "cli": "--bulk-import-batch-size", "type": "string", "default": "100", "env": "DM_BULK_IMPORT_BATCH_SIZE" }, { "cli": "--bulk-import-max-file-size", "type": "string", "default": "10485760", "env": "DM_BULK_IMPORT_MAX_FILE_SIZE" }, { "cli": "--bulk-import-schemas", "type": "string", "default": "", "env": "DM_BULK_IMPORT_SCHEMAS" }, { "cli": "--calendar-concurrency", "type": "number", "default": 10, "env": "DM_CALENDAR_CONCURRENCY" }, { "cli": "--calendar-firstname-attribute", "type": "string", "default": "givenName", "env": "DM_CALENDAR_FIRSTNAME_ATTRIBUTE" }, { "cli": "--calendar-lastname-attribute", "type": "string", "default": "sn", "env": "DM_CALENDAR_LASTNAME_ATTRIBUTE" }, { "cli": "--calendar-resource-base", "type": "string", "default": "", "env": "DM_CALENDAR_RESOURCE_BASE" }, { "cli": "--calendar-resource-creator", "type": "string", "default": "admin@example.com", "env": "DM_CALENDAR_RESOURCE_CREATOR" }, { "cli": "--calendar-resource-domain", "type": "string", "default": "", "env": "DM_CALENDAR_RESOURCE_DOMAIN" }, { "cli": "--calendar-resource-objectclass", "type": "string", "default": "", "env": "DM_CALENDAR_RESOURCE_OBJECTCLASS" }, { "cli": "--calendar-webadmin-token", "type": "string", "default": "", "env": "DM_CALENDAR_WEBADMIN_TOKEN" }, { "cli": "--calendar-webadmin-url", "type": "string", "default": "http://localhost:8080", "env": "DM_CALENDAR_WEBADMIN_URL" }, { "cli": "--cloudery-default-locale", "type": "string", "default": "en", "env": "DM_CLOUDERY_DEFAULT_LOCALE" }, { "cli": "--cloudery-default-org-role", "type": "string", "default": "member", "env": "DM_CLOUDERY_DEFAULT_ORG_ROLE" }, { "cli": "--cloudery-domain", "type": "string", "default": "", "env": "DM_CLOUDERY_DOMAIN" }, { "cli": "--cloudery-fqdn-attribute", "type": "string", "default": "twakeWorkspaceUrl", "env": "DM_CLOUDERY_FQDN_ATTRIBUTE" }, { "cli": "--cloudery-invited-attribute", "type": "string", "default": "twakeInvited", "env": "DM_CLOUDERY_INVITED_ATTRIBUTE" }, { "cli": "--cloudery-manager-token", "type": "string", "default": "", "env": "DM_CLOUDERY_MANAGER_TOKEN" }, { "cli": "--cloudery-manager-url", "type": "string", "default": "", "env": "DM_CLOUDERY_MANAGER_URL" }, { "cli": "--cloudery-offer", "type": "string", "default": "b2b_twake_default", "env": "DM_CLOUDERY_OFFER" }, { "cli": "--cloudery-org-id-attribute", "type": "string", "default": "twakeOrganizationId", "env": "DM_CLOUDERY_ORG_ID_ATTRIBUTE" }, { "cli": "--cloudery-org-id-header", "type": "string", "default": "x-cloudery-org-id", "env": "DM_CLOUDERY_ORG_ID_HEADER" }, { "cli": "--cloudery-org-role-attribute", "type": "string", "default": "twakeOrganizationRole", "env": "DM_CLOUDERY_ORG_ROLE_ATTRIBUTE" }, { "cli": "--cloudery-org-role-header", "type": "string", "default": "x-cloudery-org-role", "env": "DM_CLOUDERY_ORG_ROLE_HEADER" }, { "cli": "--cloudery-phones-attribute", "type": "string", "default": "twakePhones", "env": "DM_CLOUDERY_PHONES_ATTRIBUTE" }, { "cli": "--cloudery-user-branch", "type": "string", "default": "", "env": "DM_CLOUDERY_USER_BRANCH" }, { "cli": "--cloudery-workflow-max-attempts", "type": "number", "default": 60, "env": "DM_CLOUDERY_WORKFLOW_MAX_ATTEMPTS" }, { "cli": "--cloudery-workflow-poll-interval-ms", "type": "number", "default": 2000, "env": "DM_CLOUDERY_WORKFLOW_POLL_INTERVAL_MS" }, { "cli": "--cozy-admin-passphrase", "type": "string", "default": "", "env": "DM_COZY_ADMIN_PASSPHRASE" }, { "cli": "--cozy-admin-url", "type": "string", "default": "", "env": "DM_COZY_ADMIN_URL" }, { "cli": "--cozy-admin-user", "type": "string", "default": "admin", "env": "DM_COZY_ADMIN_USER" }, { "cli": "--cozy-apps", "type": "string", "default": "home,drive,settings,notes,dataproxy", "env": "DM_COZY_APPS" }, { "cli": "--cozy-auth-exchange", "type": "string", "default": "auth", "env": "DM_COZY_AUTH_EXCHANGE" }, { "cli": "--cozy-b2b-exchange", "type": "string", "default": "b2b", "env": "DM_COZY_B2B_EXCHANGE" }, { "cli": "--cozy-context-name", "type": "string", "default": "default", "env": "DM_COZY_CONTEXT_NAME" }, { "cli": "--cozy-default-locale", "type": "string", "default": "fr", "env": "DM_COZY_DEFAULT_LOCALE" }, { "cli": "--cozy-org-domain", "type": "string", "default": "", "env": "DM_COZY_ORG_DOMAIN" }, { "cli": "--cozy-org-id", "type": "string", "default": "", "env": "DM_COZY_ORG_ID" }, { "cli": "--cozy-user-created-routing-key", "type": "string", "default": "user.created", "env": "DM_COZY_USER_CREATED_ROUTING_KEY" }, { "cli": "--cozy-user-deleted-routing-key", "type": "string", "default": "domain.user.deleted", "env": "DM_COZY_USER_DELETED_ROUTING_KEY" }, { "cli": "--crowdsec-api-key", "type": "string", "default": "", "env": "DM_CROWDSEC_API_KEY" }, { "cli": "--crowdsec-cache-ttl", "type": "number", "default": 60, "env": "DM_CROWDSEC_CACHE_TTL" }, { "cli": "--crowdsec-url", "type": "string", "default": "http://localhost:8080/v1/decisions", "env": "DM_CROWDSEC_URL" }, { "cli": "--debug", "type": "boolean", "default": false, "env": "DM_DEBUG" }, { "cli": "--delegation-attribute", "type": "string", "default": "twakeDelegatedUsers", "env": "DM_DELEGATION_ATTRIBUTE" }, { "cli": "--display-name-attribute", "type": "string", "default": "displayName", "env": "DM_DISPLAY_NAME_ATTRIBUTE" }, { "cli": "--drive-quota-attribute", "type": "string", "default": "twakeDriveQuota", "env": "DM_DRIVE_QUOTA_ATTRIBUTE" }, { "cli": "--enterprise-domain-link-attribute", "type": "string", "default": "", "env": "DM_ENTERPRISE_DOMAIN_LINK_ATTRIBUTE" }, { "cli": "--enterprise-domain-name-attribute", "type": "string", "default": "associatedDomain", "env": "DM_ENTERPRISE_DOMAIN_NAME_ATTRIBUTE" }, { "cli": "--external-branch-class", "type": "array", "default": [ "top", "inetOrgPerson" ], "plural": "--external-branch-classes", "env": "DM_EXTERNAL_BRANCH_CLASSES", "kind": "identifiers" }, { "cli": "--external-members-branch", "type": "string", "default": "ou=contacts,dc=example,dc=com", "env": "DM_EXTERNAL_MEMBERS_BRANCH" }, { "cli": "--forward-attribute", "type": "string", "default": "mailForwardingAddress", "env": "DM_FORWARD_ATTRIBUTE" }, { "cli": "--group-class", "type": "array", "default": [ "top", "groupOfNames" ], "plural": "--group-classes", "env": "DM_GROUP_CLASSES", "kind": "identifiers" }, { "cli": "--group-default-attributes", "type": "json", "default": {}, "env": "DM_GROUP_DEFAULT_ATTRIBUTES" }, { "cli": "--group-dummy-user", "type": "string", "default": "cn=fakeuser", "env": "DM_GROUP_DUMMY_USER" }, { "cli": "--group-schema", "type": "string", "default": "/static/schemas/twake/groups.json", "env": "DM_GROUP_SCHEMA" }, { "cli": "--groups-allow-unexistent-members", "type": "boolean", "default": false, "env": "DM_ALLOW_UNEXISTENT_MEMBERS" }, { "cli": "--james-concurrency", "type": "number", "default": 10, "env": "DM_JAMES_CONCURRENCY" }, { "cli": "--james-init-delay", "type": "number", "default": 1000, "env": "DM_JAMES_INIT_DELAY" }, { "cli": "--james-mailbox-type-attribute", "type": "string", "default": "twakeMailboxType", "env": "DM_JAMES_MAILBOX_TYPE_ATTRIBUTE" }, { "cli": "--james-mailing-list-branch", "type": "array", "default": [], "plural": "--james-mailing-list-branches", "env": "DM_JAMES_MAILING_LIST_BRANCHES", "kind": "dns" }, { "cli": "--james-retry-attempts", "type": "number", "default": 5, "env": "DM_JAMES_RETRY_ATTEMPTS" }, { "cli": "--james-retry-delay", "type": "number", "default": 1000, "env": "DM_JAMES_RETRY_DELAY" }, { "cli": "--james-signature-template", "type": "string", "default": "", "env": "DM_JAMES_SIGNATURE_TEMPLATE" }, { "cli": "--james-sync-forwards", "type": "boolean", "default": false, "env": "DM_JAMES_SYNC_FORWARDS" }, { "cli": "--james-team-mailbox-any-domain", "type": "boolean", "default": false, "env": "DM_JAMES_TEAM_MAILBOX_ANY_DOMAIN" }, { "cli": "--james-webadmin-token", "type": "string", "default": "", "env": "DM_JAMES_WEBADMIN_TOKEN" }, { "cli": "--james-webadmin-url", "type": "string", "default": "http://localhost:8000", "env": "DM_JAMES_WEBADMIN_URL" }, { "cli": "--ldap-base", "type": "string", "default": "", "env": "DM_LDAP_BASE" }, { "cli": "--ldap-cache-max", "type": "number", "default": 1000, "env": "DM_LDAP_CACHE_MAX" }, { "cli": "--ldap-cache-ttl", "type": "number", "default": 0, "env": "DM_LDAP_CACHE_TTL" }, { "cli": "--ldap-concurrency", "type": "number", "default": 10, "env": "DM_LDAP_CONCURRENCY" }, { "cli": "--ldap-connection-ttl", "type": "number", "default": 60, "env": "DM_LDAP_CONNECTION_TTL" }, { "cli": "--ldap-dn", "type": "string", "default": "", "env": "DM_LDAP_DN" }, { "cli": "--ldap-flat-auto-repair", "type": "boolean", "default": true, "env": "DM_LDAP_FLAT_AUTO_REPAIR" }, { "cli": "--ldap-flat-schema", "type": "array", "default": [], "plural": "--ldap-flat-schemas", "env": "DM_LDAP_FLAT_SCHEMA" }, { "cli": "--ldap-group-base", "type": "string", "default": "", "env": "DM_LDAP_GROUP_BASE" }, { "cli": "--ldap-group-member-attribute", "type": "string", "default": "member", "env": "DM_LDAP_GROUP_MEMBER_ATTRIBUTE" }, { "cli": "--ldap-groups-main-attribute", "type": "string", "default": "cn", "env": "DM_LDAP_GROUPS_MAIN_ATTRIBUTE" }, { "cli": "--ldap-operational-attribute", "type": "array", "default": [ "dn", "controls", "structuralObjectClass", "entryUUID", "entryDN", "subschemaSubentry", "modifyTimestamp", "modifiersName", "createTimestamp", "creatorsName", "userPassword" ], "plural": "--ldap-operational-attributes", "env": "DM_LDAP_OPERATIONAL_ATTRIBUTES", "kind": "identifiers" }, { "cli": "--ldap-organization-class", "type": "array", "default": [ "top", "organizationalUnit", "twakeDepartment" ], "plural": "--ldap-organization-classes", "env": "DM_LDAP_ORGANIZATION_CLASSES", "kind": "identifiers" }, { "cli": "--ldap-organization-link-attribute", "type": "string", "default": "twakeDepartmentLink", "env": "DM_LDAP_ORGANIZATION_LINK_ATTRIBUTE" }, { "cli": "--ldap-organization-max-subnodes", "type": "number", "default": 50, "env": "DM_LDAP_ORGANIZATION_MAX_SUBNODES" }, { "cli": "--ldap-organization-path-attribute", "type": "string", "default": "twakeDepartmentPath", "env": "DM_LDAP_ORGANIZATION_PATH_ATTRIBUTE" }, { "cli": "--ldap-organization-path-separator", "type": "string", "default": " / ", "env": "DM_LDAP_ORGANIZATION_PATH_SEPARATOR" }, { "cli": "--ldap-pool-size", "type": "number", "default": 5, "env": "DM_LDAP_POOL_SIZE" }, { "cli": "--ldap-pwd", "type": "string", "default": "", "env": "DM_LDAP_PWD" }, { "cli": "--ldap-raw-base", "type": "array", "default": [], "plural": "--ldap-raw-bases", "env": "DM_LDAP_RAW_BASE", "kind": "dns" }, { "cli": "--ldap-raw-hidden-attribute", "type": "array", "default": [], "plural": "--ldap-raw-hidden-attributes", "env": "DM_LDAP_RAW_HIDDEN_ATTRIBUTES", "kind": "identifiers" }, { "cli": "--ldap-raw-max-results", "type": "number", "default": 200, "env": "DM_LDAP_RAW_MAX_RESULTS" }, { "cli": "--ldap-raw-schema-cache-ttl", "type": "number", "default": 3600, "env": "DM_LDAP_RAW_SCHEMA_CACHE_TTL" }, { "cli": "--ldap-raw-show-secrets", "type": "boolean", "default": false, "env": "DM_LDAP_RAW_SHOW_SECRETS" }, { "cli": "--ldap-top-organization", "type": "string", "default": "", "env": "DM_LDAP_TOP_ORGANIZATION" }, { "cli": "--ldap-url", "type": "array", "default": [ "ldap://localhost" ], "plural": "--ldap-urls", "env": "DM_LDAP_URL", "kind": "identifiers", "required": true }, { "cli": "--ldap-user-main-attribute", "type": "string", "default": "uid", "env": "DM_LDAP_USER_ATTRIBUTE" }, { "cli": "--ldap-users-base", "type": "string", "default": "", "env": "DM_LDAP_USERS_BASE" }, { "cli": "--llng-ini", "type": "string", "default": "/etc/lemonldap-ng/lemonldap-ng.ini", "env": "DM_LLNG_INI" }, { "cli": "--llng-username-header", "type": "string", "default": "", "env": "DM_LLNG_USERNAME_HEADER" }, { "cli": "--log-file", "type": "string", "default": "error.log", "env": "DM_LOG_FILE" }, { "cli": "--log-level", "type": "string", "default": "notice", "env": "DM_LOG_LEVEL" }, { "cli": "--logger", "type": "string", "default": "console", "env": "DM_LOGGER" }, { "cli": "--mail-attribute", "type": "string", "default": "mail", "env": "DM_MAIL_ATTRIBUTE" }, { "cli": "--mail-domain", "type": "array", "default": [], "plural": "--mail-domains", "env": "DM_MAIL_DOMAIN", "kind": "identifiers" }, { "cli": "--max-app-accounts", "type": "number", "default": 5, "env": "DM_MAX_APP_ACCOUNTS" }, { "cli": "--metrics-port", "type": "number", "default": 9464, "env": "DM_METRICS_PORT" }, { "cli": "--migration-proxy-branch", "type": "array", "default": [], "plural": "--migration-proxy-branches", "env": "DM_MIGRATION_PROXY_BRANCHES", "kind": "dns" }, { "cli": "--migration-proxy-concurrency", "type": "number", "default": 10, "env": "DM_MIGRATION_PROXY_CONCURRENCY" }, { "cli": "--migration-proxy-retry-attempts", "type": "number", "default": 5, "env": "DM_MIGRATION_PROXY_RETRY_ATTEMPTS" }, { "cli": "--migration-proxy-retry-delay", "type": "number", "default": 1000, "env": "DM_MIGRATION_PROXY_RETRY_DELAY" }, { "cli": "--migration-proxy-token", "type": "string", "default": "", "env": "DM_MIGRATION_PROXY_TOKEN" }, { "cli": "--migration-proxy-url", "type": "string", "default": "", "env": "DM_MIGRATION_PROXY_URL" }, { "cli": "--oidc-client-id", "type": "string", "default": "", "env": "DM_OIDC_CLIENT_ID" }, { "cli": "--oidc-client-secret", "type": "string", "default": "", "env": "DM_OIDC_CLIENT_SECRET" }, { "cli": "--oidc-logout-origins", "type": "array", "default": [], "plural": "--oidc-logout-origins", "env": "DM_OIDC_LOGOUT_ORIGINS" }, { "cli": "--oidc-server", "type": "string", "default": "", "env": "DM_OIDC_SERVER" }, { "cli": "--oidc-username-claim", "type": "string", "default": "sub", "env": "DM_OIDC_USERNAME_CLAIM" }, { "cli": "--organization-schema", "type": "string", "default": "", "env": "DM_ORGANIZATION_SCHEMA" }, { "cli": "--plugin", "type": "array", "default": [], "plural": "--plugins", "env": "DM_PLUGINS", "kind": "identifiers" }, { "cli": "--port", "type": "number", "default": 8081, "env": "DM_PORT" }, { "cli": "--ppolicy-base", "type": "string", "default": "", "env": "DM_PPOLICY_BASE" }, { "cli": "--ppolicy-default-dn", "type": "string", "default": "", "env": "DM_PPOLICY_DEFAULT_DN" }, { "cli": "--ppolicy-min-length", "type": "number", "default": 12, "env": "DM_PPOLICY_MIN_LENGTH" }, { "cli": "--ppolicy-require-digit", "type": "boolean", "default": true, "env": "DM_PPOLICY_REQUIRE_DIGIT" }, { "cli": "--ppolicy-require-lowercase", "type": "boolean", "default": true, "env": "DM_PPOLICY_REQUIRE_LOWERCASE" }, { "cli": "--ppolicy-require-special", "type": "boolean", "default": true, "env": "DM_PPOLICY_REQUIRE_SPECIAL" }, { "cli": "--ppolicy-require-uppercase", "type": "boolean", "default": true, "env": "DM_PPOLICY_REQUIRE_UPPERCASE" }, { "cli": "--ppolicy-validate-complexity", "type": "boolean", "default": false, "env": "DM_PPOLICY_VALIDATE_COMPLEXITY" }, { "cli": "--ppolicy-warn-days", "type": "number", "default": 14, "env": "DM_PPOLICY_WARN_DAYS" }, { "cli": "--quota-attribute", "type": "string", "default": "mailQuotaSize", "env": "DM_QUOTA_ATTRIBUTE" }, { "cli": "--rabbitmq-url", "type": "string", "default": "", "env": "DM_RABBITMQ_URL" }, { "cli": "--rate-limit-max", "type": "number", "default": 100, "env": "DM_RATE_LIMIT_MAX" }, { "cli": "--rate-limit-window-ms", "type": "number", "default": 900000, "env": "DM_RATE_LIMIT_WINDOW_MS" }, { "cli": "--schemas-path", "type": "string", "default": "/static/schemas", "env": "DM_SCHEMAS_PATH" }, { "cli": "--scim-base-header-root", "type": "string", "default": "", "env": "DM_SCIM_BASE_HEADER_ROOT" }, { "cli": "--scim-base-map", "type": "string", "default": "", "env": "DM_SCIM_BASE_MAP" }, { "cli": "--scim-base-url", "type": "string", "default": "", "env": "DM_SCIM_BASE_URL" }, { "cli": "--scim-bulk-max-operations", "type": "number", "default": 100, "env": "DM_SCIM_BULK_MAX_OPERATIONS" }, { "cli": "--scim-bulk-max-payload-size", "type": "number", "default": 1048576, "env": "DM_SCIM_BULK_MAX_PAYLOAD_SIZE" }, { "cli": "--scim-etag", "type": "boolean", "default": false, "env": "DM_SCIM_ETAG" }, { "cli": "--scim-group-base", "type": "string", "default": "", "env": "DM_SCIM_GROUP_BASE" }, { "cli": "--scim-group-base-header", "type": "string", "default": "", "env": "DM_SCIM_GROUP_BASE_HEADER" }, { "cli": "--scim-group-base-template", "type": "string", "default": "", "env": "DM_SCIM_GROUP_BASE_TEMPLATE" }, { "cli": "--scim-group-external-id-attribute", "type": "string", "default": "", "env": "DM_SCIM_GROUP_EXTERNAL_ID_ATTRIBUTE" }, { "cli": "--scim-group-mapping", "type": "string", "default": "", "env": "DM_SCIM_GROUP_MAPPING" }, { "cli": "--scim-group-object-class", "type": "array", "default": [ "top", "groupOfNames" ], "plural": "--scim-group-object-classes", "env": "DM_SCIM_GROUP_OBJECT_CLASSES", "kind": "identifiers" }, { "cli": "--scim-group-rdn-attribute", "type": "string", "default": "cn", "env": "DM_SCIM_GROUP_RDN_ATTRIBUTE" }, { "cli": "--scim-id-attribute", "type": "string", "default": "rdn", "env": "DM_SCIM_ID_ATTRIBUTE" }, { "cli": "--scim-max-results", "type": "number", "default": 200, "env": "DM_SCIM_MAX_RESULTS" }, { "cli": "--scim-max-scanned", "type": "number", "default": 10000, "env": "DM_SCIM_MAX_SCANNED" }, { "cli": "--scim-prefix", "type": "string", "default": "/scim/v2", "env": "DM_SCIM_PREFIX" }, { "cli": "--scim-user-base", "type": "string", "default": "", "env": "DM_SCIM_USER_BASE" }, { "cli": "--scim-user-base-header", "type": "string", "default": "", "env": "DM_SCIM_USER_BASE_HEADER" }, { "cli": "--scim-user-base-template", "type": "string", "default": "", "env": "DM_SCIM_USER_BASE_TEMPLATE" }, { "cli": "--scim-user-lock-attribute", "type": "string", "default": "pwdAccountLockedTime", "env": "DM_SCIM_USER_LOCK_ATTRIBUTE" }, { "cli": "--scim-user-lock-value", "type": "string", "default": "", "env": "DM_SCIM_USER_LOCK_VALUE" }, { "cli": "--scim-user-mapping", "type": "string", "default": "", "env": "DM_SCIM_USER_MAPPING" }, { "cli": "--scim-user-object-class", "type": "array", "default": [ "top", "inetOrgPerson", "organizationalPerson", "person" ], "plural": "--scim-user-object-classes", "env": "DM_SCIM_USER_OBJECT_CLASSES", "kind": "identifiers" }, { "cli": "--scim-user-rdn-attribute", "type": "string", "default": "uid", "env": "DM_SCIM_USER_RDN_ATTRIBUTE" }, { "cli": "--shutdown-timeout", "type": "number", "default": 20000, "env": "DM_SHUTDOWN_TIMEOUT" }, { "cli": "--static-csp", "type": "string", "default": "frame-ancestors 'self'", "env": "DM_STATIC_CSP" }, { "cli": "--static-frame-options", "type": "string", "default": "SAMEORIGIN", "env": "DM_STATIC_FRAME_OPTIONS" }, { "cli": "--static-name", "type": "string", "default": "static", "env": "DM_STATIC_NAME" }, { "cli": "--static-path", "type": "string", "default": "/static", "env": "DM_STATIC_PATH" }, { "cli": "--storage-backend", "type": "string", "default": "", "env": "DM_STORAGE_BACKEND" }, { "cli": "--storage-file-directory", "type": "string", "default": "", "env": "DM_STORAGE_FILE_DIRECTORY" }, { "cli": "--storage-ldap-base", "type": "string", "default": "", "env": "DM_STORAGE_LDAP_BASE" }, { "cli": "--storage-ldap-object-class", "type": "string", "default": "applicationProcess", "env": "DM_STORAGE_LDAP_OBJECT_CLASS" }, { "cli": "--storage-postgres-table", "type": "string", "default": "ldap_rest_storage", "env": "DM_STORAGE_POSTGRES_TABLE" }, { "cli": "--storage-postgres-url", "type": "string", "default": "", "env": "DM_STORAGE_POSTGRES_URL" }, { "cli": "--storage-sweep-interval", "type": "number", "default": 600, "env": "DM_STORAGE_SWEEP_INTERVAL" }, { "cli": "--storage-valkey-prefix", "type": "string", "default": "ldap-rest:", "env": "DM_STORAGE_VALKEY_PREFIX" }, { "cli": "--storage-valkey-url", "type": "string", "default": "", "env": "DM_STORAGE_VALKEY_URL" }, { "cli": "--strict-options", "type": "boolean", "default": false, "env": "DM_STRICT_OPTIONS" }, { "cli": "--trash-add-metadata", "type": "string", "default": "true", "env": "DM_TRASH_ADD_METADATA" }, { "cli": "--trash-auto-create", "type": "string", "default": "true", "env": "DM_TRASH_AUTO_CREATE" }, { "cli": "--trash-base", "type": "string", "default": "", "env": "DM_TRASH_BASE" }, { "cli": "--trash-watched-bases", "type": "string", "default": "", "env": "DM_TRASH_WATCHED_BASES" }, { "cli": "--trusted-proxy", "type": "array", "default": [], "plural": "--trusted-proxies", "env": "DM_TRUSTED_PROXIES", "kind": "identifiers" }, { "cli": "--trusted-proxy-auth-header", "type": "string", "default": "Auth-User", "env": "DM_TRUSTED_PROXY_AUTH_HEADER" }, { "cli": "--twake-drive-concurrency", "type": "number", "default": 10, "env": "DM_TWAKE_DRIVE_CONCURRENCY" }, { "cli": "--twake-drive-default-domain-template", "type": "string", "default": "", "env": "DM_TWAKE_DRIVE_DEFAULT_DOMAIN_TEMPLATE" }, { "cli": "--twake-drive-domain-attribute", "type": "string", "default": "twakeCozyDomain", "env": "DM_TWAKE_DRIVE_DOMAIN_ATTRIBUTE" }, { "cli": "--twake-drive-webadmin-token", "type": "string", "default": "", "env": "DM_TWAKE_DRIVE_WEBADMIN_TOKEN" }, { "cli": "--twake-drive-webadmin-url", "type": "string", "default": "", "env": "DM_TWAKE_DRIVE_WEBADMIN_URL" }, { "cli": "--twake-group-base", "type": "string", "default": "", "env": "DM_TWAKE_GROUP_BASE" }, { "cli": "--twake-group-color-attribute", "type": "string", "default": "twakeGroupColor", "env": "DM_TWAKE_GROUP_COLOR_ATTRIBUTE" }, { "cli": "--twake-group-created-at-attribute", "type": "string", "default": "twakeCreatedAt", "env": "DM_TWAKE_GROUP_CREATED_AT_ATTRIBUTE" }, { "cli": "--twake-group-display-name-attribute", "type": "string", "default": "twakeDisplayName", "env": "DM_TWAKE_GROUP_DISPLAY_NAME_ATTRIBUTE" }, { "cli": "--twake-group-max-page-limit", "type": "number", "default": 1000, "env": "DM_TWAKE_GROUP_MAX_PAGE_LIMIT" }, { "cli": "--twake-group-member-fields", "type": "json", "default": {}, "env": "DM_TWAKE_GROUP_MEMBER_FIELDS" }, { "cli": "--twake-group-organization-deleted-value", "type": "string", "default": "deleted", "env": "DM_TWAKE_GROUP_ORGANIZATION_DELETED_VALUE" }, { "cli": "--twake-group-organization-dn", "type": "string", "default": "", "env": "DM_TWAKE_GROUP_ORGANIZATION_DN" }, { "cli": "--twake-group-organization-status-attribute", "type": "string", "default": "twakeOrgStatus", "env": "DM_TWAKE_GROUP_ORGANIZATION_STATUS_ATTRIBUTE" }, { "cli": "--twake-group-user-base", "type": "string", "default": "", "env": "DM_TWAKE_GROUP_USER_BASE" }, { "cli": "--twake-instance-auth-exchange", "type": "string", "default": "auth", "env": "DM_TWAKE_INSTANCE_AUTH_EXCHANGE" }, { "cli": "--twake-instance-b2b-exchange", "type": "string", "default": "b2b", "env": "DM_TWAKE_INSTANCE_B2B_EXCHANGE" }, { "cli": "--twake-instance-cloudery-domain", "type": "string", "default": "", "env": "DM_TWAKE_INSTANCE_CLOUDERY_DOMAIN" }, { "cli": "--twake-instance-cloudery-offer", "type": "string", "default": "b2b_twake_default", "env": "DM_TWAKE_INSTANCE_CLOUDERY_OFFER" }, { "cli": "--twake-instance-cloudery-organization-offer", "type": "string", "default": "", "env": "DM_TWAKE_INSTANCE_CLOUDERY_ORGANIZATION_OFFER" }, { "cli": "--twake-instance-cloudery-token", "type": "string", "default": "", "env": "DM_TWAKE_INSTANCE_CLOUDERY_TOKEN" }, { "cli": "--twake-instance-cloudery-url", "type": "string", "default": "", "env": "DM_TWAKE_INSTANCE_CLOUDERY_URL" }, { "cli": "--twake-instance-cozy-apps", "type": "string", "default": "home,drive,settings,notes,dataproxy", "env": "DM_TWAKE_INSTANCE_COZY_APPS" }, { "cli": "--twake-instance-cozy-context", "type": "string", "default": "default", "env": "DM_TWAKE_INSTANCE_COZY_CONTEXT" }, { "cli": "--twake-instance-cozy-domain", "type": "string", "default": "", "env": "DM_TWAKE_INSTANCE_COZY_DOMAIN" }, { "cli": "--twake-instance-cozy-org-domain", "type": "string", "default": "", "env": "DM_TWAKE_INSTANCE_COZY_ORG_DOMAIN" }, { "cli": "--twake-instance-cozy-org-id", "type": "string", "default": "", "env": "DM_TWAKE_INSTANCE_COZY_ORG_ID" }, { "cli": "--twake-instance-cozy-passphrase", "type": "string", "default": "", "env": "DM_TWAKE_INSTANCE_COZY_PASSPHRASE" }, { "cli": "--twake-instance-cozy-url", "type": "string", "default": "", "env": "DM_TWAKE_INSTANCE_COZY_URL" }, { "cli": "--twake-instance-cozy-user", "type": "string", "default": "admin", "env": "DM_TWAKE_INSTANCE_COZY_USER" }, { "cli": "--twake-instance-dn", "type": "array", "default": [], "env": "DM_TWAKE_INSTANCE_DN", "kind": "dns" }, { "cli": "--twake-instance-fqdn-attribute", "type": "string", "default": "twakeWorkspaceUrl", "env": "DM_TWAKE_INSTANCE_FQDN_ATTRIBUTE" }, { "cli": "--twake-instance-id", "type": "string", "default": "{uid}", "env": "DM_TWAKE_INSTANCE_ID" }, { "cli": "--twake-instance-locale", "type": "string", "default": "", "env": "DM_TWAKE_INSTANCE_LOCALE" }, { "cli": "--twake-instance-organization-base", "type": "string", "default": "", "env": "DM_TWAKE_INSTANCE_ORGANIZATION_BASE" }, { "cli": "--twake-instance-organization-created-key", "type": "string", "default": "organization.created", "env": "DM_TWAKE_INSTANCE_ORGANIZATION_CREATED_KEY" }, { "cli": "--twake-instance-organization-domain-attribute", "type": "string", "default": "twakeDomain", "env": "DM_TWAKE_INSTANCE_ORGANIZATION_DOMAIN_ATTRIBUTE" }, { "cli": "--twake-instance-organization-fqdn-attribute", "type": "string", "default": "", "env": "DM_TWAKE_INSTANCE_ORGANIZATION_FQDN_ATTRIBUTE" }, { "cli": "--twake-instance-organization-id-attribute", "type": "string", "default": "ou", "env": "DM_TWAKE_INSTANCE_ORGANIZATION_ID_ATTRIBUTE" }, { "cli": "--twake-instance-organization-name-attribute", "type": "string", "default": "description", "env": "DM_TWAKE_INSTANCE_ORGANIZATION_NAME_ATTRIBUTE" }, { "cli": "--twake-instance-provider", "type": "string", "default": "cloudery", "env": "DM_TWAKE_INSTANCE_PROVIDER" }, { "cli": "--twake-instance-queue", "type": "string", "default": "workplace.created.ldap-rest", "env": "DM_TWAKE_INSTANCE_QUEUE" }, { "cli": "--twake-instance-sent-attribute", "type": "string", "default": "twakeCreatedEventAt", "env": "DM_TWAKE_INSTANCE_SENT_ATTRIBUTE" }, { "cli": "--twake-instance-skip-attribute", "type": "string", "default": "", "env": "DM_TWAKE_INSTANCE_SKIP_ATTRIBUTE" }, { "cli": "--twake-instance-skip-value", "type": "string", "default": "", "env": "DM_TWAKE_INSTANCE_SKIP_VALUE" }, { "cli": "--twake-instance-timeout", "type": "number", "default": 30000, "env": "DM_TWAKE_INSTANCE_TIMEOUT" }, { "cli": "--twake-instance-user-created-key", "type": "string", "default": "user.created", "env": "DM_TWAKE_INSTANCE_USER_CREATED_KEY" }, { "cli": "--twake-instance-workplace-created-key", "type": "string", "default": "workplace.created", "env": "DM_TWAKE_INSTANCE_WORKPLACE_CREATED_KEY" }, { "cli": "--twake-lifecycle-deleted-at-attribute", "type": "string", "default": "", "env": "DM_TWAKE_LIFECYCLE_DELETED_AT_ATTRIBUTE" }, { "cli": "--twake-lifecycle-deleted-at-format", "type": "string", "default": "iso8601", "env": "DM_TWAKE_LIFECYCLE_DELETED_AT_FORMAT" }, { "cli": "--twake-lifecycle-deleted-attribute", "type": "string", "default": "", "env": "DM_TWAKE_LIFECYCLE_DELETED_ATTRIBUTE" }, { "cli": "--twake-lifecycle-deleted-value", "type": "string", "default": "TRUE", "env": "DM_TWAKE_LIFECYCLE_DELETED_VALUE" }, { "cli": "--twake-lifecycle-lock-attribute", "type": "string", "default": "", "env": "DM_TWAKE_LIFECYCLE_LOCK_ATTRIBUTE" }, { "cli": "--twake-lifecycle-lock-value", "type": "string", "default": "", "env": "DM_TWAKE_LIFECYCLE_LOCK_VALUE" }, { "cli": "--twake-lifecycle-member-attribute", "type": "string", "default": "member", "env": "DM_TWAKE_LIFECYCLE_MEMBER_ATTRIBUTE" }, { "cli": "--twake-lifecycle-reason-attribute", "type": "string", "default": "", "env": "DM_TWAKE_LIFECYCLE_REASON_ATTRIBUTE" }, { "cli": "--twake-lifecycle-role-attribute", "type": "string", "default": "", "env": "DM_TWAKE_LIFECYCLE_ROLE_ATTRIBUTE" }, { "cli": "--twake-lifecycle-rules", "type": "string", "default": "", "env": "DM_TWAKE_LIFECYCLE_RULES" }, { "cli": "--twake-space-admin-attribute", "type": "string", "default": "twakeSpaceAdmin", "env": "DM_TWAKE_SPACE_ADMIN_ATTRIBUTE" }, { "cli": "--twake-space-base", "type": "string", "default": "", "env": "DM_TWAKE_SPACE_BASE" }, { "cli": "--twake-space-class", "type": "array", "default": [ "top", "twakeSpace" ], "env": "DM_TWAKE_SPACE_CLASS", "kind": "identifiers" }, { "cli": "--twake-space-display-name-attribute", "type": "string", "default": "twakeDisplayName", "env": "DM_TWAKE_SPACE_DISPLAY_NAME_ATTRIBUTE" }, { "cli": "--twake-space-editor-attribute", "type": "string", "default": "twakeSpaceEditor", "env": "DM_TWAKE_SPACE_EDITOR_ATTRIBUTE" }, { "cli": "--twake-space-exchange", "type": "string", "default": "space", "env": "DM_TWAKE_SPACE_EXCHANGE" }, { "cli": "--twake-space-sync-queue", "type": "string", "default": "twake.space.sync.requested.ldap-rest", "env": "DM_TWAKE_SPACE_SYNC_QUEUE" }, { "cli": "--twake-space-user-role-attribute", "type": "string", "default": "", "env": "DM_TWAKE_SPACE_USER_ROLE_ATTRIBUTE" }, { "cli": "--twake-space-viewer-attribute", "type": "string", "default": "twakeSpaceViewer", "env": "DM_TWAKE_SPACE_VIEWER_ATTRIBUTE" }, { "cli": "--twake-tombstone-clear-attributes", "type": "array", "default": [], "env": "DM_TWAKE_TOMBSTONE_CLEAR_ATTRIBUTES", "kind": "identifiers" }, { "cli": "--twake-tombstone-default-reason", "type": "string", "default": "deleted", "env": "DM_TWAKE_TOMBSTONE_DEFAULT_REASON" }, { "cli": "--twake-tombstone-dn", "type": "array", "default": [], "env": "DM_TWAKE_TOMBSTONE_DN", "kind": "dns" }, { "cli": "--twake-tombstone-erase-min-age", "type": "number", "default": 2592000, "env": "DM_TWAKE_TOMBSTONE_ERASE_MIN_AGE" }, { "cli": "--twake-tombstone-group-bases", "type": "array", "default": [], "env": "DM_TWAKE_TOMBSTONE_GROUP_BASES", "kind": "dns" }, { "cli": "--twake-tombstone-reason-header", "type": "string", "default": "x-deletion-reason", "env": "DM_TWAKE_TOMBSTONE_REASON_HEADER" }, { "cli": "--twake-tombstone-reasons", "type": "array", "default": [], "env": "DM_TWAKE_TOMBSTONE_REASONS", "kind": "identifiers" }, { "cli": "--user-class", "type": "array", "default": [ "top", "twakeAccount", "twakeWhitePages" ], "plural": "--user-classes", "env": "DM_USER_CLASSES", "kind": "identifiers" } ] linagora-ldap-rest-a4bb78e/docs/usage/plugins/000077500000000000000000000000001527331160600214165ustar00rootroot00000000000000linagora-ldap-rest-a4bb78e/docs/usage/plugins/README.md000066400000000000000000000124431527331160600227010ustar00rootroot00000000000000# Plugins LDAP-Rest is extensible through a plugin system. This section documents all available plugins. ## LDAP Plugins LDAP entity management: | Plugin | Description | | ---------------------------------------- | --------------------------------------------------------- | | [flat-generic](ldap/flat-generic.md) | Generic LDAP entity management (users, positions, etc.) | | [groups](ldap/groups.md) | LDAP group management with member validation | | [organizations](ldap/organizations.md) | Hierarchical organization management | | [bulk-import](ldap/bulk-import.md) | Bulk import from CSV | | [trash](ldap/trash.md) | Trash system (soft delete) | | [external-users](ldap/external-users.md) | Automatic external contact creation | | [on-change](ldap/on-change.md) | LDAP change detection | | [raw](ldap/raw.md) | Low-level browsing: root DSE, schema, entries (read-only) | ## Authentication Plugins Secure API access: | Plugin | Description | | -------------------------------------------------- | ---------------------------------------------- | | [token](auth/token.md) | Bearer token authentication | | [totp](auth/totp.md) | TOTP authentication (time-based codes) | | [hmac](auth/hmac.md) | HMAC-SHA256 signing for backend services | | [llng](auth/llng.md) | LemonLDAP::NG SSO integration | | [oidc](auth/oidc.md) | OpenID Connect / OAuth 2.0 | | [back-channel-logout](auth/back-channel-logout.md) | Honour a logout performed at the OIDC provider | ## Authorization Plugins Access control: | Plugin | Description | | -------------------------------------------- | ------------------------------- | | [authz-per-branch](auth/authz-per-branch.md) | Branch-level LDAP authorization | | [authz-linid1](auth/authz-linid1.md) | LinID 1.x integration | ## Security Plugins Protection and rate limiting: | Plugin | Description | | -------------------------------------- | --------------------------------- | | [trusted-proxy](auth/trusted-proxy.md) | X-Forwarded-For header validation | | [rate-limit](auth/rate-limit.md) | Request rate limiting | | [crowdsec](auth/crowdsec.md) | IP blocking via CrowdSec | ## Integration Plugins Connect to external systems: | Plugin | Description | | ---------------------------------------------------- | ------------------------------------ | | [james-mail](integrations/james-mail.md) | Apache James synchronization | | [james-mailboxes](integrations/james-mailboxes.md) | Apache James team mailboxes | | [mail-migration](integrations/mail-migration.md) | Twake Mail migration proxy | | [calendar](integrations/calendar.md) | Twake Calendar resources and users | | [app-accounts](integrations/app-accounts.md) | Applicative accounts API | | [lifecycle-events](integrations/lifecycle-events.md) | Account lifecycle events to RabbitMQ | | [instances](integrations/instances.md) | Workplace instance of each account | | [tombstone](integrations/tombstone.md) | Deleted accounts kept as tombstones | | [groups](integrations/groups.md) | Organization groups | ## Utility Plugins | Plugin | Description | | ------------------------------- | ------------------------------------------------------------------- | | [static](utilities/static.md) | Static file server | | [weblogs](utilities/weblogs.md) | HTTP logging | | [metrics](utilities/metrics.md) | Prometheus metrics on a port of their own | | [storage](utilities/storage.md) | Keyed storage with a deadline, in LDAP, files, PostgreSQL or Valkey | ## Plugin Dependencies Some plugins require other plugins: ``` core/twake/james └─ requires: core/ldap/onChange core/ldap/externalUsersInGroups └─ requires: core/ldap/groups core/auth/authzPerBranch └─ requires: An authentication plugin core/bcl └─ requires: core/storage (refuses to start without it) ``` See [the complete dependencies matrix](../../plugin-development/dependencies.md). ## Load Order Authentication plugins are loaded first, so their middleware sits ahead of the routes it guards. The list also carries the plugins other plugins depend on, which have to be registered before the batch their consumers land in — `core/storage` is one. The order is defined in `src/plugins/priority.json`. linagora-ldap-rest-a4bb78e/docs/usage/plugins/auth/000077500000000000000000000000001527331160600223575ustar00rootroot00000000000000linagora-ldap-rest-a4bb78e/docs/usage/plugins/auth/README.md000066400000000000000000000425371527331160600236510ustar00rootroot00000000000000# Authentication Plugins LDAP-Rest provides multiple authentication plugins to secure API access. These plugins can be used individually or combined depending on your infrastructure requirements. ## Available Authentication Methods | Method | Plugin | Description | | ------------------------- | ------------------------- | ------------------------------------------------ | | [Token](token.md) | `core/auth/token` | Simple bearer token authentication | | [TOTP](totp.md) | `core/auth/totp` | Time-based One-Time Password authentication | | [HMAC](hmac.md) | `core/auth/hmac` | HMAC-SHA256 request signing for backend services | | [LemonLDAP::NG](llng.md) | `core/auth/llng` | Integration with LemonLDAP::NG SSO | | [OpenID Connect](oidc.md) | `core/auth/openidconnect` | OAuth 2.0 / OpenID Connect authentication | | [Fake](fake.md) | `core/auth/fake` | Development only: a fixed identity, no login | ## What a rule is keyed on Authorization rules name a caller, and each authenticator has its own idea of what a caller is called. Every one of them publishes the caller twice: | Authenticator | `req.user` | `req.userName` | | ------------------------- | ----------------------------------- | ---------------------------------------- | | `core/auth/token` | the name given in `--auth-token` | the same | | `core/auth/totp` | the name given in `--auth-totp` | the same | | `core/auth/hmac` | the service name from `--auth-hmac` | the same | | `core/auth/llng` | LLNG's `whatToTrace` | `--llng-username-header`, or the same | | `core/auth/openidconnect` | the OIDC `sub` | `--oidc-username-claim` (default: `sub`) | | `core/auth/authzDynamic` | the token's tenant | the same | | `core/auth/fake` | `--auth-fake-user` | the same | `req.user` is this server's identifier for the caller; `req.userName` is the caller under a name a person would use. `--authz-identity` says which one is keyed on by `core/auth/authzPerBranch`, `core/auth/authzLinid1`, `core/auth/authzPerRoute` and the SCIM base map — every plugin that names a caller in its configuration: ```bash --authz-identity req.user # the default: every existing rule is unchanged --authz-identity req.userName # rules written on logins ``` The second is what makes a rule portable. Under OpenID Connect the `sub` is opaque — `auth0|…`, a UUID — so rules keyed on it have to be looked up per administrator and rewritten when the provider changes; keyed on `preferred_username` they read as the logins they are. A value other than these two is refused at startup. When the option asks for `req.userName` and the authenticator published none, permissions are read for `req.user` and a warning says so once — a rule that silently stops matching is worse than one that refuses. At startup each authenticator logs what it will publish, and each authorization plugin whose configured identities none of them can produce says so: token, TOTP and HMAC names are in the configuration, and an identity provider's claims are not, in which case the line says it cannot be checked before a login rather than staying silent. ## Several authorization plugins Every plugin that judges LDAP operations — `core/auth/authzPerBranch`, `core/auth/authzLinid1`, `core/auth/authzDynamic` — registers the same hooks, and all of them run: loaded together, they compose as an **AND**, and the first refusal wins. The per-caller read filter (`ldapsearchfilter`) is one of the hooks they register: two plugins there compose as an intersection, which is the same AND. That is sound when it is meant. When it is not, it is a refusal nobody can explain: `authzPerBranch` beside `authzDynamic` judges a machine token by a branch configuration written for administrators, finds no entry for the tenant, and refuses a write the token's own ACLs grant. The authorization plugins' request hooks (`ldapsearchrequest`, `ldapaddrequest`, `ldapmodifyrequest`, `ldapdeleterequest`, `ldaprenamerequest`) run before any other plugin's, whatever order the plugins loaded in: a caller who may not make an operation gets the 403, not another plugin's 409, and the operation is not replaced (by `core/ldap/trash`, for instance) before it is judged. `ldapsearchfilter` keeps the load order. The AND needs no second plugin to bite. `--authz-per-branch-config` has a default — read everywhere, write nowhere — so a lone `authzPerBranch` judges **every** authenticated identity, whatever authenticated it: a token that no rule names reads, and every write it attempts is refused. ### Whose requests a plugin judges The dispatcher records which authentication plugins vouched for a request, by instance name, in `req.authenticators`. An authorization plugin given `authz_for` judges the requests of those plugins and skips the others: ```bash --plugin core/auth/authzDynamic --plugin 'core/auth/openidconnect:oidc:{"auth_path_prefix":"/api/admin"}' --plugin 'core/auth/authzPerBranch:authzPerBranch:{"authz_for":["oidc"]}' ``` Here the tokens are held to their own ACLs, and the administrators signed in through OpenID Connect to the branch configuration: no request is judged by both. Unset, `authz_for` means every authenticated request — what every plugin did before the option existed. It is read by `authzPerBranch`, `authzLinid1` and `authzPerRoute`; `authzDynamic` judges the requests carrying its own tokens, and no others, whatever the option says — an `authz_for` of its own there is said at startup rather than ignored in silence. Two cases stay apart: - a request **another** population's authenticator vouched for is skipped — it is not this model's business; - a request of **this** population whose identity does not resolve is refused — a configuration error, not an absence of one (`--authz-unresolved-user`). A request carrying an identity that no authenticator vouched for is judged: "not mine" is never inferred from an absence. A plugin that only stepped aside — `authzDynamic` letting a request through on `--authz-dynamic-bypass` — did not vouch for it and is not recorded. ### What is refused at startup - An authorization plugin with no authentication plugin is refused: no request carries an identity, so every authorization plugin would step aside and everything would pass while authorization looks on. `authzDynamic`, which authenticates the requests it judges, needs nothing else. With neither kind of plugin, the server serves every request with every right, on purpose (local development, a trusted network); with authentication alone, every authenticated caller has every right (a single administration token, say). The log says which, once. This is checked over the plugins the configuration loads (`--plugin`); a host registering plugins itself answers for what they enforce. - Two plugins judging LDAP operations for the same authenticator's requests — two branch-level plugins with no `authz_for`, or a branch-level plugin judging `authzDynamic`'s tokens — are refused, with both names, unless `--authz-combine` says the AND is meant. `authzPerRoute` registers no LDAP hook and is never part of it: route plus branch, and route plus token, are deliberate ANDs on identities each side can name. - An `authz_for` naming no loaded authentication plugin is refused: the plugin would judge nobody, which reads as a working configuration. A server-wide `--authz-for` that no loaded plugin reads — only `authzDynamic` there — is said: it scopes nothing. - Two plugins whose populations only meet through two authenticators on the same path prefix — a request presenting both credentials is judged by both — are said, not refused: asking for two credentials is a decision already. A host that assembles the server itself — `registerPlugin` after `ready`, rather than `--plugin` — gets the same checks of the composition, as each plugin arrives (not the one asking for an authentication plugin, above): the refusal comes out of `registerPlugin`, before any of the plugin's routes or hooks exist. An arriving plugin is judged against what is already there, so the authentication plugins an `authz_for` or `--authz-scope-source` names are registered before the plugin naming them. ### What the log says At startup, one line per plugin names the hooks it takes part in and whose requests it judges — `every authenticated request (authToken, oidc)` when `authz_for` is unset, so a model judging an authenticator nobody wrote it for is visible before a request arrives. When a hook refuses, the log line names the plugin that refused; the 403 body does not, as it never discloses the model. With two plugins combined on purpose, `--authz-filter-attached-entries` only broadens a listing while both honour it — both do, being built on the same base class, but nothing obliges a third implementation to. ## Authorization Plugins | Method | Plugin | Description | | ----------------------------------------------- | -------------------------- | ---------------------------- | | [Authorization Per Branch](authz-per-branch.md) | `core/auth/authzPerBranch` | Branch-level access control | | [Authorization Per Route](authz-per-route.md) | `core/auth/authzPerRoute` | HTTP route-level ACL | | [Authorization LinID 1.x](authz-linid1.md) | `core/auth/authzLinid1` | LinID 1.x integration | | [Authorization Scope](authz-scope.md) | `core/auth/authzScope` | What the current user may do | ## Security Plugins | Method | Plugin | Description | | --------------------------------- | ------------------------ | ----------------------------------------------------- | | [Trusted Proxy](trusted-proxy.md) | `core/auth/trustedProxy` | Validate X-Forwarded-For headers from reverse proxies | | [Rate Limiting](rate-limit.md) | `core/auth/rateLimit` | Prevent brute force attacks | | [CrowdSec](crowdsec.md) | `core/auth/crowdsec` | IP reputation and blocking | ## Choosing an Authentication Method | Feature | Token | TOTP | HMAC | LemonLDAP::NG | OpenID Connect | | ---------------------- | ------------- | ----------------- | ---------------------- | --------------------- | -------------------------------- | | **Setup Complexity** | Simple | Simple | Medium | Medium | Medium | | **User Management** | None | Manual | Manual (service-based) | External (LLNG) | External (Provider) | | **SSO Support** | No | No | No | Yes | Yes | | **MFA Support** | No | Yes (TOTP itself) | N/A | Yes (via LLNG) | Yes (via Provider) | | **Session Management** | Stateless | Stateless | Stateless | LLNG Sessions | OIDC Sessions | | **Code Expiration** | Never | 30-60 seconds | Per-request | Session-based | Session-based | | **Request Integrity** | No | No | Yes (body + path hash) | No | No | | **Replay Protection** | No | Yes (time-based) | Yes (timestamp) | Session-based | Session-based | | **Best For** | APIs, Scripts | APIs, Enhanced | Backend Services | Enterprises with LLNG | Cloud/SaaS, Enterprises with SSO | | **Dependencies** | None | None | None | lemonldap-ng-handler | express-openid-connect | ## Serving several populations from one server Populations rarely authenticate the same way: machines carry a token, administrators arrive with an SSO session. `--auth-path-prefix` restricts an authentication plugin to one or more path prefixes, so a single server can host both without either credential being valid on the other's branch of the API. Any plugin can be loaded more than once under a distinct name with its own configuration (`module:name:{json}`), which is what makes this work: ```bash ldap-rest \ --plugin 'core/auth/token:auth-machines:{"auth_path_prefix":"/api/m","auth_token":"…:robot"}' \ --plugin 'core/ldap/raw:raw-machines:{"api_prefix":"/api/m"}' \ --plugin 'core/auth/openidconnect:auth-admins:{"auth_path_prefix":"/api/admin"}' \ --plugin 'core/ldap/raw:raw-admins:{"api_prefix":"/api/admin"}' ``` A token presented on `/api/admin` is refused, and a session presented on `/api/m` is refused too: each authentication plugin only sees the branch it was mounted on. Prefixes match on segment boundaries, so `/api/m` guards `/api/m` and `/api/m/entry` but never `/api/machines`. A prefix must be a string starting with `/`. Anything else — a number left in the JSON, an empty entry, a prefix without its leading slash — refuses to start rather than being skipped: a dropped entry shrinks what the plugin guards, which is the failure that leaves a branch open while the configuration still reads as if it were covered. `/` is not a prefix but the whole server, so a list containing it makes the plugin a catch-all: `["/", "/api/admin"]` guards everything, not only `/api/admin`. ### One branch apart, a token on everything else Leaving a plugin unscoped makes it the **catch-all**: it guards everything no scoped plugin claims. There is no list of "everything else" to write, and none to keep up to date when a route is added: ```bash ldap-rest \ --plugin 'core/auth/openidconnect:auth-admins:{"auth_path_prefix":"/api/admin"}' \ --plugin core/auth/token \ --auth-token "…:robot" ``` `/api/admin` requires the SSO session, everything else requires the token. ### How the choice is made Authentication is not a middleware each plugin mounts for itself. The server mounts **one dispatcher**, positioned after the guards (`protect`: rate limiting, proxy trust, CrowdSec) and access logging, and before the first plugin able to register a route. Authentication plugins register with it instead of adding a layer. Two properties follow, and both are load-bearing: - **Order of declaration does not matter.** A plugin that registers after the API it guards still guards it, because the dispatcher — not the plugin — owns the layer. With per-plugin middlewares that was untrue and silent: a guard mounted after its routes never ran for them. - **The most specific claim wins.** `/api/admin` is handled by the plugin scoped to it, `/api` by the plugin scoped to `/api`, and anything unclaimed by an unscoped plugin. Only the winner runs, so two plugins covering the same request never compose as an AND that no credential can satisfy. Several plugins sharing the same winning prefix all run, in registration order: requiring two credentials on one branch is a legitimate ask, and an accident there fails closed rather than open. ### The gap to watch A route registered **outside every prefix is served without authentication**. That is the price of scoping, and it is silent — the server starts, the API answers. To make it visible, the routes no plugin guards are listed at startup: ``` Authentication is restricted to /api/m, /api/admin, so these routes are served without authentication: /api/v1/config, /api/v1/ldap/users ``` Read that line on every configuration change. Either scope the missing routes to a prefix as well, add an unscoped plugin as the catch-all, or keep them public deliberately. Nothing is reported when no authentication is configured (the server is open on purpose) or when a catch-all covers what the scoped plugins do not. Note the converse: with a catch-all there is no public branch at all. Making one API reachable without credentials means scoping every authentication plugin and leaving that branch out — the warning will then name it, which is correct but reads as an oversight rather than a decision. Note that a prefix scopes **authentication**, not authorization: once past it, a caller reaches everything the branch exposes. Restrict what each population may do with [authz-per-route](authz-per-route.md) or [authz-per-branch](authz-per-branch.md). ## Combining with Authorization All authentication plugins set `req.user` to the authenticated identity. You can use hooks to implement custom authorization: ```javascript hooks: { afterAuth: async ([req, res]) => { // Check if user can access this endpoint if (req.path.startsWith('/api/v1/ldap/users') && !isAdmin(req.user)) { throw new Error('Forbidden: Admin access required'); } return [req, res]; }; } ``` ## See Also - [LemonLDAP::NG Documentation](https://lemonldap-ng.org/documentation) - [OpenID Connect Specification](https://openid.net/connect/) - [express-openid-connect](https://github.com/auth0/express-openid-connect) linagora-ldap-rest-a4bb78e/docs/usage/plugins/auth/authz-dynamic.md000066400000000000000000000261161527331160600254640ustar00rootroot00000000000000# authzDynamic — LDAP-backed tokens + per-branch authorization The `authzDynamic` plugin combines **bearer-token authentication** and **per-branch authorization** in a single plugin, sourced entirely from a dedicated LDAP branch. Adding, rotating, or revoking a token — or changing its ACL — is a matter of writing to LDAP: no process restart, no config file. This is the recommended building block for multi-tenant deployments where each SCIM client (or REST API consumer) is scoped to its own subtree. ## How it works 1. The plugin loads all entries under `--authz-dynamic-base` and caches them. 2. On every incoming request, it reads `Authorization: Bearer ` and verifies the token against the `userPassword` hash of each cached entry (timing-safe comparison). 3. On a match, the plugin sets `req.user` to the tenant name and records the token's ACL in an `AsyncLocalStorage` context. 4. Downstream LDAP operations (via `ldapActions.search / add / modify / delete / rename`) trigger authz hooks that read the active token from the async context and throw if the requested DN is outside the allowed branches. 5. The cache refreshes on a TTL (default 60 s) or on demand via a protected reload endpoint. ## Configuration ```bash node lib/bin/index.js \ --ldap-url ldap://ldap.internal \ --ldap-base dc=example,dc=com \ --ldap-dn cn=admin,dc=example,dc=com --ldap-pwd '***' \ \ --plugin core/auth/authzDynamic \ --authz-dynamic-base ou=authz-tokens,dc=example,dc=com \ --authz-dynamic-cache-ttl 60 \ --authz-dynamic-reload-endpoint ``` ### CLI / environment | Argument | Environment | Default | Purpose | | ---------------------------------- | ----------------------------------- | -------------- | --------------------------------------------------------------------- | | `--authz-dynamic-base` | `DM_AUTHZ_DYNAMIC_BASE` | — (required) | LDAP branch that contains the token entries | | `--authz-dynamic-cache-ttl` | `DM_AUTHZ_DYNAMIC_CACHE_TTL` | `60` | Cache refresh interval in seconds | | `--authz-dynamic-token-attribute` | `DM_AUTHZ_DYNAMIC_TOKEN_ATTRIBUTE` | `userPassword` | Attribute holding the hashed bearer token | | `--authz-dynamic-config-attribute` | `DM_AUTHZ_DYNAMIC_CONFIG_ATTRIBUTE` | `description` | Attribute holding the JSON ACL document | | `--authz-dynamic-tenant-attribute` | `DM_AUTHZ_DYNAMIC_TENANT_ATTRIBUTE` | `cn` | Attribute from which `req.user` is read | | `--authz-dynamic-reload-endpoint` | `DM_AUTHZ_DYNAMIC_RELOAD_ENDPOINT` | `false` | Register `POST /api/v1/authz-dynamic/reload` for manual cache refresh | | `--authz-dynamic-bypass` | `DM_AUTHZ_DYNAMIC_BYPASS` | _(empty)_ | Who may reach the directory without one of these tokens (see below) | ### Loading this plugin beside another authenticator The dispatcher runs every authentication plugin claiming a request's path, so a request can arrive here already identified by another one. Until 0.9.0 this plugin stepped aside whenever that happened, and the token ACLs were then applied to nothing: with `core/auth/token` loaded beside it, a static token reached the whole directory as an unscoped administrator — or was refused for lacking a dynamic token, depending on which plugin had been registered first. The ACLs now apply to whatever token the request carries, whoever else identified it, and anything allowed past them without a token is named: | `--authz-dynamic-bypass` | Effect | | ------------------------ | --------------------------------------------------------------------------------------------------- | | _(empty, the default)_ | Nobody. A request must carry one of these tokens | | `any-authenticated` | Anything another plugin authenticated — the behaviour before 0.9.0 | | `trusted-proxy` | A request from a trusted proxy **that named a caller** (`req.trustedProxy` and `req.proxyAuthUser`) | | any other value | The identity another authenticator publishes in `req.user`, e.g. a name from `--auth-token` | Values combine: `--authz-dynamic-bypass trusted-proxy --authz-dynamic-bypass ops-admin`. `trusted-proxy` requires the proxy to have named someone. `trustedProxy` marks the _address_ it trusts and fills `proxyAuthUser` only when the request carried the authentication header it expects, so accepting the mark alone would hand the whole directory to every host of the `--trusted-proxy` range — `10.0.0.0/8` being all of it. A bypassed request carries **no** token, so no ACL is enforced on it — that is what a bypass is. Each one is logged at `info` with the reason that let it through, and a configuration loading this plugin beside another authenticator says so once at startup. The answer no longer depends on registration order: when the bypass names an identity another plugin has not published yet, the verdict waits until every authenticator has run. ## Token entry shape Each token is a plain LDAP entry under `--authz-dynamic-base`. Minimal requirements: `cn` (token identifier), `userPassword` (the hashed secret), and a JSON ACL document in `description` (by default — override via `--authz-dynamic-config-attribute`). ```ldif dn: cn=acme,ou=authz-tokens,dc=example,dc=com objectClass: top objectClass: inetOrgPerson cn: acme sn: acme userPassword: {SSHA}eFt2wP4ykczHzV9pzE3CH3k1t9M= description: { "tenant": "acme", "bases": [ { "dn": "ou=users,ou=acme,dc=example,dc=com", "read": true, "write": true, "delete": true }, { "dn": "ou=groups,ou=acme,dc=example,dc=com", "read": true, "write": true, "delete": true } ] } ``` ### ACL JSON ```json { "tenant": "acme", "bases": [ { "dn": "", "read": true, "write": true, "delete": true } ] } ``` - **Sub-branch matching**: a permission on `ou=acme,dc=example,dc=com` automatically covers `ou=users,ou=acme,…` and any sub-branch. Branches and target DNs are compared RDN by RDN, so case, spacing, escapes, `;` separators and attribute-type spellings (`ou`, `2.5.4.11`, `OID.2.5.4.11`) do not matter; an escaped comma is part of a value, never a separator. - Only the JSON boolean `true` grants a permission. An absent flag, or one written as anything else (a string such as `"false"` or `"true"`, a number), is `false`; a non-boolean flag is logged as a warning. - `tenant` is optional; if omitted, `req.user` falls back to the value of the configured `tenantAttribute` (default `cn`). ### Supported userPassword schemes Handled by a constant-time verifier (`authzDynamicHash.ts`): | Scheme | Notes | | ------------------------------------- | ---------------------------------------------------- | | `{SSHA}` | salted SHA-1 — OpenLDAP default, recommended minimum | | `{SHA}` | unsalted SHA-1 — legacy | | `{SSHA256}` / `{SHA256}` | salted / unsalted SHA-256 | | `{SSHA512}` / `{SHA512}` | salted / unsalted SHA-512 | | `{SMD5}` / `{MD5}` | salted / unsalted MD5 — legacy, avoid | | `{CLEARTEXT}` / `{PLAIN}` / no prefix | cleartext — test environments only | To generate an `{SSHA}` hash with OpenLDAP tools: `slappasswd -h '{SSHA}'`. To generate programmatically, `ssha(token)` is also exported from `authzDynamicHash.ts` for scripts. ## Usage with SCIM This is the combination the plugin was designed for: ```bash node lib/bin/index.js \ --ldap-base dc=example,dc=com \ --ldap-dn cn=admin,dc=example,dc=com --ldap-pwd '***' \ \ --plugin core/auth/authzDynamic \ --authz-dynamic-base ou=authz-tokens,dc=example,dc=com \ \ --plugin core/scim \ --scim-user-base-template 'ou=users,ou={user},dc=example,dc=com' \ --scim-group-base-template 'ou=groups,ou={user},dc=example,dc=com' ``` Each token's `tenant` field populates `req.user`, which the SCIM plugin uses to resolve its LDAP bases via the `{user}` template. Combined with the token's ACL, the client is cryptographically constrained to its own SCIM subtree. ## Operational notes - **Cache**: read entries are kept in memory. A failed reload (LDAP hiccup) keeps the previous snapshot in use — no request is ever served with an empty cache unless the very first load fails. - **Reload endpoint**: when `--authz-dynamic-reload-endpoint` is set, any **already-authenticated** token can call `POST /api/v1/authz-dynamic/reload` to force an immediate refresh. Useful after provisioning a new token. - **Deletion**: deleting a token entry from LDAP invalidates it within `--authz-dynamic-cache-ttl` seconds (or immediately if a reload is triggered). Rotate compromised secrets this way. - **Audit**: every unauthorized attempt is logged at `warn` with a masked token (first 8 chars + `...`). Every authorization failure is logged at `error` via the DM error middleware. - **configApi**: when `core/configApi` is loaded, the plugin publishes a compact status under `features.authzDynamic` (token count, base DN, cache TTL, reload endpoint URL) — never hashes or per-token DNs. ## Security considerations - **Secret storage**: always hash `userPassword` (`{SSHA}` or better). The plugin refuses unknown scheme prefixes, but accepts cleartext — do not deploy cleartext secrets in production. - **Timing**: comparisons run through `crypto.timingSafeEqual` or a buffer wrapper, preventing timing oracles on the secret. - **Composition**: loading another authenticator on the same paths means a request can arrive already identified. It is still checked against the token it carries; what may pass without one is `--authz-dynamic-bypass`, and nothing else. - **Scope escape**: the authz hooks check every LDAP operation's DN against the token's ACL. The active token is carried via `AsyncLocalStorage`, so it applies even to plugins that do not thread `req` down to `ldapActions`. - **Binding credentials**: the DM bind user still has admin access — the plugin's isolation is logical (path prefix check), not LDAP-layer ACL. For a second layer of defence, configure OpenLDAP ACLs to match. ## Example: listing a tenant's configuration With `core/configApi` loaded: ```bash curl -H 'Authorization: Bearer ' http://localhost:8081/api/v1/config ``` The response contains (trimmed): ```json { "features": { "authzDynamic": { "enabled": true, "base": "ou=authz-tokens,dc=example,dc=com", "cacheTtlSeconds": 60, "tokenCount": 17, "tokenAttribute": "userPassword", "configAttribute": "description", "tenantAttribute": "cn", "reloadEndpoint": "/api/v1/authz-dynamic/reload" } } } ``` linagora-ldap-rest-a4bb78e/docs/usage/plugins/auth/authz-linid1.md000066400000000000000000000626541527331160600252270ustar00rootroot00000000000000# Authorization Plugin: authzLinid1 LDAP-based authorization plugin that grants permissions based on the `twakeLocalAdminLink` attribute in organizational units. ## Overview The `authzLinid1` plugin provides dynamic, LDAP-driven access control by reading permissions directly from LDAP organizational units. It supports: - **LDAP-based permissions** - No configuration files needed, permissions stored in LDAP - **Organization-centric** - Users listed in `twakeLocalAdminLink` manage the organization - **Full CRUD rights** - Local admins get read, write, and delete permissions - **Sub-branch inheritance** - Permissions apply recursively to sub-branches - **Cross-branch access** - Users/groups can be managed if their `twakeDepartmentLink` points to an authorized organization - **Permission caching** - Configurable TTL reduces LDAP queries (default: 5 minutes) ## How It Works ### twakeLocalAdminLink Attribute Organizations in LDAP can have a `twakeLocalAdminLink` attribute listing user DNs who manage that organization: ```ldif dn: ou=HR,ou=organization,dc=example,dc=com objectClass: organizationalUnit ou: HR twakeLocalAdminLink: uid=hr-admin,ou=users,dc=example,dc=com twakeLocalAdminLink: uid=hr-manager,ou=users,dc=example,dc=com ``` Users listed in `twakeLocalAdminLink` automatically get full permissions (read, write, delete) for: - The organization itself (`ou=HR,ou=organization,dc=example,dc=com`) - All sub-organizations (`ou=Payroll,ou=HR,ou=organization,dc=example,dc=com`) - All users/groups with `twakeDepartmentLink` pointing to this organization ### Cross-Branch Access Users and groups can exist in different LDAP branches but belong to specific organizations via `twakeDepartmentLink`: ```ldif # User in global users branch dn: uid=john,ou=users,dc=example,dc=com objectClass: twakeAccount uid: john cn: John Doe twakeDepartmentLink: ou=HR,ou=organization,dc=example,dc=com ``` The HR admin (listed in `ou=HR`'s `twakeLocalAdminLink`) can manage John even though he's in `ou=users` because his `twakeDepartmentLink` points to the HR organization. ### What an administrator reads `authzLinid1` always judges an attached entry this way, as `--authz-filter-attached-entries` does for `authzPerBranch`, whatever that option says: judged by its parent instead, an account in a flat `ou=users` belongs to nobody's branch, and no administrator could list or change one. - Listings of accounts and groups drop the entries attached to an organization outside the caller's branches. A single read of one answers `404`. - An organization is judged by the branch it sits in. The tree under the top organization carries every branch's name, description and local administrators, so a listing returns the caller's branches, and a direct read of another branch answers `404`. `organizations/top` still answers one of their branches: see [getOrganisationTop Hook](#getorganisationtop-hook). - Entries attached to nothing — a nomenclature value, a group or an account with no `twakeDepartmentLink` — are readable by every administrator: reference data. Writes to them are judged on their parent. The transit branch's own entry stays readable too: the console opens its card from the scope banner. - A caller named in no `twakeLocalAdminLink` is refused every read (`403`) but the top organization's own entry, which `organizations/top` answers. ### Transit: handing an entry over An administrator can only move an entry between organizations they write, and an account created by another tool often carries no `twakeDepartmentLink` at all. The transit branch covers both cases: `--authz-transit-branch` names an organization through which entries change hands. ```bash --authz-transit-branch "ou=Transit,ou=organization,dc=example,dc=com" ``` An entry is **in transit** when it is attached to that organization, or to none (with or without the option). An entry carrying no link is in transit on purpose: it is how the entries other tools insert without one are caught. Nothing ties such an entry to an organization: any administrator may claim it into one they manage, and the first to claim it takes it. - Every administrator sees the entries in transit, but in a global branch (`--authz-global-branch`), which only its holders read. - **Claiming** one (`POST /api/v1/ldap/users/:id/move` towards an organization) asks for write on that organization only. The write claiming it may carry other changes to the entry — its password, say — judged with it: the entry lands in the organization that write is allowed in. - **Putting** one in transit asks for write on the organization it leaves, and nothing on the transit branch: an administrator only lets go of what they manage. Handing an account over to an organization one does not manage is two steps: put it in transit, then its new administrator claims it. - Any other change to an entry in transit, not claiming it (attributes, password, status, deletion), is judged as before: on the transit branch for an entry attached to it, on its parent branch for one attached to nothing. `GET /api/v1/authz/scope` returns the transit branch as `transit` (`null` when there is none), so that a client can offer these moves. A group attached to the transit branch follows the same rules through `POST /api/v1/ldap/groups/:cn/move`; that endpoint does not move a group attached to no organization. #### Where an entry comes from Once in transit, an entry no longer says which organization it left, and the administrator who would claim it cannot tell whether it is theirs. Name an attribute to keep it in; `twakeTransitOrigin` below is an example, which the directory's schema must define (see the rules after it): ```bash --authz-transit-origin-attribute twakeTransitOrigin ``` Putting an entry in transit, by `move` or by any modify of its `twakeDepartmentLink`, then sets that attribute to the DN of the organization it leaves; claiming it removes the attribute. A move from transit to transit keeps it, and an entry that was attached to no organization gets none. A modify that sets or removes the attribute itself is left as it is. The name is compared as LDAP does, whatever its case. Without a transit branch, the option does nothing. The attribute is the directory's to allow, and its rules apply to the whole move: what it refuses, it refuses the move with. - **A dedicated attribute.** The origin is written with a `replace`: every value the attribute held is overwritten. A business attribute such as `departmentNumber`, which may hold several real values, would lose them. The Twake schema has no attribute for this: add one to the directory's schema. - **Optional.** Claiming the entry removes the attribute, so it must be among the `MAY` of its object classes, never a `MUST`. It must be allowed on every entry that can be handed over, accounts and groups alike. - **Writable.** The directory's ACLs must let ldap-rest's bind write it. Without the option, nothing is recorded. ### Mail domains The mail domains an organization's entries may use are those it links in `twakeDomainLink` and those every organization above it links (`ldap/enterpriseRules`). Creating or changing an organization only asks for write on its parent, so the link itself is held to more: - a domain newly linked to an organization must already be linked by an organization above it, up to the top organization; - a caller holding write on the top organization, or on `--authz-global-organization`, links any domain: they are the one giving each organization its own. The second matters where the top organization is a bare container nobody administers, the administrators of the whole directory holding an organization under it; - a domain the organization already links can be kept, and removing one is judged as any other change. So the administrator of `ou=Sales` can create `ou=Sales-EU` restricted to Sales' domain, but cannot give it another customer's. The attribute is the organization schema's `domainLink` role, or `--enterprise-domain-link-attribute`; with neither, nothing is checked. The rule belongs to the base of the authorization plugins, so `authzPerBranch` applies it too, the top organization alone granting there. `GET /authz/scope` answers `linksAnyDomain: true` to a caller linking any domain, so that a form offers them every domain and the others only those the organizations above inherit. Like the rest of the scope, it is the answer of the plugin describing it (`source`, see `--authz-scope-source`): where `authzPerBranch` judges the same caller too, it may still refuse the link, granting it on the top organization only. ### Global branches What is stored outside the organization tree is reference data: every administrator reads it, and nobody writes it, since nobody administers its parent. `--authz-global-branches` names branches held instead by the administrators of one organization, `--authz-global-organization`, the top organization by default, and of the organizations above it: ```bash --authz-global-branches "ou=ppolicies,dc=example,dc=com" \ --authz-global-organization "ou=Headquarters,ou=organization,dc=example,dc=com" ``` They read, write and delete there; any other administrator is refused, even a search started above the branch leaves its entries out. That is how only the administrators of the whole directory manage the [password policies](../ldap/password-policy.md#managing-policies). A global branch is no branch of theirs in `GET /authz/scope`, so it adds nothing to the organizations they are offered; `passwordPolicies` there says whether they may list the policies. `--authz-global-write-branches` names branches whose writes and deletes are held the same way, while reading them stays as for the rest of the reference data: every administrator reads there. Password policies are for the administrators of the whole directory alone, and are a global branch; the [mail domains](../integrations/james-mail.md#mail-domains) are read by every administrator, whose forms offer them, and only added or deleted by those of the whole directory, so they are a global write branch: ```bash --authz-global-branches "ou=ppolicies,dc=example,dc=com" \ --authz-global-write-branches "ou=domains,ou=nomenclature,dc=example,dc=com" ``` There, the administrators of `--authz-global-organization` and above add, change, rename and delete, at any depth, and `GET /authz/scope` offers them to create the entity whose base it is; any other administrator reads, lists and searches, and is refused every write with `403`. Taking an entry out of the branch, by a move or a rename, asks for the delete permission there, not the read every administrator holds: a local administrator cannot move a domain into their own organization and delete it from there. Several branches are separated by `;`. The administrators of `--authz-global-organization` also give the organizations their domains: they link any domain, as those of the top organization do ([Mail domains](#mail-domains)), so that the ones who create a domain can hand it to an organization, the top organization being a bare container or not. `--authz-global-organization` must lie outside the global branches and the global write branches: nobody would administer it otherwise. A global branch and a global write branch may not be the same, nor lie one in the other: the global branch would hide the write branch it holds from the administrators meant to read it. In either case the server refuses to start. ## Configuration No configuration file needed - permissions are read from LDAP. Simply load the plugin: ```bash --plugin core/auth/authzLinid1 ``` **Optional Cache Configuration:** The plugin caches permissions for 5 minutes by default. This is hardcoded but can be modified in the source if needed. ### The identity has to be a `uid` The plugin resolves `req.user` by searching `(=)` under `--ldap-base`, so it only works behind an authenticator that publishes what the directory calls a user: | Authenticator | `req.user` | Resolves here | | ------------------------- | -------------------------------- | -------------------------------------- | | `core/auth/token` | the name given in `--auth-token` | only if that name is a `uid` | | `core/auth/totp` | the name given in `--auth-totp` | only if that name is a `uid` | | `core/auth/hmac` | the service name | usually not | | `core/auth/llng` | LLNG's `whatToTrace` | when it traces the `uid` | | `core/auth/openidconnect` | the OIDC `sub` | **no** — an opaque provider identifier | An identity that does not resolve is **refused** (403), and the refusal is logged with the plugin's name; `--authz-unresolved-user` sets it otherwise ([notes](../../upgrading.md#an-identity-that-does-not-resolve-is-refused)). An identity naming **several** entries is refused (403) whatever that option says, and a warning gives the number of entries. A lookup the directory fails fails the request and is not cached. Both answers are cached for the plugin's TTL (5 minutes), negatives included, so a mismatch does not turn every request into a directory search. The TTL cuts both ways: an identity added to the directory after being refused waits it out, and an administrator whose **own entry is renamed or moved** keeps resolving to the former DN — where no organization names them, so their own operations are refused. A rename, a move to the trash or a delete performed through this server drops what was resolved; a change made directly in the directory waits out the TTL. `GET /api/v1/authz/scope` resolves the identity outside that cache, so during such a window it can describe a scope the hooks no longer grant. ## Permission Resolution For each LDAP operation, the plugin: 1. **Resolves user DN** - Converts username to LDAP DN 2. **Searches for permissions** - Finds all organizations where user is in `twakeLocalAdminLink` 3. **Caches results** - Stores permissions for 5 minutes 4. **Checks branch access** - Verifies if requested branch matches authorized organizations 5. **Checks link attributes** - For entries with `twakeDepartmentLink`, checks if link points to authorized org ### Permission Inheritance Permissions apply to the organization and all descendants: ``` ou=organization,dc=example,dc=com ├── ou=Main Unit [admin1 has twakeLocalAdminLink here] │ ├── ou=Sub Unit 1 [admin1 can access] │ │ └── ou=Department1 [admin1 can access] │ └── ou=Sub Unit 2 [admin1 can access] └── ou=Private [admin1 cannot access] ``` ### Cross-Branch Examples **Scenario 1: User in authorized branch** ``` Organization: ou=HR,ou=organization,dc=example,dc=com twakeLocalAdminLink: uid=admin,ou=users,dc=example,dc=com User: uid=john,ou=users,ou=HR,ou=organization,dc=example,dc=com Result: admin can manage john (user is in HR branch) ``` **Scenario 2: User with department link** ``` Organization: ou=HR,ou=organization,dc=example,dc=com twakeLocalAdminLink: uid=admin,ou=users,dc=example,dc=com User: uid=john,ou=users,dc=example,dc=com twakeDepartmentLink: ou=HR,ou=organization,dc=example,dc=com Result: admin can manage john (twakeDepartmentLink points to HR) ``` **Scenario 3: User without link in wrong branch** ``` Organization: ou=HR,ou=organization,dc=example,dc=com twakeLocalAdminLink: uid=admin,ou=users,dc=example,dc=com User: uid=john,ou=users,dc=example,dc=com (no twakeDepartmentLink) Result: admin cannot manage john (not in HR branch, no link) ``` ## Hooks ### ldapaddrequest Hook Intercepts all LDAP add operations: 1. Checks if request has authenticated user 2. Resolves user's DN from username 3. Determines which branch to check: - If entry has `twakeDepartmentLink`, use that organization - Otherwise, use parent DN of the entry being added 4. Verifies user has write permission for that branch 5. Throws error if access denied **Special allowances:** - Unauthenticated requests pass through (for internal operations) ### ldapsearchrequest Hook Intercepts all LDAP search operations: 1. Checks if request has authenticated user 2. Resolves user's DN from username 3. Allows base scope searches on top organization (for navigation) 4. Allows searches for `twakeLocalAdminLink` (for permission refresh) 5. Refuses a caller who administers no branch; lets the others search, and drops from the answer the entries attached outside their branches (`ldapsearchfilter`) **Special allowances:** - Base scope on `ldap_top_organization` (e.g., for getOrganisationTop API) - Filters containing `twakeLocalAdminLink` (permission refresh queries) ### getOrganisationTop Hook Modifies the organization tree API: 1. Returns organizations where user is local admin 2. Replaces default top organization with authorized branches 3. Enables UI to show only manageable organizations **Behavior:** - If user manages multiple orgs, returns the first one - If user manages one org, returns that org - If user manages no orgs, returns default top organization ## API Integration ### Organization Tree API The plugin automatically filters the organization tree API: **Global admin (manages top organization):** ``` GET /api/v1/ldap/organizations/top Authorization: Bearer Response: { "dn": "ou=organization,dc=example,dc=com", "ou": "organization" } ``` **Local admin (manages specific organization):** ``` GET /api/v1/ldap/organizations/top Authorization: Bearer Response: { "dn": "ou=HR,ou=organization,dc=example,dc=com", "ou": "HR" } ``` ### Add User Example **Success - User with department link to authorized org:** ``` POST /api/v1/users Authorization: Bearer { "uid": "newuser", "cn": "New User", "twakeDepartmentLink": ["ou=HR,ou=organization,dc=example,dc=com"] } Response: 201 Created ``` **Failure - User with link to unauthorized org:** ``` POST /api/v1/users Authorization: Bearer { "uid": "newuser", "cn": "New User", "twakeDepartmentLink": ["ou=IT,ou=organization,dc=example,dc=com"] } Response: 403 Forbidden { "error": "User hr-admin does not have write permission for branch ou=IT,ou=organization,dc=example,dc=com" } ``` ## Performance ### Permission Caching Permissions are cached to reduce LDAP queries: - **Cache TTL**: 5 minutes (hardcoded) - **Per-user cache**: Each user's permissions cached separately - **Automatic refresh**: Cache expires after TTL - **On-demand refresh**: New permissions fetched on cache miss **Cache structure:** ```javascript { "uid=admin,ou=users,dc=example,dc=com": { branches: Map([ ["ou=HR,ou=organization,dc=example,dc=com", {read: true, write: true, delete: true}], ["ou=Finance,ou=organization,dc=example,dc=com", {read: true, write: true, delete: true}] ]), timestamp: 1696512000000 } } ``` ### LDAP Query Optimization The plugin minimizes LDAP queries: 1. **User DN resolution** - Cached implicitly by permission cache 2. **Permission search** - Single query for all organizations with user in `twakeLocalAdminLink` 3. **No recursive searches** - Uses DN suffix matching for sub-branch checks **Typical query pattern:** ``` User makes request → Check cache → (if expired) Search LDAP once → Cache for 5 min ``` ## LDAP Schema Requirements ### Required Attributes **Organizations:** - `twakeLocalAdminLink` (multi-valued) - DNs of local administrators **Users/Groups:** - `twakeDepartmentLink` (single-valued) - DN of parent organization ### Example Schema ```ldif # Organization with local admins dn: ou=HR,ou=organization,dc=example,dc=com objectClass: organizationalUnit objectClass: twakeOrganization ou: HR twakeLocalAdminLink: uid=hr-admin,ou=users,dc=example,dc=com twakeLocalAdminLink: uid=hr-manager,ou=users,dc=example,dc=com # User with department link dn: uid=employee1,ou=users,dc=example,dc=com objectClass: inetOrgPerson objectClass: twakeAccount uid: employee1 cn: Employee One twakeDepartmentLink: ou=HR,ou=organization,dc=example,dc=com # Group with department link dn: cn=hr-staff,ou=groups,dc=example,dc=com objectClass: groupOfNames objectClass: twakeGroup cn: hr-staff twakeDepartmentLink: ou=HR,ou=organization,dc=example,dc=com member: uid=employee1,ou=users,dc=example,dc=com ``` ## Security Considerations ### LDAP-Driven Security - **Centralized control** - All permissions in LDAP, no config files to sync - **Audit trail** - LDAP modifications can be logged/audited - **Consistency** - Single source of truth for permissions - **Real-time updates** - Changes effective after cache expiry (5 min max) ### Permission Model - **Local admins get full access** - Read, write, and delete - **No granular permissions** - Cannot grant read-only local admin - **Inheritance always applies** - Cannot restrict sub-branches - **Cross-branch by design** - Department links intentionally allow cross-branch access ### Authentication Required This plugin only handles **authorization**. Combine with an authentication plugin: ```bash --plugin core/auth/token \ --plugin core/auth/authzLinid1 \ --auth-token "secret-token" ``` ### Cache Security - **Cache delays permission revocation** - Up to 5 minutes after removing user from `twakeLocalAdminLink` - **Cache per user** - Removing one user doesn't invalidate others - **No cache persistence** - Cache cleared on server restart ## Troubleshooting ### Problem: Access Denied **Symptoms:** ```json { "error": "User jdoe does not have write permission for branch ou=HR,ou=organization,dc=example,dc=com" } ``` **Solutions:** 1. Verify user is in organization's `twakeLocalAdminLink`: ```bash ldapsearch -x -b "ou=organization,dc=example,dc=com" "(twakeLocalAdminLink=uid=jdoe,*)" ``` 2. Check user DN matches exactly (spaces, case, order) 3. Wait up to 5 minutes for cache to expire after adding user 4. Check user is authenticated (verify `req.user` is set) ### Problem: Cannot Manage User in Different Branch **Symptoms:** User exists in `ou=users,dc=example,dc=com` but local admin cannot manage them. **Solutions:** 1. Add `twakeDepartmentLink` to user: ```ldif dn: uid=user,ou=users,dc=example,dc=com changetype: modify add: twakeDepartmentLink twakeDepartmentLink: ou=HR,ou=organization,dc=example,dc=com ``` 2. Or let the local admin claim it: an account with no `twakeDepartmentLink` is in transit, see [Transit](#transit-handing-an-entry-over). 3. Or move user to organization branch: ``` From: uid=user,ou=users,dc=example,dc=com To: uid=user,ou=users,ou=HR,ou=organization,dc=example,dc=com ``` ### Problem: Permissions Not Updating **Symptoms:** Added user to `twakeLocalAdminLink` but they still get access denied. **Solutions:** 1. Wait up to 5 minutes for cache to expire 2. Restart server to clear all caches immediately 3. Verify LDAP modification succeeded: ```bash ldapsearch -x -b "ou=HR,ou=organization,dc=example,dc=com" "(objectClass=*)" twakeLocalAdminLink ``` ### Problem: User Not Found **Symptoms:** ``` User jdoe not found in LDAP ``` **Solutions:** 1. Verify user exists in LDAP: ```bash ldapsearch -x -b "dc=example,dc=com" "(uid=jdoe)" ``` 2. Check `ldap_user_main_attribute` configuration (default: `uid`) 3. Verify authentication plugin sets `req.user` correctly ## Integration Examples ### With Token Authentication ```bash --plugin core/auth/token \ --plugin core/auth/authzLinid1 \ --auth-token "admin-token" \ --ldap-top-organization "ou=organization,dc=example,dc=com" ``` ### With OpenID Connect ```bash --plugin core/auth/openidconnect \ --plugin core/auth/authzLinid1 \ --oidc-server "https://auth.example.com" \ --oidc-client-id "ldap-rest" \ --oidc-client-secret "secret" \ --base-url "https://api.example.com" \ --ldap-top-organization "ou=organization,dc=example,dc=com" ``` ### With LemonLDAP::NG ```bash --plugin core/auth/llng \ --plugin core/auth/authzLinid1 \ --llng-ini /etc/lemonldap-ng/lemonldap-ng.ini \ --ldap-top-organization "ou=organization,dc=example,dc=com" ``` ## Comparison with authzPerBranch | Feature | authzLinid1 | authzPerBranch | | ----------------- | ---------------------------------- | -------------------------------- | | Configuration | LDAP-based | Config file/env | | Permission source | `twakeLocalAdminLink` attribute | JSON configuration | | Granularity | Organization-level | Per-branch, per-user/group | | Read-only access | No (always full access) | Yes (separate read/write/delete) | | Cross-branch | Yes (via `twakeDepartmentLink`) | Yes (if configured) | | Dynamic updates | Yes (via LDAP) | No (requires restart) | | Cache | 5 min (hardcoded) | Configurable TTL | | Use case | Organization-based delegated admin | Fine-grained access control | **When to use authzLinid1:** - Organizations manage themselves - LDAP is source of truth - Full admin access is acceptable - Department-based delegation **When to use authzPerBranch:** - Need read-only access - Complex permission matrix - Config-driven permissions - Different rights per branch ## See Also - [authzPerBranch Plugin](authzPerBranch.md) - Configuration-based authorization - [Authentication Plugins](authentication.md) - Setup authentication - [LDAP Organizations](ldapOrganizations.md) - Organization tree management - [LDAP Users](ldapFlatGeneric.md) - User management - [LDAP Groups](ldapGroups.md) - Group management linagora-ldap-rest-a4bb78e/docs/usage/plugins/auth/authz-per-branch.md000066400000000000000000000407371527331160600260660ustar00rootroot00000000000000# Authorization Plugin: authzPerBranch Branch-level authorization plugin that restricts LDAP access based on user and group permissions. ## Overview The `authzPerBranch` plugin provides fine-grained access control by restricting which LDAP branches users can access. It supports: - **Default permissions** - Applied when no specific rules match - **User-based permissions** - Per-user branch access rules - **Group-based permissions** - Permissions inherited from group memberships - **Separate rights** - Read, write, and delete permissions - **Sub-branch inheritance** - Permissions apply recursively to sub-branches - **Group caching** - Configurable TTL for group membership resolution ## Configuration ```bash --plugin core/auth/authzPerBranch \ --authz-per-branch-config '{ "default": { "read": true, "write": false, "delete": false }, "users": { "admin": { "ou=users,dc=example,dc=com": { "read": true, "write": true, "delete": true } } }, "groups": { "cn=managers,ou=groups,dc=example,dc=com": { "ou=organization,dc=example,dc=com": { "read": true, "write": true, "delete": false } } } }' \ --authz-per-branch-cache-ttl 60 ``` **Environment Variables:** ```bash DM_AUTHZ_PER_BRANCH_CONFIG='{"default":{"read":true,"write":false,"delete":false},...}' DM_AUTHZ_PER_BRANCH_CACHE_TTL=60 ``` ## Configuration Structure ### Default Permissions Applied when no user or group-specific rules match: ```json { "default": { "read": true, "write": false, "delete": false } } ``` ### User Permissions Branch-specific permissions for individual users: ```json { "users": { "username": { "ou=branch,dc=example,dc=com": { "read": true, "write": true, "delete": false } } } } ``` ### Group Permissions Permissions applied to all group members: ```json { "groups": { "cn=groupname,ou=groups,dc=example,dc=com": { "ou=branch,dc=example,dc=com": { "read": true, "write": true, "delete": false } } } } ``` ## Permission Resolution The rules holding the target, the user's own and those of every group they belong to, are combined: 1. **Within one source** (the user's own rules, or one group's), the rule of the **most specific** branch holding the target applies, whatever order the rules are written in, broader rules of that source aside. Branches are compared as DNs, whatever their case and spacing. 2. **Between sources**, for each permission type (read/write/delete), the **most permissive** wins. 3. **Default permissions** apply to a permission none of these rules sets. They are a fallback, not a floor: `"default": {"read": true}` does not undo a rule refusing read. Creating or writing an entry a rule names also asks for that rule's permission, and moving or deleting one holding such an entry asks for read and write, or delete, at every rule below it: a caller granted `ou=organization` cannot rename `ou=payroll` from under the rule refusing it, nor change or delete the `ou=payroll` entry itself. This holds under `--authz-filter-attached-entries` too, where `departmentSync` would relink the accounts attached there. A write setting the organization link (`--ldap-organization-link-attribute`) is judged on the organizations it leaves and joins; anything else it carries still needs write where the entry stands: its branch, or, judged by attachment, the organization it leaves. A rule's branch matches a DN however it is spelled, as OpenLDAP reads it: case, spaces, escapes (`\2C` and `\,`, `\20` and a space, `\C3\A9` and `é`), an attribute type by any of its names or its OID (`organizationalUnitName=` and `2.5.4.11=` are `ou=`, learned from the directory schema at startup), the spaces its matching rules ignore, and the LDAPv2 forms (`;` between RDNs, a value in double quotes). A write is judged on the entry as the directory names it, read as the server before the write. A caller cannot step around a rule by spelling its branch another way. A move is judged on the mover's own rules. The rules of other users and groups name the branch by its DN: once a caller allowed to move it has done so, they no longer apply to it, and need rewriting for the new DN. ## How It Works ### LDAP Search Hook Intercepts all LDAP search operations: 1. Checks if request has authenticated user (`req.user`) 2. Resolves user's permissions for the requested branch 3. Verifies user has `read` permission 4. Throws error if access denied ### LDAP Add Hook Intercepts all LDAP add operations: 1. Checks if request has authenticated user 2. Resolves user's permissions for the target branch 3. Verifies user has `write` permission 4. Throws error if access denied ### LDAP Modify Hook Intercepts all LDAP modify operations: 1. For regular modifications: checks `write` permission on the entry's branch 2. For **move operations** (changing organization link): - Verifies user has `read` permission on source branch - Verifies user has `write` permission on destination branch - Throws error if either permission is missing ### LDAP Delete Hook Intercepts all LDAP delete operations: 1. Checks if request has authenticated user 2. Resolves user's permissions for the entry's branch 3. Verifies user has `delete` permission 4. Throws error if access denied ### LDAP Rename Hook Intercepts all LDAP rename/modifyDN operations (for organization moves): 1. Extracts source and destination branches from DNs 2. Verifies user has `read` permission on source branch 3. Verifies user has `write` permission on destination branch 4. Throws error if either permission is missing ### Organization Top Hook Modifies the organization tree API: 1. Returns only branches the user can read 2. Replaces default top organization with authorized branches 3. Enables UI to show only accessible parts of the tree ### Group Resolution Groups are resolved dynamically: 1. Resolves the caller to its DN, searching `(=)` under the LDAP base (`--ldap-base`) 2. Searches for all groups where that DN is a member 3. Matches each group found against the `groups` keys **as DNs**: case and the spaces around `,` and `=` do not matter, so `CN=Editors, OU=Groups,dc=example,dc=com` names the same group as `cn=editors,ou=groups,dc=example,dc=com` 4. Caches results (see [Group Caching](#group-caching)); concurrent requests for the same user share one lookup A uid that names **more than one** entry gets **no group rule**, since its groups cannot be told apart from its homonym's; a warning is logged, and the uid's own `users` rule still applies. A lookup that fails answers `500` and is not cached. ## Sub-branch Inheritance Permissions apply recursively to all sub-branches: ```json { "users": { "manager": { "ou=organization,dc=example,dc=com": { "read": true } } } } ``` With this config, user `manager` can read: - `ou=organization,dc=example,dc=com` - `ou=dept1,ou=organization,dc=example,dc=com` - `ou=team1,ou=dept1,ou=organization,dc=example,dc=com` - ... all descendants A rule on a sub-branch takes over below it. To keep `manager` out of one department: ```json { "users": { "manager": { "ou=organization,dc=example,dc=com": { "read": true }, "ou=payroll,ou=organization,dc=example,dc=com": { "read": false } } } } ``` A search granted on `ou=organization` then returns nothing from `ou=payroll`: each entry it finds is kept only where the caller may read it. A group the user belongs to may still grant the department, as the most permissive source wins. ## Examples ### Example 1: Department-Based Access Allow users to access only their department: ```json { "default": { "read": false, "write": false, "delete": false }, "users": { "hr_user": { "ou=HR,ou=organization,dc=example,dc=com": { "read": true, "write": true, "delete": false } }, "it_user": { "ou=IT,ou=organization,dc=example,dc=com": { "read": true, "write": true, "delete": false } } } } ``` ### Example 2: Group-Based Access Use groups for team permissions: ```json { "default": { "read": true, "write": false, "delete": false }, "groups": { "cn=editors,ou=groups,dc=example,dc=com": { "ou=content,dc=example,dc=com": { "read": true, "write": true, "delete": false } }, "cn=admins,ou=groups,dc=example,dc=com": { "dc=example,dc=com": { "read": true, "write": true, "delete": true } } } } ``` ### Example 3: Read-Only by Default Restrict write access to specific users: ```json { "default": { "read": true, "write": false, "delete": false }, "users": { "admin": { "dc=example,dc=com": { "read": true, "write": true, "delete": true } } } } ``` ### Example 4: Multiple Branches per User Grant access to multiple branches: ```json { "users": { "coordinator": { "ou=HR,ou=organization,dc=example,dc=com": { "read": true, "write": true, "delete": false }, "ou=Finance,ou=organization,dc=example,dc=com": { "read": true, "write": false, "delete": false } } } } ``` ### Example 5: Move Operations Authorization For move operations (groups or organizations), the user needs **both** read permission on the source and write permission on the destination: ```json { "users": { "manager": { "ou=DeptA,ou=organization,dc=example,dc=com": { "read": true, "write": false, "delete": false }, "ou=DeptB,ou=organization,dc=example,dc=com": { "read": true, "write": true, "delete": false } } } } ``` With this configuration: - ✅ User `manager` **CAN** move items from DeptA to DeptB (has read on source, write on destination) - ❌ User `manager` **CANNOT** move items from DeptB to DeptA (has write on source but no write on destination) - ❌ User `manager` **CANNOT** move items from DeptC to DeptB (no read permission on source) ## API Integration ### Organization Tree API The plugin automatically filters the organization tree API (`/api/v1/ldap/organizations/top`): **Without authzPerBranch:** ```json { "dn": "ou=organization,dc=example,dc=com", "ou": "organization" } ``` **With authzPerBranch (user has access to specific branches):** ```json [ { "dn": "ou=HR,ou=organization,dc=example,dc=com", "ou": "HR" }, { "dn": "ou=IT,ou=organization,dc=example,dc=com", "ou": "IT" } ] ``` ## Performance ### Group Caching Group memberships are cached to reduce LDAP queries: - **Default TTL**: 60 seconds - **Configurable**: `--authz-per-branch-cache-ttl` - **Per-user cache**: Each user's groups cached separately - **Automatic expiry**: Cache entries expire after TTL - **Dropped on writes**: any add, delete, rename or move made through ldap-rest empties the cache, and so does a modify touching the group member attribute or `--ldap-user-main-attribute`. A change made directly in the directory waits for the TTL Cache hit/miss logging: ``` [debug] Group cache hit for user: jdoe [debug] Group cache miss for user: jsmith - querying LDAP ``` ### Optimization Tips 1. **Increase cache TTL** for stable group memberships 2. **Use group permissions** instead of per-user rules 3. **Keep permission config small** - avoid hundreds of rules 4. **Structure LDAP efficiently** - shallow hierarchies perform better ## Security Considerations ### Permission Inheritance - Sub-branch rules take over from parent branch rules, for what they set - A rule may restrict a sub-branch as well as widen it, but another source (a group) granting more still wins - Plan branch structure carefully ### Default Permissions - **Secure by default**: Set `read: false` for sensitive directories - **Open by default**: Set `read: true` for general access - Choose based on your security posture `--authz-per-branch-config` is never empty: unset, it is `{"default":{"read":true,"write":false,"delete":false}}`, and it applies to **every** authenticated identity, whatever authenticated it. Loaded beside a token plugin, `authzDynamic` or an identity provider whose callers no rule names, this plugin lets them read and refuses every write. Give it an `authz_for` naming the authentication plugins it was written for — see [several authorization plugins](README.md#several-authorization-plugins). ### Group-Based Security - Group DNs must be fully qualified - Group resolution uses LDAP search - ensure proper indexing ### Authentication Required This plugin only handles **authorization**. Combine with an authentication plugin: ```bash --plugin core/auth/token \ --plugin core/auth/authzPerBranch \ --auth-token "secret-token" ``` ## Troubleshooting ### Problem: Access Denied **Symptoms:** ```json { "error": "User jdoe does not have read permission for branch ou=users,dc=example,dc=com" } ``` **Solutions:** 1. Check user has permission for requested branch: ```json { "users": { "jdoe": { "ou=users,dc=example,dc=com": { "read": true } } } } ``` 2. Verify user is authenticated (check `req.user`) 3. Check default permissions allow access 4. Verify group membership if using group permissions ### Problem: Move Operation Failed **Symptoms:** ```json { "error": "Internal Server Error", "message": "An error occurred" } ``` With logs showing: ``` User jdoe does not have read permission for source branch ou=DeptA,dc=example,dc=com ``` or ``` User jdoe does not have write permission for destination branch ou=DeptB,dc=example,dc=com ``` **Solutions:** 1. Verify user has **read** permission on the source branch 2. Verify user has **write** permission on the destination branch 3. Move operations require **both** permissions - having write on source is not sufficient Example fix: ```json { "users": { "jdoe": { "ou=DeptA,dc=example,dc=com": { "read": true, "write": false }, "ou=DeptB,dc=example,dc=com": { "read": true, "write": true } } } } ``` ### Problem: No Branches Returned **Symptoms:** Organization tree API returns empty array or no data. **Solutions:** 1. Check user has `read` permission for at least one branch 2. Verify DN syntax matches exactly (including spaces, case) 3. Check LDAP base configuration matches permission DNs ### Problem: Permissions Not Updating **Symptoms:** Changed permissions don't take effect immediately. **Solutions:** 1. A membership changed directly in the directory waits for the [group cache](#group-caching) TTL 2. Restart server to clear all caches 3. Reduce `--authz-per-branch-cache-ttl` for testing ### Problem: Group Permissions Not Working **Symptoms:** User should inherit group permissions but doesn't. **Solutions:** 1. Verify the user's DN is a member of the group: ```bash ldapsearch -x -b "dc=example,dc=com" \ "(member=uid=jdoe,ou=users,dc=example,dc=com)" ``` 2. Check the group DN in config names the group: it is compared as a DN (case and spaces are ignored), but every RDN must be there 3. Check the uid names a single entry: ```bash ldapsearch -x -b "dc=example,dc=com" "(uid=jdoe)" dn ``` 4. Ensure group membership uses correct attribute (default: `member`) 5. Configure custom member attribute if needed: ```bash --ldap-group-member-attribute uniqueMember ``` ## Integration Examples ### With Token Authentication ```bash --plugin core/auth/token \ --plugin core/auth/authzPerBranch \ --auth-token "admin-token" \ --authz-per-branch-config '{ "default": {"read": false, "write": false, "delete": false} }' ``` ### With OpenID Connect ```bash --plugin core/auth/openidconnect \ --plugin core/auth/authzPerBranch \ --oidc-server "https://auth.example.com" \ --oidc-client-id "ldap-rest" \ --oidc-client-secret "secret" \ --base-url "https://api.example.com" \ --authz-per-branch-config '{ "users": { "user@example.com": { "ou=organization,dc=example,dc=com": {"read": true, "write": true} } } }' ``` ### With LemonLDAP::NG ```bash --plugin core/auth/llng \ --plugin core/auth/authzPerBranch \ --llng-ini /etc/lemonldap-ng/lemonldap-ng.ini \ --authz-per-branch-config '{"default": {"read": true}}' ``` ## See Also - [Authentication Plugins](authentication.md) - Setup authentication - [LDAP Organizations](ldapOrganizations.md) - Organization tree management - [LDAP Groups](ldapGroups.md) - Group management linagora-ldap-rest-a4bb78e/docs/usage/plugins/auth/authz-per-route.md000066400000000000000000000143721527331160600257630ustar00rootroot00000000000000# Route-Level Authorization (`authzPerRoute`) Restricts access to specific HTTP routes based on the authenticated user's name (`req.user`), which is set by an auth plugin such as `core/auth/token`. This plugin complements `authzPerBranch` (LDAP-branch ACL) and `authzDynamic` (LDAP-backed tokens): whereas those operate at the LDAP-branch level, `authzPerRoute` works at the HTTP route level and needs no LDAP connection. ## Plugin loading order `authzPerRoute` MUST run **after** the authentication plugin (so `req.user` is set) AND **before** any API route plugin (so its Express middleware is registered before the routes it protects). When loaded via the CLI plugin loader, plugins not listed in `src/plugins/priority.json` are loaded in parallel — this can race with API route registration and result in an authorization bypass. To guarantee correct ordering, `core/auth/authzPerRoute` is included in the priority list and loads sequentially after the other auth plugins. If you build a custom loader, ensure equivalent sequencing. ```bash --plugin core/auth/token \ --plugin core/auth/authzPerRoute ``` ## Configuration ### CLI ```bash --authz-per-route "full-access:*" \ --authz-per-route "updt-only:POST:/api/v1/ldap/updt" \ --authz-per-route "updt-only:GET:/api/v1/ldap/updt/**" ``` ### Environment Variable ```bash DM_AUTHZ_PER_ROUTES="full-access:*,updt-only:POST:/api/v1/ldap/updt,updt-only:GET:/api/v1/ldap/updt/**" ``` Multiple values are comma-separated. Repeating `--authz-per-route` on the CLI is equivalent. ## Rule Syntax Each entry has one of two forms: | Form | Meaning | | ---------------------------- | ----------------------------------------------------------- | | `:*` | Full wildcard — allow any method on any path | | `::` | Allow only the given HTTP method on paths matching the glob | - `` — the value of `req.user` as set by the auth plugin. - `` — an HTTP verb (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`) or `*` (any verb). - `` — a glob pattern (not a regex). Patterns are implicitly anchored start-to-end. Rules: - `*` matches any sequence of characters **except** `/` (one path segment). - `**` matches any sequence of characters **including** `/` (multiple segments). - All other characters are matched literally — regex special characters (`.`, `+`, `?`, etc.) have no special meaning. - **Allowed characters:** alphanumerics (`a-z`, `A-Z`, `0-9`), `/`, `_`, `-`, `.`, `+`, and `*`. Globs containing any other character (e.g. `;`, space, `?`) are rejected at startup with a warning and the rule is skipped. A user may have multiple entries; access is granted if **any** rule matches (logical OR). ### Examples ```bash # admin token: full access full-access:* # read-only token: GET only on exactly /api/hello reader:GET:/api/hello # update token: POST on exact path, GET on that path and all sub-paths updt:POST:/api/v1/ldap/updt updt:GET:/api/v1/ldap/updt** # any method on a path and all sub-paths any-method:*:/api/v1/ldap/users** # match one level deep only (not /api/v1/ldap/users/uid=bob/sub) one-level:GET:/api/v1/ldap/users/* ``` ### Glob pattern quick reference | Pattern | Matches | Does NOT match | | ---------------- | ----------------------------------------------------- | ----------------------------------- | | `/api/hello` | `/api/hello` | `/api/hello/sub` | | `/api/hello**` | `/api/hello`, `/api/hello/sub`, `/api/hello/sub/deep` | `/api/hell` | | `/api/hello/**` | `/api/hello/sub`, `/api/hello/sub/deep` | `/api/hello` | | `/api/hello/*` | `/api/hello/sub` | `/api/hello`, `/api/hello/sub/deep` | | `/api/hello.bak` | `/api/hello.bak` | `/api/helloXbak` (`.` is literal) | ## Behavior | Situation | Result | | ----------------------------------------------------- | ------------------------------------------------------ | | `req.user` is unset (no auth plugin ran) | Pass through (`next()`) — let upstream auth return 401 | | User authenticated but has no rules configured | 403 Forbidden | | User has at least one matching rule | 200 / pass through | | User has rules but none match the current method+path | 403 Forbidden | | Authenticated by a plugin outside `authz_for` | Pass through — another population, judged elsewhere | `authz_for` lists the authentication plugins, by instance name, whose requests the rules are for; unset, they apply to every authenticated request. See [several authorization plugins](README.md#several-authorization-plugins). ## Full Example ```bash DM_AUTH_TOKENS="tok-admin:admin,tok-ro:reader" DM_AUTHZ_PER_ROUTES="admin:*,reader:GET:/api/v1/ldap/users**,reader:GET:/api/v1/ldap/groups**" npx ldap-rest \ --plugin core/auth/token \ --plugin core/auth/authzPerRoute \ --plugin core/ldap/users \ --plugin core/ldap/groups \ --ldap-url ldap://localhost:389 \ ... ``` ```bash # admin: any request allowed curl -H "Authorization: Bearer tok-admin" http://api/v1/ldap/users curl -H "Authorization: Bearer tok-admin" -X DELETE http://api/v1/ldap/users/uid=bob,ou=users,dc=example,dc=org # reader: GET allowed curl -H "Authorization: Bearer tok-ro" http://api/v1/ldap/users # 200 # reader: write denied curl -H "Authorization: Bearer tok-ro" -X POST http://api/v1/ldap/users # 403 ``` ## Difference vs. Other Authz Plugins | Plugin | Scope | Requires LDAP | | ---------------- | ------------------------------ | ------------------ | | `authzPerRoute` | HTTP method + path | No | | `authzPerBranch` | LDAP branch read/write/delete | No (static config) | | `authzDynamic` | LDAP branch ACL stored in LDAP | Yes | linagora-ldap-rest-a4bb78e/docs/usage/plugins/auth/authz-scope.md000066400000000000000000000142341527331160600251470ustar00rootroot00000000000000# Authorization Scope Plugin Tells a client what the signed-in administrator may actually do, before it offers them a button that will fail. In a local-administration model the scope _is_ the interface: a manager administers a few branches of the tree and nothing else, and an application that cannot name those branches leaves them guessing. This endpoint answers both halves — which branches, and which entities can be created — by asking whichever authorization plugin is in force. ## Configuration ```bash --plugin core/auth/authzScope ``` It answers through a loaded plugin that carries the `authz` role _and_ can say who may do what where — one implementing `resolveUser`, `getAuthorizedBranches` and `getUserPermissions`. Both [authz-linid1](authz-linid1.md) and [authz-per-branch](authz-per-branch.md) do, and this endpoint works with either without knowing which. | CLI | Env | Default | Description | | ---------------------- | ----------------------- | ------- | ------------------------------------------------------------- | | `--authz-scope-source` | `DM_AUTHZ_SCOPE_SOURCE` | | The plugin, by instance name, that describes a caller's scope | ### Which plugin answers The caller is resolved under the name the hooks key on — `req.user`, or `req.userName` with `--authz-identity req.userName` — so the scope described is the one enforced. Only a plugin that judges the caller answers for them: one scoped with `authz_for` to other authentication plugins is not this caller's model, and is passed over (see [several authorization plugins](README.md#several-authorization-plugins)). When more than one judges the caller — two branch-level plugins combined with `--authz-combine` — the hooks apply both and this endpoint can describe only one. `--authz-scope-source` names it. Without it, the first loaded answers, and for plugins outside the priority list that is import order, which nobody chose: the server says so at startup, and the answer lists every plugin judging the caller under `sources`, the one it comes from under `source`. A name that is not a loaded plugin able to describe a scope is refused at startup. ### When nothing can describe the scope [authz-per-route](authz-per-route.md) gates URLs and [authz-dynamic](authz-dynamic.md) reads a token: both carry the `authz` role, neither resolves a user or a branch. When they judge the caller and no branch-level plugin does, no model can say what the caller may do, and the answer says that rather than guessing: ```json { "user": "alice", "unrestricted": false, "described": false, "source": null, "sources": ["authzPerRoute"], "branches": [], "entities": [] } ``` This endpoint used to answer `unrestricted: true` with `create: true` on every entity there — an authorization judgement handed to a client that acts on it, while the route or the token refused. A client that reads an entity missing from `entities` as not creatable offers nothing. ## Endpoint ``` GET /api/v1/authz/scope ``` ```json { "user": "uid=alice,ou=users,dc=example,dc=com", "unrestricted": false, "described": true, "source": "authzLinid1", "sources": ["authzLinid1"], "branches": [ { "dn": "ou=Sales,ou=organization,dc=example,dc=com", "name": "Sales", "path": "Acme / Sales", "read": true, "write": true, "delete": true } ], "entities": [ { "name": "users", "base": "ou=users,dc=example,dc=com", "create": true }, { "name": "groups", "base": "ou=groups,dc=example,dc=com", "create": true }, { "name": "organizations", "base": "ou=organization,dc=example,dc=com", "create": false } ] } ``` `name` and `path` come from the branch entry itself, so an interface can show the scope in the directory's own words rather than as a raw DN. `create` answers "may I add one of these?", on the rule the add hook enforces. An entry that can carry the organization link (its schema declares the configured link attribute) is scoped by the organization it is attached to, so the answer is yes as soon as the caller may write in a branch they administer. One that cannot — a position, a nomenclature row — is checked against the branch it lands in, so the answer is whether the caller may write there. Organizations are the other exception, since they live in the tree itself and need write permission on the node they hang from — the top of the tree, which is where a new organization goes when the client names no parent. A local administrator of one branch therefore reads `create: false` for organizations, and creates sub-organizations under their own node by naming it as `parentDn`. With no authorization plugin judging the caller the server grants everything, and the answer says so: ```json { "user": "alice", "unrestricted": true, "described": true, "source": null, "sources": [], "branches": [], "entities": [ { "name": "users", "base": "ou=users,dc=example,dc=com", "create": true } ] } ``` ## Errors | Status | When | | ------ | ------------------------------------------------------------------------------------------- | | `401` | No authenticated user while any authorization plugin is loaded, or a user it cannot resolve | An anonymous caller is refused as soon as anything authorizes: every authorization plugin skips a request without an identity, so nothing would judge them and the answer would be `unrestricted: true` — more than an identified caller of the same server is told. A server loading no authorization plugin at all answers `unrestricted` to anyone, anonymous included, which is what it does. An identity that names **several** entries — the same `uid` in two branches of a directory without the `unique` overlay, which `authzLinid1` refuses with `AmbiguousIdentityError` — is answered `403`, not `401`: the caller cannot be told apart from the homonym, so no scope can be described for them. ## See also - [authz-linid1](authz-linid1.md) — the local-administrator model this serves - [authz-per-branch](authz-per-branch.md) — static per-branch permissions linagora-ldap-rest-a4bb78e/docs/usage/plugins/auth/back-channel-logout.md000066400000000000000000000113051527331160600265160ustar00rootroot00000000000000# Back-Channel Logout Honouring a logout the provider performed elsewhere. ## Overview When someone logs out at the OpenID Provider — or an administrator ends their sessions — the provider sends a **logout token** to every application that asked for one. Without it, an application keeps honouring its own cookie: the session stays alive until it expires on its own, which is exactly what a logout is supposed to prevent. `core/bcl` records what such a token killed, and `core/auth/openidconnect` asks, on every request, whether the session it holds is still alive. **Nothing here stores a session.** A logout token only leaves a _tombstone_ saying that one `sid`, or every session of one `sub`, is dead. Sessions stay in their cookie, and the storage holds only what died — which is what keeps it small. ## The Server's Own `/logout` A provider sends logout tokens to the other applications, not to the one that asked it to log out. So `core/auth/openidconnect` records the session its own `/logout` ends, as a logout token naming it would: by the id it gave that session at login, kept in the cookie, which ends that one session and leaves the person's others alone. The same person logging in again over a live session keeps its id, so a copy taken before is ended with it. A session from before that id existed is named by the provider's `sid`, when it sent one. A provider that sends none (Dex) leaves such a session unnamed: its copies keep working until its tokens expire, and the first `/logout` of one logs a warning. That lasts until those sessions have expired, a week at most. Without `core/bcl`, `/logout` drops the browser's cookie and nothing else, and a copy of it keeps working until its tokens expire. A plugin subscribing to `oidclogouttoken` receives `{ iss, session }` from `/logout`, with neither `sid` nor `sub`: `session` names one session of this server, and is matched against the `session` that `oidcsessionvalid` carries. ## Configuration ```bash --plugin core/auth/openidconnect \ --plugin core/storage --storage-backend file --storage-file-directory /var/lib/ldap-rest/storage \ --plugin core/bcl --bcl-retention 604800 ``` | Option | Environment | Default | Description | | ----------------- | ------------------ | -------- | --------------------------- | | `--bcl-retention` | `DM_BCL_RETENTION` | `604800` | Seconds a tombstone is kept | Where the marks live, and how often expired ones are reclaimed, belongs to [`core/storage`](../utilities/storage.md) — `core/bcl` keeps only the policy. At the provider, register the back-channel logout URI: ``` https://ldap-rest.example.com/backchannel-logout ``` ## Retention A tombstone has to outlive the session it kills, or a cookie older than the mark would be honoured again. The default matches `express-openid-connect`'s own seven-day session; shorten it only alongside the session lifetime. A copy of a session `/logout` ended is refused, so no longer renewed: it dies a day after its last use at the latest, the cookie's rolling lifetime. While the store cannot answer, though, a copy is still renewed, up to the week; keep the retention at the session lifetime. Expiry is enforced when the mark is read, not only when the sweeper passes, so a late sweeper never keeps out someone who has since logged in again. ## `core/bcl` Refuses to Start Without Storage `core/storage` must be loaded and `--storage-backend` set, or the plugin throws at startup. That is deliberate. A logout check that quietly does nothing would leave every session outliving the logout that closed it, and nothing would say so. For a feature that can be skipped, a warning is the right answer; for this one, a refusal to start is the honest failure. The refusal answers for the configuration, not for the order it is written in. `core/bcl` declares `core/storage` as a dependency, so a configuration naming only `core/bcl` loads the store anyway; and `core/storage` is in the priority list, so one that names both gets a single instance whichever comes first. ## What a Login Forgets A mark on the `sub` kills every session of that person. Left in place it would also kill the sessions created _after_ it, for the whole retention — so a successful login clears the marks matching its own claims. This has a consequence worth knowing, which the SDK's own default shares: a login also revives sessions that a "log out everywhere" token killed without naming a `sid`. ## Failures Are Not Swallowed If the store cannot record a tombstone, the provider is answered `400` rather than `204`. A provider retries a `400`; a `204` tells it a logout was honoured that nothing kept. ## See Also - [Storage](../utilities/storage.md) — where the marks live - [OpenID Connect](oidc.md) linagora-ldap-rest-a4bb78e/docs/usage/plugins/auth/crowdsec.md000066400000000000000000000042611527331160600245150ustar00rootroot00000000000000# CrowdSec Integration IP reputation and blocking using [CrowdSec](https://crowdsec.net/). ## Configuration ```bash --plugin core/auth/crowdsec \ --crowdsec-url "http://localhost:8080" \ --crowdsec-api-key "your-api-key" ``` **Environment Variables:** ```bash DM_CROWDSEC_URL="http://localhost:8080" DM_CROWDSEC_API_KEY="your-api-key" ``` ## Prerequisites 1. **CrowdSec Installation**: Running CrowdSec instance 2. **API Key**: Bouncer API key from CrowdSec ## How It Works 1. Extracts client IP from request (respects trusted proxy) 2. Queries CrowdSec API for IP reputation 3. Blocks requests from banned IPs with `403 Forbidden` 4. Allows legitimate requests to proceed ## Use Cases - **IP Reputation**: Block known malicious IPs - **Threat Intelligence**: Leverage community-shared threat data - **Dynamic Blocking**: Automatic response to attacks - **Compliance**: Security monitoring and logging ## CrowdSec Setup ### Install CrowdSec ```bash # Debian/Ubuntu curl -s https://packagecloud.io/install/repositories/crowdsec/crowdsec/script.deb.sh | sudo bash sudo apt install crowdsec # Generate bouncer API key sudo cscli bouncers add ldap-rest-bouncer ``` ### Configure LDAP-Rest ```bash npx ldap-rest \ --plugin core/auth/crowdsec \ --crowdsec-url "http://localhost:8080" \ --crowdsec-api-key "generated-api-key" \ ... ``` ## Combining with Other Security Plugins Recommended security stack: ```bash --plugin core/auth/trustedProxy \ --trusted-proxy "127.0.0.1" \ --plugin core/auth/crowdsec \ --crowdsec-url "http://localhost:8080" \ --crowdsec-api-key "your-api-key" \ --plugin core/auth/rateLimit \ --rate-limit-max 100 \ --plugin core/auth/token \ --auth-token "your-token" ``` Order matters: 1. `trustedProxy` - Sanitize IP headers first 2. `crowdsec` - Block known bad IPs 3. `rateLimit` - Limit request frequency 4. Authentication plugins ## Security Considerations - Keep CrowdSec updated for latest threat intelligence - Monitor CrowdSec logs for blocked requests - Consider local decisions for sensitive applications - Use HTTPS between LDAP-Rest and CrowdSec API ## See Also - [CrowdSec Documentation](https://docs.crowdsec.net/) - [CrowdSec Hub](https://hub.crowdsec.net/) linagora-ldap-rest-a4bb78e/docs/usage/plugins/auth/fake.md000066400000000000000000000016241527331160600236120ustar00rootroot00000000000000# Fake Authentication For development only: every request is served as the identity given on the command line, without asking for anything. Restarting with another `--auth-fake-user` shows an interface as another administrator sees it. ```bash npx ldap-rest \ --plugin core/auth/fake --auth-fake-user alice \ --plugin core/auth/authzLinid1 \ ... ``` **Environment Variable:** `DM_AUTH_FAKE_USER=alice` The identity goes to `req.user` and `req.userName` alike, so the authorization plugins judge it as they would the same login coming from a real authenticator. ## Safeguards - The server refuses to start when `NODE_ENV` is `production`, as it is in the Docker image. - It refuses to start without `--auth-fake-user`. - A warning naming the identity is logged at every start. Loaded without an authorization plugin, it grants everything to anyone who reaches the port. Listen on a local address only. linagora-ldap-rest-a4bb78e/docs/usage/plugins/auth/hmac.md000066400000000000000000000246031527331160600236160ustar00rootroot00000000000000# HMAC Authentication HMAC-SHA256 request signing authentication for backend services (Registration Service, admin panel backend, cloudery, etc.). ## Configuration ### Basic Configuration ```bash --plugin core/auth/hmac \ --auth-hmac "registration-service:secret-key-minimum-32-chars:Registration Service" \ --auth-hmac "cloudery:another-secret-key-long-enough:Cloudery Backend" ``` **Environment Variable:** ```bash DM_AUTH_HMAC="registration-service:secret-key-minimum-32-chars:Registration Service,cloudery:another-secret-key-long-enough:Cloudery Backend" ``` **Syntax:** `service-id:secret:name` - **service-id**: Service identifier (e.g., `registration-service`, `cloudery`) - **secret**: Shared secret for HMAC (minimum 32 characters recommended) - **name**: Descriptive service name (can contain colons) ### Advanced Configuration ```bash --auth-hmac-window 120000 # Time window in milliseconds (default: 120000 = 2 minutes) ``` **Environment Variable:** ```bash DM_AUTH_HMAC_WINDOW=120000 # Default: 120000ms (2 minutes) ``` - **window**: Maximum time difference (in milliseconds) between server and client timestamp - Prevents replay attacks by rejecting requests with timestamps outside the window ## How It Works ### Request Signing Process 1. **Client calculates signature:** ``` signature = HMAC-SHA256( secret, "METHOD|PATH|timestamp|body-hash" ) ``` Where: - `METHOD`: HTTP method (GET, POST, PATCH, DELETE, PUT, etc.) - `PATH`: Request path with query string (e.g., `/api/v1/ldap/users?filter=active`) - `timestamp`: Unix timestamp in milliseconds (e.g., `1698765432000`) - `body-hash`: SHA256 of the body bytes for POST/PATCH/PUT, empty string for GET/DELETE/HEAD and for a request without a body. The server hashes the bytes it received, so the body need not be serialized in any particular way. With a `Content-Encoding` (gzip, deflate, br), it hashes them once decoded: sign the body before compressing it. Only a body the server parses before checking the signature can be signed: `application/json`, `application/x-www-form-urlencoded` and, under the prefix of a loaded `core/scim`, `application/scim+json`. A POST, PATCH or PUT carrying any other body, such as a `multipart/form-data` bulk import upload, is refused with `401`. So is a GET, DELETE or HEAD carrying a body, since they are signed without one. The `Content-Type` is not part of the signature. A signed body is read as UTF-8 only: one sent under another `charset` is refused with `401`. 2. **Client sends Authorization header:** ``` Authorization: HMAC-SHA256 service-id:timestamp:signature ``` 3. **Server validates:** - Extracts `service-id`, `timestamp`, and `signature` from header - Verifies timestamp is within configured window (prevents replay attacks) - Retrieves service secret using `service-id` - Recalculates signature using same method - Compares signatures using constant-time comparison (prevents timing attacks) - Rejects if mismatch or timestamp expired 4. **On success:** - Sets `req.user` to service name - Allows request to proceed ## Usage ### Manual Request Example ```bash # Example: GET request SERVICE_ID="registration-service" SECRET="secret-key-minimum-32-chars" METHOD="GET" PATH="/api/v1/ldap/users" TIMESTAMP=$(date +%s000) # Unix timestamp in milliseconds # Calculate body hash (empty for GET) BODY_HASH="" # Create signing string SIGNING_STRING="${METHOD}|${PATH}|${TIMESTAMP}|${BODY_HASH}" # Calculate HMAC-SHA256 signature SIGNATURE=$(echo -n "$SIGNING_STRING" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2) # Make request curl -H "Authorization: HMAC-SHA256 ${SERVICE_ID}:${TIMESTAMP}:${SIGNATURE}" \ http://localhost:8081${PATH} ``` ### POST Request with Body ```bash SERVICE_ID="registration-service" SECRET="secret-key-minimum-32-chars" METHOD="POST" PATH="/api/v1/ldap/users" TIMESTAMP=$(date +%s000) BODY='{"uid":"user1","mail":"user1@example.com"}' # Calculate body hash BODY_HASH=$(echo -n "$BODY" | openssl dgst -sha256 | cut -d' ' -f2) # Create signing string SIGNING_STRING="${METHOD}|${PATH}|${TIMESTAMP}|${BODY_HASH}" # Calculate signature SIGNATURE=$(echo -n "$SIGNING_STRING" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2) # Make request curl -X POST \ -H "Authorization: HMAC-SHA256 ${SERVICE_ID}:${TIMESTAMP}:${SIGNATURE}" \ -H "Content-Type: application/json" \ -d "$BODY" \ http://localhost:8081${PATH} ``` ### Browser Client The HMAC client library is available as an npm module export: ```bash npm install ldap-rest ``` ```typescript import { HmacAuthClient } from 'ldap-rest/browser-shared-utils-hmac'; const client = new HmacAuthClient({ serviceId: 'registration-service', secret: 'secret-key-minimum-32-chars', }); // Automatic authentication with HMAC signature const response = await client.get('/api/v1/ldap/users'); const users = await response.json(); // POST with automatic body hashing and signing await client.post('/api/v1/ldap/users', { uid: 'user1', mail: 'user1@example.com', }); // Other methods: put, patch, delete await client.put('/api/v1/ldap/users/user1', { mail: 'new@example.com' }); await client.delete('/api/v1/ldap/users/user1'); ``` **Manual signature generation:** ```typescript import { generateHmacSignature } from 'ldap-rest/browser-shared-utils-hmac'; const signature = await generateHmacSignature( 'secret-key-minimum-32-chars', 'GET', '/api/v1/ldap/users', Date.now(), undefined // no body for GET ); const authHeader = `HMAC-SHA256 registration-service:${Date.now()}:${signature}`; ``` ## Use Cases - **Backend Services**: Service-to-service authentication (Registration Service, admin panels, etc.) - **Microservices**: Secure inter-service communication - **Webhooks**: Verify request authenticity and integrity - **API Gateways**: Backend service authentication - **Cloudery Integration**: Secure communication with cloudery backend - **Automated Services**: CI/CD, monitoring, backup services ## Security Considerations - **Secret Strength**: Use secrets of at least 32 characters (64+ recommended) - **Secret Storage**: Store secrets securely (encrypted configuration, environment variables, secret managers) - **HTTPS Required**: Always use HTTPS to protect signatures in transit - **Time Synchronization**: Ensure server and client clocks are synchronized (NTP recommended) - **Replay Protection**: Time window prevents replay attacks (adjust `auth-hmac-window` as needed) - **Constant-Time Comparison**: Signature validation uses `timingSafeEqual` to prevent timing attacks - **Secret Rotation**: Rotate secrets periodically for enhanced security - **Body Integrity**: Body hash ensures request payload hasn't been tampered with - **Path Integrity**: Full path (including query parameters) is signed ## Security Features 1. **Replay Attack Prevention**: Timestamp validation with configurable window 2. **Timing Attack Protection**: Constant-time signature comparison 3. **Request Integrity**: Body hash ensures payload integrity 4. **Path Integrity**: Query parameters included in signature 5. **Multi-Service Support**: Isolated secrets per service 6. **Clock Drift Tolerance**: Configurable time window (default 2 minutes) ## Advantages Over Other Methods | Feature | HMAC | Token | TOTP | | ------------------------- | ---------------- | ----------- | ---------------- | | **Request Integrity** | Yes (body hash) | No | No | | **Replay Protection** | Yes (timestamp) | No | Yes (time-based) | | **Signature Per Request** | Yes | No (static) | Yes (30s codes) | | **Body Tampering Detect** | Yes | No | No | | **Path/Query Protection** | Yes | No | No | | **Setup Complexity** | Medium | Simple | Simple | | **Best For** | Backend services | Simple APIs | MFA, user auth | ## Comparison with Standards This implementation is similar to: - **AWS Signature V4**: Similar approach but simpler (AWS is more complex with canonicalization) - **HTTP Message Signatures (RFC 9421)**: IETF standard for HTTP signatures - **HMAC-Based Authentication**: Industry-standard pattern for service authentication Our approach provides a good balance between security and simplicity: - Simpler than AWS Signature V4 (easier to implement) - More secure than static tokens (request-specific signatures) - Better integrity than TOTP (includes body and path hashing) - Standard cryptography (HMAC-SHA256) ## Troubleshooting **Problem:** 401 Unauthorized despite correct signature **Solutions:** 1. Verify service-id matches exactly (case-sensitive) 2. Verify secret is correctly configured (minimum 32 characters) 3. Check system clock synchronization (use NTP: `ntpdate -q pool.ntp.org`) 4. Increase `--auth-hmac-window` if experiencing timing issues 5. Verify Authorization header format: `HMAC-SHA256 service-id:timestamp:signature` 6. Check server logs for specific error messages 7. Ensure timestamp is in milliseconds, not seconds 8. Verify body hash calculation (must match exactly) **Problem:** Timestamp outside allowed window **Solutions:** 1. Synchronize clocks using NTP 2. Increase `--auth-hmac-window` (default: 120000ms = 2 minutes) 3. Check if timestamp is in milliseconds (not seconds) 4. Verify both server and client are using same timezone reference (UTC) **Problem:** Signature mismatch **Solutions:** 1. Verify signing string format: `METHOD|PATH|timestamp|body-hash` 2. Ensure METHOD is uppercase (GET, POST, etc.) 3. Include full path with query parameters 4. For POST/PATCH/PUT: verify body hash is the SHA256 of the exact bytes sent, before any compression, not of a re-serialized object 5. For GET/DELETE/HEAD: body-hash must be empty string 6. Ensure secret is exactly the same on client and server 7. Use hex encoding for signature (lowercase) 8. Check for trailing whitespace in secret **Problem:** Warning about short secrets **Solutions:** 1. Use secrets of at least 32 characters 2. Recommended: 64+ characters for enhanced security 3. Generate secure random secrets: ```bash openssl rand -hex 32 # 64-character hex string node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" ``` **Debug Example:** ```bash # Enable debug logging DM_LOG_LEVEL=debug npm start # Check signing string in logs # Expected format: "GET|/api/v1/ldap/users|1698765432000|" ``` linagora-ldap-rest-a4bb78e/docs/usage/plugins/auth/llng.md000066400000000000000000000105201527331160600236330ustar00rootroot00000000000000# LemonLDAP::NG Authentication Integration with [LemonLDAP::NG](https://lemonldap-ng.org/) (LLNG) Web SSO solution. ## Configuration ```bash --plugin core/auth/llng \ --llng-ini /etc/lemonldap-ng/lemonldap-ng.ini ``` **Environment Variable:** ```bash DM_LLNG_INI="/etc/lemonldap-ng/lemonldap-ng.ini" ``` ## Rules that name nobody The plugin serves a request only when the handler named a user. A LemonLDAP::NG location rule set to `skip` makes the handler return at once — no session read, no `Lm-Remote-User` written — and such a request is refused with 401 rather than served with no identity: every authorization plugin skips its check when a request carries no identity, so "no authentication on this path" would have become "authenticated, and scoped by nobody". Serve such a path from a virtual host this plugin does not guard, or scope the plugin with `auth_path_prefix` so the path falls outside it. The identity header is read whatever case the handler wrote it in, and whatever the client sent under that name is dropped before the handler runs: only the handler may name the caller. `req.user` is what `whatToTrace` traces, which is often a mail or a display name. `--llng-username-header` names a second header LemonLDAP::NG exports carrying the login, published as `req.userName`; with `--authz-identity req.userName`, rules are then written on logins. Export it from the LLNG configuration as you would any other header. Whatever the request carried under that name is dropped before the handler runs, exactly as the identity header is: a rule can be keyed on `req.userName`, so a client supplying it would otherwise take over the identity that rule names. The consequence is that the value must come from the handler — a header set by an upstream proxy does not survive, and would only have been trustworthy if that proxy stripped the client's copy itself. ## Prerequisites 1. **LemonLDAP::NG Handler**: The `lemonldap-ng-handler` npm package (optional dependency) 2. **LLNG Configuration**: Valid `lemonldap-ng.ini` file 3. **Virtual Host Configuration**: LDAP-Rest must be configured as a protected application in LLNG The handler runs inside LDAP-Rest and reads `lemonldap-ng.ini` when the server starts. The file tells it where the LLNG configuration is, and which virtual hosts it protects — a Node.js handler serves only those listed in `nodeVhosts`: ```ini [configuration] type = REST baseConfigUrl = https://auth.example.com/index.fcgi/config [node-handler] nodeVhosts = api.example.com ``` A file that cannot be read, a configuration that cannot be reached or a missing `nodeVhosts` stops the server at startup, with the reason. ## How It Works 1. Uses the LemonLDAP::NG Handler to validate requests 2. Extracts user identity from `Lm-Remote-User` header 3. Sets `req.user` to authenticated username 4. Inherits all LLNG authorization rules and features ## Installation Install the optional dependency: ```bash npm install lemonldap-ng-handler ``` ## LLNG Configuration Configure LDAP-Rest as a protected virtual host in LemonLDAP::NG: ```apache # In LLNG Manager: Virtual Hosts > api.example.com ServerName api.example.com # LemonLDAP::NG Handler PerlHeaderParserHandler Lemonldap::NG::Handler::ApacheMP2 # Proxy to LDAP-Rest ProxyPass / http://localhost:8081/ ProxyPassReverse / http://localhost:8081/ ``` ## Use Cases - **Enterprise SSO**: Centralized authentication for multiple applications - **Advanced Authorization**: Fine-grained access control using LLNG rules - **Session Management**: Centralized session handling - **Multi-Factor Authentication**: MFA support through LLNG ## Example: LLNG Authorization Rules Configure access rules in LLNG Manager: ```perl # Allow only HR group to access users endpoint $groups =~ /\bhr\b/ and $uri =~ m#^/api/v1/ldap/users# # Allow admins full access $groups =~ /\badmins\b/ # Allow specific users to manage groups $uid eq "groupmanager" and $uri =~ m#^/api/v1/ldap/groups# ``` ## Troubleshooting **Problem:** Handler not found **Solution:** Install optional dependency: ```bash npm install lemonldap-ng-handler ``` **Problem:** Authentication refused **Solutions:** 1. Verify LLNG handler is configured correctly 2. Check virtual host configuration 3. Review LLNG access logs ## See Also - [LemonLDAP::NG Documentation](https://lemonldap-ng.org/documentation) linagora-ldap-rest-a4bb78e/docs/usage/plugins/auth/oidc.md000066400000000000000000000222731527331160600236250ustar00rootroot00000000000000# OpenID Connect Authentication OAuth 2.0 / OpenID Connect authentication for modern identity providers. ## Configuration ```bash --plugin core/auth/openidconnect \ --oidc-server "https://auth.example.com" \ --oidc-client-id "ldap-rest-client" \ --oidc-client-secret "client-secret-here" \ --base-url "https://api.example.com" ``` **Environment Variables:** ```bash DM_OIDC_SERVER="https://auth.example.com" DM_OIDC_CLIENT_ID="ldap-rest-client" DM_OIDC_CLIENT_SECRET="client-secret-here" DM_BASE_URL="https://api.example.com" ``` ## Prerequisites 1. **OpenID Provider**: [Lemonldap-NG](https://lemonldap-ng.org), Keycloak, Auth0, Okta, Azure AD, etc. 2. **Client Registration**: LDAP-Rest registered as OAuth2/OIDC client 3. **Optional Dependency**: The `express-openid-connect` npm package ## Installation Install the optional dependency: ```bash npm install express-openid-connect ``` ## Scope Like every other authentication plugin, this one registers with the server's authentication dispatcher, so `auth_path_prefix` says which paths it guards: ```bash --plugin 'core/auth/openidconnect:oidc:{"auth_path_prefix":"/api/admin"}' ``` Until 0.9.0 the option was accepted and ignored — the plugin mounted its own middleware and guarded the whole server — and the layers it mounted kept their registration order, so a route plugin loading before it was served without a session and an authorization plugin loading after it judged rules before `req.user` existed. See [the note](../../upgrading.md#openid-connect-honours-auth_path_prefix). Its own routes come with it: `/login`, `/logout`, `/callback` and `/backchannel-logout` are claimed whatever the scope, so the provider can always reach the callback and post a logout token, and a catch-all authentication never answers them with a 401. Two instances are supported — `auth()` builds a fresh router per instance — but `--base-url` and the callback path are global, so both share one redirect URI. Scope by prefix, not by provider, unless you also front them with different base URLs. ## The identity it publishes `req.user` is the OIDC `sub` — an opaque provider identifier, `auth0|…` or a UUID — which is what authorization rules are keyed on by default, and what nobody can write a rule for without looking it up per administrator. `--oidc-username-claim` names the claim published as `req.userName`: ```bash --oidc-username-claim preferred_username ``` Ask the provider for the scope that carries it (`profile`, or `email` for `email`), then key the authorization plugins on it with `--authz-identity req.userName`. A session missing the configured claim falls back to the `sub`, with a warning naming the claim. ## What an unauthenticated request receives The library's own answer: a browser is redirected to the provider, an API client (`Accept: application/json`) receives 401. Every other authentication plugin in this repository answers JSON 401 throughout — the difference is kept because it is what makes a login work at all. ## Session lifetime A session lasts no longer than its access token. Once the token has expired, the next request renews it with the refresh token, when the provider gave one; without a refresh token, or when the provider refuses the renewal, the session ends and the request is answered as an unauthenticated one: the browser goes back through the provider, which signs the caller in again without asking anything while its own session lasts. So that renewals do not cost a round trip through the provider, have it issue refresh tokens to this client (LemonLDAP::NG: _Issue refresh tokens_ in the relying party's options). A token the provider gives no expiry keeps the session for the cookie's own lifetime. Nothing serialises the renewals: requests arriving together with the same expired cookie each send the refresh token. A provider that rotates refresh tokens refuses all but one, and the requests it refused end their session: the caller signs in again, silently while the provider's session lasts. ## Logout `/logout` ends the session at the provider as well (RP-Initiated Logout): the browser is sent to the provider's `end_session_endpoint` with the id token as `id_token_hint`, and the provider closes its own session — and, with [Back-Channel Logout](back-channel-logout.md), tells the other applications. No `post_logout_redirect_uri` is sent: the caller ends on the provider's own logout page, and nothing has to be registered there. A request a browser reports as coming from another site — `Sec-Fetch-Site` other than `same-origin` or `none`, or an `Origin` or `Referer` naming another origin — is refused (`403`) and leaves the session alone. `/logout` is a `GET` a link reaches and asks for no token, so without that check any page could end an administrator's session. A console served from another origin than `--base-url` names that origin in `--oidc-logout-origins` (`DM_OIDC_LOGOUT_ORIGINS`, comma-separated), and is then the only other origin a logout is accepted from. A link from such a console is recognized by its `Referer`: a page setting `Referrer-Policy: no-referrer`, or a link marked `noreferrer`, sends neither `Referer` nor `Origin`, and its logout is refused. Ending the cookie alone would not log anyone out: every guarded path goes through the provider, which still holds its session and logs the caller back in without asking anything. A provider whose discovery document names no `end_session_endpoint` gets a local logout — the cookie is dropped, the browser goes to `--base-url` — and a warning saying the provider may log the caller back in. The session itself ends here only with [`core/bcl`](back-channel-logout.md) loaded. The cookie holds the whole session, so dropping it ends the browser's copy: a copy kept elsewhere — a shared computer, a backup of the browser profile, a stolen cookie — keeps working until its tokens expire. With `core/bcl`, `/logout` records the session it ends, named by an id given at login, and that copy is refused. Without it, the first `/logout` logs a warning saying so. `GET /v1/config` (with `core/configApi`) publishes the route, so a front-end knows it can offer a sign-out control. The key is `openidconnect` whatever the instance is named: ```json "features": { "openidconnect": { "enabled": true, "endpoints": { "logout": "/logout" } } } ``` ## How It Works 1. Uses `express-openid-connect` for OAuth2/OIDC flow 2. Handles authorization code flow automatically 3. Extracts user identity from `sub` claim 4. Sets `req.user` to user's subject identifier 5. Provides `beforeAuth` and `afterAuth` hooks for customization ## Provider Configuration ### Keycloak ```bash --oidc-server "https://keycloak.example.com/realms/master" \ --oidc-client-id "ldap-rest" \ --oidc-client-secret "abc123..." \ --base-url "https://api.example.com" ``` **Client Settings:** - Access Type: `confidential` - Valid Redirect URIs: `https://api.example.com/callback` - Standard Flow Enabled: `ON` ### Auth0 ```bash --oidc-server "https://tenant.auth0.com" \ --oidc-client-id "your-client-id" \ --oidc-client-secret "your-client-secret" \ --base-url "https://api.example.com" ``` ### Azure AD ```bash --oidc-server "https://login.microsoftonline.com/{tenant-id}/v2.0" \ --oidc-client-id "application-id" \ --oidc-client-secret "client-secret" \ --base-url "https://api.example.com" ``` ## Scopes Default scopes requested: - `openid` - Basic OpenID Connect - `profile` - User profile information - `email` - User email address ## Hooks The OpenID Connect plugin supports custom hooks: ### beforeAuth Hook Called before authentication processing: ```javascript hooks: { beforeAuth: async ([req, res]) => { // Custom pre-authentication logic console.log('Authentication attempt from:', req.ip); return [req, res]; }; } ``` ### afterAuth Hook Called after successful authentication: ```javascript hooks: { afterAuth: async ([req, res]) => { // Access OIDC user data const user = req.oidc.user; console.log('User authenticated:', user.email); // Add custom user properties req.customData = { email: user.email, name: user.name, }; return [req, res]; }; } ``` ## Use Cases - **Modern Identity Providers**: Integration with cloud identity services - **Social Login**: Google, Microsoft, GitHub authentication - **Multi-Tenant Applications**: Different OIDC providers per tenant - **Standards-Based**: Portable across OIDC-compliant providers ## Troubleshooting **Problem:** express-openid-connect not found **Solution:** Install optional dependency: ```bash npm install express-openid-connect ``` **Problem:** Missing config parameter **Solution:** Ensure all required parameters are set: - `--oidc-server` - `--oidc-client-id` - `--oidc-client-secret` - `--base-url` **Problem:** Redirect loop **Solutions:** 1. Set a long secret, shorts are refused by `openid-client` 2. Verify `--base-url` matches actual public URL 3. Check redirect URI in provider matches `{base-url}/callback` 4. Ensure HTTPS in production (many providers require it) ## See Also - [Back-Channel Logout](back-channel-logout.md) — honouring a logout the provider performed elsewhere - [OpenID Connect Specification](https://openid.net/connect/) - [express-openid-connect](https://github.com/auth0/express-openid-connect) linagora-ldap-rest-a4bb78e/docs/usage/plugins/auth/rate-limit.md000066400000000000000000000044651527331160600247610ustar00rootroot00000000000000# Rate Limiting Prevent brute force attacks by limiting the number of requests per IP address. ## Configuration ```bash --plugin core/auth/rateLimit \ --rate-limit-window 60000 \ --rate-limit-max 100 ``` **Environment Variables:** ```bash DM_RATE_LIMIT_WINDOW=60000 # Time window in milliseconds (default: 60000 = 1 minute) DM_RATE_LIMIT_MAX=100 # Maximum requests per window (default: 100) ``` ## How It Works 1. Tracks request count per IP address 2. Uses sliding window algorithm 3. Returns `429 Too Many Requests` when limit exceeded 4. Automatically respects `X-Forwarded-For` when behind trusted proxy ## Use Cases - **Brute Force Prevention**: Limit authentication attempts - **API Protection**: Prevent abuse of API endpoints - **DDoS Mitigation**: Basic protection against flood attacks - **Fair Usage**: Ensure equitable API access ## Headers When rate limiting is active, responses include: ``` X-RateLimit-Limit: 100 X-RateLimit-Remaining: 95 X-RateLimit-Reset: 1698765492 ``` ## Combining with Trusted Proxy For correct IP detection behind reverse proxies: ```bash --plugin core/auth/trustedProxy \ --trusted-proxy "127.0.0.1" \ --plugin core/auth/rateLimit \ --rate-limit-max 100 ``` The rate limiter will use the real client IP from `X-Forwarded-For` when the request comes from a trusted proxy. This pairing is not a refinement, it is what makes the limit hold. The limiter keys on `X-Forwarded-For`, a header any client can set to anything; `trustedProxy` is what strips it on requests that do not come from a declared proxy. Loaded alone, the limiter counts forged keys: rotating the header on every attempt gives an attacker a fresh counter each time, and the brute-force protection becomes decorative while still looking configured. The plugin therefore warns at startup when `trustedProxy` is absent: ``` Auth rate limiting keys on X-Forwarded-For but core/auth/trustedProxy is not loaded: a client can forge that header and evade the limit. ``` Either load `trustedProxy`, or make sure the reverse proxy in front **overwrites** `X-Forwarded-For` rather than appending to it. ## Security Considerations - Always use with `trustedProxy` plugin behind reverse proxies - Consider different limits for different endpoints - Monitor for legitimate users hitting limits - Combine with CrowdSec for IP reputation linagora-ldap-rest-a4bb78e/docs/usage/plugins/auth/token.md000066400000000000000000000065371527331160600240340ustar00rootroot00000000000000# Token Authentication Simple stateless authentication using bearer tokens. ## Configuration ### Basic Configuration ```bash --plugin core/auth/token \ --auth-token "secret-token-1" \ --auth-token "secret-token-2" \ --auth-token "secret-token-3" ``` **Environment Variable:** ```bash DM_AUTH_TOKENS="token1,token2,token3" ``` ### Named Tokens (Recommended) Associate a descriptive name with each token for better logging and audit trails: ```bash --plugin core/auth/token \ --auth-token "9f8e7d6c5b4a:web-application" \ --auth-token "1a2b3c4d5e6f:monitoring-service" \ --auth-token "f1e2d3c4b5a6:backup-scripts" ``` **Environment Variable:** ```bash DM_AUTH_TOKENS="9f8e7d6c5b4a:web-application,1a2b3c4d5e6f:monitoring-service,f1e2d3c4b5a6:backup-scripts" ``` **Syntax:** `token:name` - Token value comes first (the secret) - Colon `:` separator - Name comes second (descriptive identifier) ## Usage Include the token in the `Authorization` header: ```bash curl -H "Authorization: Bearer 9f8e7d6c5b4a" \ http://localhost:8081/api/v1/ldap/users ``` ## How It Works 1. Extracts token from `Authorization: Bearer ` header 2. Validates token against configured list 3. Sets `req.user` to the token's name (e.g., `"web-application"`) or `"token {index}"` for unnamed tokens 4. Returns 401 Unauthorized if token is missing or invalid **Named tokens in logs:** ``` INFO: Request from user: web-application INFO: Request from user: monitoring-service ``` **Unnamed tokens in logs (backward compatible):** ``` INFO: Request from user: token 0 INFO: Request from user: token 1 ``` ## Use Cases - **Development/Testing**: Quick authentication without complex setup - **Service-to-Service**: API access for backend services - **CI/CD Pipelines**: Automated scripts and deployments - **Simple Deployments**: Small teams without SSO infrastructure ## Security Considerations - Tokens are static and shared (not user-specific) - Use HTTPS in production to protect tokens in transit - Rotate tokens regularly - Limit token count to necessary services only - Consider combining with IP whitelisting ## Example: Multiple Services with Named Tokens ```bash # API server configuration with named tokens npx ldap-rest \ --plugin core/auth/token \ --auth-token "a1b2c3d4e5f6:production-web-app" \ --auth-token "f6e5d4c3b2a1:prometheus-monitoring" \ --auth-token "1234567890ab:nightly-backup" \ --ldap-url ldap://localhost:389 \ ... ``` ```bash # Web application (logs will show: user: production-web-app) curl -H "Authorization: Bearer a1b2c3d4e5f6" \ http://api/v1/ldap/users # Monitoring system (logs will show: user: prometheus-monitoring) curl -H "Authorization: Bearer f6e5d4c3b2a1" \ http://api/v1/ldap/groups # Backup script (logs will show: user: nightly-backup) curl -H "Authorization: Bearer 1234567890ab" \ http://api/v1/ldap/organizations/top ``` ## Example: Mixed Named and Unnamed Tokens You can mix named and unnamed tokens for backward compatibility: ```bash --auth-token "abc123:production-api" \ --auth-token "def456" # Unnamed, will be "token 1" in logs \ --auth-token "ghi789:staging-app" ``` ## Troubleshooting **Problem:** 401 Unauthorized despite correct token **Solutions:** 1. Ensure `Authorization: Bearer {token}` format (not just the token) 2. Check token is in configured list 3. Verify no whitespace in token 4. Check server logs for token mismatch linagora-ldap-rest-a4bb78e/docs/usage/plugins/auth/totp.md000066400000000000000000000136401527331160600236730ustar00rootroot00000000000000# TOTP Authentication Time-based One-Time Password (TOTP) authentication using dynamic codes compatible with authenticator apps. ## Configuration ### Basic Configuration ```bash --plugin core/auth/totp \ --auth-totp "JBSWY3DPEHPK3PXP:admin:6" \ --auth-totp "HXDMVJECJJWSRB3H:service:8" ``` **Environment Variable:** ```bash DM_AUTH_TOTP="JBSWY3DPEHPK3PXP:admin:6,HXDMVJECJJWSRB3H:service:8" ``` **Syntax:** `secret:name[:digits]` - **secret**: Base32-encoded secret (e.g., generated by authenticator apps) - **name**: Descriptive user/service name - **digits**: Number of digits in TOTP code (optional, 6-10, default: 6) ### Advanced Configuration ```bash --auth-totp-window 1 # Time window tolerance (±30s with step=30) --auth-totp-step 30 # Time step in seconds (default: 30) ``` **Environment Variables:** ```bash DM_AUTH_TOTP_WINDOW=1 # Default: 1 DM_AUTH_TOTP_STEP=30 # Default: 30 ``` - **window**: Number of time steps to check before/after current time (compensates for clock drift) - **step**: Time interval in seconds for code generation (typically 30) ## Generating Secrets Use an authenticator app or generate Base32 secrets: ```bash # Using Node.js crypto (example) node -e "console.log(require('crypto').randomBytes(20).toString('base32'))" ``` ## Usage ### HTTP Requests Include the current TOTP code in the `Authorization` header: ```bash # Get current TOTP code from your authenticator app curl -H "Authorization: Bearer 123456" \ http://localhost:8081/api/v1/ldap/users ``` ### Browser Client The TOTP client library is available as an npm module export: ```bash npm install ldap-rest ``` ```typescript import { TotpAuthClient, generateTotp, getRemainingSeconds, isValidBase32, } from 'ldap-rest/browser-shared-utils-totp'; const client = new TotpAuthClient({ secret: 'JBSWY3DPEHPK3PXP', digits: 6, step: 30, }); // Automatic authentication with current TOTP code const response = await client.get('/api/v1/ldap/users'); const users = await response.json(); // Or get current code manually const code = await client.getCode(); console.log(`Current TOTP code: ${code}`); ``` **Live Demo:** See [examples/web/totp-client.html](../../../../examples/web/totp-client.html) for an interactive demonstration. ## How It Works 1. Extracts TOTP code from `Authorization: Bearer ` header 2. Generates expected TOTP codes for current time window 3. Validates code against all configured users' secrets 4. Supports multiple time windows (±window × step seconds) for clock drift tolerance 5. Sets `req.user` to the user's name on successful validation 6. Returns 401 Unauthorized if code is missing, invalid, or expired ## Multi-User Example ```bash # Configure multiple users with different code lengths --plugin core/auth/totp \ --auth-totp "JBSWY3DPEHPK3PXP:admin:6" \ --auth-totp "HXDMVJECJJWSRB3H:api-service:8" \ --auth-totp "IXDMVJECJJWSRB2A:monitoring:10" ``` **In logs:** ``` INFO: TOTP authentication successful for user: admin INFO: TOTP authentication successful for user: api-service ``` ## Use Cases - **Enhanced Security**: Dynamic codes that expire every 30 seconds - **No Shared Secrets**: Each user/service has unique TOTP secret - **Compatible**: Works with Google Authenticator, Authy, 1Password, etc. - **API Access**: Suitable for automated scripts with TOTP generation - **Multi-Factor**: Can be combined with other authentication methods ## Security Considerations - **Secret Storage**: Store Base32 secrets securely (encrypted configuration, environment variables) - **HTTPS Required**: Always use HTTPS to protect TOTP codes in transit - **Clock Synchronization**: Ensure server and client clocks are synchronized (NTP recommended) - **Window Size**: Larger windows are more tolerant but slightly less secure - **Secret Rotation**: Rotate TOTP secrets periodically for better security - **Rate Limiting**: Consider adding rate limiting to prevent brute force attacks ## Browser Client Features The TOTP browser library provides these functions and classes: ```typescript import { generateTotp, getRemainingSeconds, isValidBase32, TotpAuthClient } from 'ldap-rest/browser-shared-utils-totp'; // Generate TOTP code const code = await generateTotp({ secret: 'JBSWY3DPEHPK3PXP', digits: 6, step: 30 }); // Check validity time const remaining = getRemainingSeconds(30); console.log(`Code expires in ${remaining} seconds`); // Validate secret format if (isValidBase32('JBSWY3DPEHPK3PXP')) { console.log('Valid Base32 secret'); } // HTTP client with automatic TOTP authentication const client = new TotpAuthClient({ secret: 'JBSWY3DPEHPK3PXP', digits: 6, step: 30 }); // All HTTP methods automatically include TOTP in Authorization header await client.post('/api/v1/ldap/users', { uid: 'user1', ... }); await client.put('/api/v1/ldap/users/user1', { mail: 'new@example.com' }); await client.delete('/api/v1/ldap/users/user1'); ``` **Module Export:** `ldap-rest/browser-shared-utils-totp` **Live Demo:** Run the server and open [http://localhost:8081/static/examples/web/totp-client.html](http://localhost:8081/static/examples/web/totp-client.html) ## Troubleshooting **Problem:** 401 Unauthorized despite correct code **Solutions:** 1. Verify secret is correctly configured (Base32 format) 2. Check system clock synchronization (use NTP) 3. Increase `--auth-totp-window` if experiencing timing issues 4. Ensure code is used immediately (codes expire every 30s) 5. Verify `Authorization: Bearer {code}` format **Problem:** Codes don't match authenticator app **Solutions:** 1. Verify secret matches exactly (case-sensitive Base32) 2. Check `--auth-totp-step` matches app configuration (usually 30) 3. Ensure both server and device clocks are synchronized 4. Confirm correct number of digits configured **Problem:** Invalid Base32 secret warning **Solutions:** 1. Secret must contain only A-Z and 2-7 characters 2. Use proper Base32 encoding (not Base64) 3. Remove padding `=` characters if present (optional) linagora-ldap-rest-a4bb78e/docs/usage/plugins/auth/trusted-proxy.md000066400000000000000000000107101527331160600255510ustar00rootroot00000000000000# Trusted Proxy When running behind a reverse proxy (nginx, Apache, HAProxy, etc.), client IP addresses are typically passed via `X-Forwarded-For` headers. The `trustedProxy` plugin validates these headers to prevent IP spoofing attacks. ## Configuration ```bash --plugin core/auth/trustedProxy \ --trusted-proxy "127.0.0.1" \ --trusted-proxy "10.0.0.0/8" \ --trusted-proxy "192.168.0.0/16" ``` **Environment Variables:** ```bash # Comma-separated list of trusted proxy IPs or CIDR ranges DM_TRUSTED_PROXIES="127.0.0.1,10.0.0.0/8,192.168.0.0/16,::1" # Optional: Header name for authenticated user from proxy (default: Auth-User) DM_TRUSTED_PROXY_AUTH_HEADER="Auth-User" ``` ## How It Works 1. **Request arrives** from reverse proxy 2. **Plugin checks** if `req.socket.remoteAddress` matches a trusted proxy 3. **If trusted:** - `X-Forwarded-For` header is preserved - `Auth-User` header (or custom header) is extracted for logging - Request is marked as `req.trustedProxy = true` 4. **If untrusted:** - `X-Forwarded-For` header is **removed** to prevent IP spoofing - Request is marked as `req.trustedProxy = false` 5. **Other plugins** (rate limiting, CrowdSec, weblogs) see sanitized headers ## Supported Formats - **IPv4 addresses:** `192.168.1.1` - **IPv6 addresses:** `::1`, `fe80::1` - **CIDR ranges:** `10.0.0.0/8`, `192.168.0.0/16`, `2001:db8::/32` - **IPv4-mapped IPv6:** accepted in any spelling, e.g. `::ffff:127.0.0.1` or `::ffff:7f00:1` matches `127.0.0.1`. A network written in mapped form is checked as the IPv4 network it is: `::ffff:10.0.0.0/104` is `10.0.0.0/8`. - **Zone index:** a peer address carrying an interface, such as `fe80::1%eth0`, is matched on the address alone. A trusted entry carrying one is refused: the same link-local address names another host on another link. An entry that is neither an address nor an `address/prefix` network (`:::`, `10.0.0.0/2.`, `10.0.0.0/24x`) makes the server refuse to start: it would otherwise trust a network the administrator never wrote. ## Auth-User Header When requests come from a trusted proxy, the plugin can extract an authenticated username from a header set by the proxy. This is useful for: - Logging who made the request (even if LDAP-Rest doesn't handle authentication) - Passing identity from an upstream authentication system ```bash # Custom header name (default: Auth-User) DM_TRUSTED_PROXY_AUTH_HEADER="X-Remote-User" ``` The extracted username is: - Available as `req.proxyAuthUser` for other plugins - Used by the `weblogs` plugin to log the user field ## Example: nginx Configuration ```nginx upstream ldap_rest { server 127.0.0.1:8081; } server { listen 443 ssl; server_name api.example.com; location / { proxy_pass http://ldap_rest; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # Optional: Pass authenticated user from nginx auth proxy_set_header Auth-User $remote_user; } } ``` LDAP-Rest configuration: ```bash DM_TRUSTED_PROXIES="127.0.0.1" ``` ## Example: HAProxy Configuration ```haproxy frontend https bind *:443 ssl crt /etc/ssl/cert.pem default_backend ldap_rest backend ldap_rest option forwardfor http-request set-header X-Forwarded-Proto https server ldap_rest 127.0.0.1:8081 ``` ## Use Cases - **Reverse Proxy Deployments:** Ensure correct client IP for rate limiting and logging - **Load Balancers:** Trust headers from known load balancer IPs - **CDN Integration:** Trust headers from CDN edge servers - **Kubernetes:** Trust headers from ingress controllers - **Security:** Prevent attackers from spoofing `X-Forwarded-For` to bypass rate limits ## Security Considerations - **Only trust known proxies:** Never use `0.0.0.0/0` or trust all IPs - **Use specific IPs/ranges:** Limit to your actual proxy infrastructure - **HTTPS between proxy and LDAP-Rest:** Prevent header injection attacks - **Monitor logs:** Watch for warnings about removed X-Forwarded-For headers ## Plugin Load Order The `trustedProxy` plugin is automatically loaded **first** (via `priority.json`) to ensure all other plugins see sanitized headers: 1. `core/auth/trustedProxy` - Sanitizes X-Forwarded-For headers 2. `core/weblogs` - Logs requests with correct client IP 3. `core/auth/crowdsec` - Checks IP reputation 4. `core/auth/rateLimit` - Rate limits by IP 5. Authentication plugins... linagora-ldap-rest-a4bb78e/docs/usage/plugins/integrations/000077500000000000000000000000001527331160600241245ustar00rootroot00000000000000linagora-ldap-rest-a4bb78e/docs/usage/plugins/integrations/README.md000066400000000000000000000036431527331160600254110ustar00rootroot00000000000000# Integration Plugins Plugins for connecting LDAP-Rest to external systems. ## Apache James | Plugin | Description | | ------------------------------------- | -------------------------------- | | [james-mail](james-mail.md) | User and domain synchronization | | [james-mailboxes](james-mailboxes.md) | Team mailbox management | | [mail-migration](mail-migration.md) | Migrated-users list of the proxy | ## Twake | Plugin | Description | | --------------------------------------- | ---------------------------------------- | | [calendar](calendar.md) | Calendar resources and users sync | | [app-accounts](app-accounts.md) | Applicative accounts API (devices, apps) | | [lifecycle-events](lifecycle-events.md) | Account lifecycle events to RabbitMQ | | [instances](instances.md) | Workplace instance of each account | | [tombstone](tombstone.md) | Deleted accounts kept as tombstones | | [groups](groups.md) | Organization groups | | [spaces](spaces.md) | Organization spaces, with member roles | ## Prerequisites ### Apache James James plugins require: - Apache James running - WebAdmin URL configuration - James authentication token ```bash --james-webadmin-url "http://localhost:8000" --james-token "your-james-token" ``` ### Dependencies ``` core/twake/james └─ requires: core/ldap/onChange core/twake/calendar └─ requires: core/ldap/onChange core/twake/migrationProxy └─ requires: core/ldap/onChange core/twake/lifecycleEvents ├─ requires: core/ldap/onChange └─ requires: core/rabbitmq core/twake/spaces ├─ requires: core/twake/groups ├─ requires: core/ldap/onChange └─ requires: core/rabbitmq (with RabbitMQ) ``` linagora-ldap-rest-a4bb78e/docs/usage/plugins/integrations/app-accounts.md000066400000000000000000000362761527331160600270610ustar00rootroot00000000000000# Twake Applicative Accounts API Plugin RESTful API for managing applicative accounts (device/app-specific accounts) for users. ## Overview The `twake/appAccountsApi` plugin provides API endpoints for creating, listing, and deleting applicative accounts. These are secondary accounts that allow users to have multiple passwords for different devices or applications, each identified by a unique uid (e.g., `username_c12345678`). Applicative accounts are stored in a separate LDAP branch (e.g., `ou=applicative`) and share the same mail address as the principal user account. ## Why Applicative Accounts? Instead of using a single primary password to access all services that authenticate via LDAP, the applicative accounts system provides separation of concerns: ### Primary Authentication - The primary authentication system may not use passwords at all (e.g., smart cards, biometrics, SSO) - Users authenticate once with their primary credentials (which may be strong 2FA/MFA) - Primary account remains secure and is not exposed to application protocols ### Application-Specific Accounts - Dedicated accounts for specific applications or devices - Each device/app gets its own isolated password - Essential for protocols that require password authentication: **IMAP**, **SMTP**, **CalDAV**, **CardDAV**, etc. - Benefits: - **Security**: Compromise of one device doesn't expose the primary account - **Revocation**: Delete individual app accounts without affecting others - **Auditing**: Track which device/app is accessing which service - **Device identification**: Each account can be labeled (e.g., "My Phone", "My Laptop") ### Use Cases 1. **Email clients (IMAP/SMTP)**: Create an app account for each device accessing email 2. **Calendar sync (CalDAV)**: Separate credentials for calendar applications 3. **Contact sync (CardDAV)**: Dedicated account for contact synchronization 4. **Legacy applications**: Apps that require password auth but can't use modern auth methods 5. **API access**: Service accounts for automated tools that need LDAP authentication ## Prerequisites 1. **Authentication plugin** loaded (e.g., `core/auth/token`) - **Required** 2. **App accounts consistency plugin** `core/twake/appAccountsConsistency` - **Required** - Automatically creates principal accounts (uid=mail@domain.com) - Ensures automatic cleanup when users are deleted - Moves the principal account on a mail change, and drops the app accounts: every MUA has to be reconfigured against the new address anyway 3. **Applicative accounts base** configured in LDAP (e.g., `ou=applicative,dc=example,dc=com`) 4. **Read access on `userPassword`** in that branch, for the bind DN (`--ldap-dn`) - **Required for deletion** Revoking an app account means removing _its_ value from the principal account, which holds one `userPassword` per device. The server must read the stored hash to know which value to remove — a salted hash cannot be recomputed. Without that access, `DELETE` returns `500` and keeps the account rather than reporting a revocation that did not happen. A typical OpenLDAP ACL, scoped to the applicative branch so that the users' primary passwords stay unreadable: ``` olcAccess: to dn.subtree="ou=applicative,dc=example,dc=com" attrs=userPassword by dn.exact="cn=ldap-rest,ou=tech,dc=example,dc=com" read by * break ``` It must precede any broader rule on `userPassword`; OpenLDAP stops at the first matching directive, and `by * break` lets other identities fall through to the following ones. 5. **Uniqueness constraints must not cover the applicative branch.** The principal account and its app accounts intentionally share the same `mail`, so a `slapo-unique` overlay on `mail` has to be scoped to the users branch (`olcUniqueURI: ldap:///ou=users,dc=example,dc=com?mail?sub?`) or every app account creation is rejected. ## Configuration ```bash --plugin core/auth/token \ --plugin core/ldap/onChange \ --plugin twake/appAccountsConsistency \ --plugin twake/appAccountsApi \ --auth-token "secret-token" \ --applicative-account-base "ou=applicative,dc=example,dc=com" \ --max-app-accounts 5 \ --mail-attribute mail ``` **Environment Variables:** ```bash DM_AUTH_TOKEN="secret-token" DM_APPLICATIVE_ACCOUNT_BASE="ou=applicative,dc=example,dc=com" DM_MAX_APP_ACCOUNTS=5 DM_MAIL_ATTRIBUTE="mail" ``` ### Parameters - `--applicative-account-base`: LDAP base DN for applicative accounts (required) - `--max-app-accounts`: Maximum number of app accounts per user (default: 5) - `--app-accounts-user-attribute`: attribute the `:user` of the routes is matched against (default: the mail attribute, unique directory-wide). `uid` is safe only where a uid is unique across the whole directory: a uid shared by users the caller can read is answered `409`, and one shared with a user it cannot read resolves to the one it can - `--mail-attribute`: LDAP attribute for email (default: `mail`) - `--api-prefix`: API endpoint prefix (default: `/api`) ## LDAP Structure ### Principal Account Created automatically by `twake/appAccountsConsistency` plugin when a user with a mail attribute is added: ``` dn: uid=alice@example.com,ou=applicative,dc=example,dc=com objectClass: inetOrgPerson uid: alice@example.com cn: Alice Smith sn: Smith mail: alice@example.com userPassword: {SSHA}hash1... userPassword: {SSHA}hash2... userPassword: {SSHA}hash3... ``` The principal account stores multiple passwords (one for each applicative account). ### Applicative Accounts Created via API: ``` dn: uid=alice_c12345678,ou=applicative,dc=example,dc=com objectClass: inetOrgPerson uid: alice_c12345678 cn: Alice Smith sn: Smith mail: alice@example.com userPassword: {SSHA}hash1... description: My Phone ``` Each applicative account: - Has a unique uid with format: `{username}_c{8-digits}` - Shares the same mail as the user - Has its own password - Optional description field for device name ## API Endpoints ### List Applicative Accounts ```http GET /api/v1/users/{username}/app-accounts Authorization: Bearer {token} ``` **Response:** ```json [ { "uid": "alice_c12345678", "name": "My Phone" }, { "uid": "alice_c87654321", "name": "My Laptop" } ] ``` **Status Codes:** - `200 OK` - Success - `401 Unauthorized` - Missing or invalid token - `404 Not Found` - User not found ### Create Applicative Account ```http POST /api/v1/users/{username}/app-accounts Authorization: Bearer {token} Content-Type: application/json { "name": "My Phone" } ``` **Request Body:** - `name` (optional): Description/device name **Response:** ```json { "uid": "alice_c12345678", "pwd": "AbC3@-2xYz!-9pQr@-St4v!-mN8#-pQ5$", "mail": "alice@example.com" } ``` **Important**: The password is only returned once during creation. It cannot be retrieved later. **Status Codes:** - `200 OK` - Account created - `400 Bad Request` - Max accounts limit reached or user has no mail - `401 Unauthorized` - Missing or invalid token - `404 Not Found` - User not found - `500 Internal Server Error` - The password could not be added to the principal account; the app account is rolled back and no password is returned ### Delete Applicative Account ```http DELETE /api/v1/users/{username}/app-accounts/{uid} Authorization: Bearer {token} ``` **Response:** ```json { "uid": "alice_c12345678" } ``` **Status Codes:** - `200 OK` - Account deleted (or already deleted - idempotent) - `401 Unauthorized` - Missing or invalid token - `403 Forbidden` - UID does not belong to user - `500 Internal Server Error` - The account password could not be read, or could not be removed from the principal account; the app account is kept so the request stays replayable ## Password Generation Applicative account passwords are generated automatically with the following characteristics: - **Format**: 6 blocks of 4 characters separated by `-` - **Length**: 29 characters total - **Example**: `AbC3@-2xYz!-9pQr@-St4v!-mN8#-pQ5$` - **Character classes**: Each block contains: - 1 uppercase letter (A-Z, excluding I, L, O) - 1 lowercase letter (a-z, excluding i, l, o) - 1 digit (2-9) - 1 special character (@, !, #, $, %) Passwords are passed to LDAP in cleartext. OpenLDAP's ppolicy overlay automatically hashes them using SSHA. ## Command-Line Utility The `sync-app-accounts` utility maintains consistency between user and applicative account branches: ```bash # Preview changes sync-app-accounts --dry-run # Execute synchronization sync-app-accounts # Quiet mode (for cron) sync-app-accounts --quiet # Show help sync-app-accounts --help ``` **Operations performed:** 1. **Create missing principal accounts** - For users with mail but no `uid=mail` in applicative base 2. **Delete orphaned applicative accounts** - `username_cXXXXXXXX` entries where username no longer exists 3. **Delete orphaned principal accounts** - `uid=mail` entries where no user has that mail **Recommended usage:** ```bash # Add to cron (run nightly) 0 2 * * * /usr/local/bin/sync-app-accounts --quiet ``` ## How It Works ### Account Creation Flow 1. Client requests new app account via `POST /api/v1/users/alice/app-accounts` 2. Plugin validates: - User exists in LDAP - User has mail attribute - Max accounts limit not reached 3. Plugin generates: - Unique account ID (`c12345678`) - Secure random password 4. Plugin creates applicative account in LDAP: - `uid=alice_c12345678,ou=applicative,dc=example,dc=com` - Copies the allowlisted attributes from the user (`--applicative-account-attribute`) - Sets generated password 5. Plugin adds password to principal account: - `uid=alice@example.com,ou=applicative,dc=example,dc=com` - Adds password as additional `userPassword` value 6. Returns credentials to client (only time password is visible) ### Account Deletion Flow 1. Client requests deletion via `DELETE /api/v1/users/alice/app-accounts/alice_c12345678` 2. Plugin validates: - UID belongs to user (starts with `alice_`) 3. Plugin retrieves account from LDAP 4. Plugin deletes password from principal account: - Removes specific `userPassword` value 5. Plugin deletes applicative account from LDAP 6. Returns success (idempotent - no error if already deleted) ### Authentication Flow Users can authenticate with: - **Principal account**: `uid=alice@example.com` + any app account password - **Applicative account**: `uid=alice_c12345678` + its specific password This allows revoking a single device's access by deleting its applicative account without affecting other devices. ## Integration Example ### JavaScript/TypeScript ```typescript import axios from 'axios'; const API_BASE = 'http://localhost:8081/api/v1'; const TOKEN = 'your-api-token'; // List accounts const accounts = await axios.get(`${API_BASE}/users/alice/app-accounts`, { headers: { Authorization: `Bearer ${TOKEN}` }, }); console.log(accounts.data); // [{ uid: "alice_c12345678", name: "My Phone" }] // Create account const newAccount = await axios.post( `${API_BASE}/users/alice/app-accounts`, { name: 'My Laptop' }, { headers: { Authorization: `Bearer ${TOKEN}` } } ); console.log(newAccount.data); // { uid: "alice_c87654321", pwd: "...", mail: "alice@example.com" } // Delete account await axios.delete(`${API_BASE}/users/alice/app-accounts/alice_c87654321`, { headers: { Authorization: `Bearer ${TOKEN}` }, }); ``` ### curl ```bash # List accounts curl -H "Authorization: Bearer secret-token" \ http://localhost:8081/api/v1/users/alice/app-accounts # Create account curl -X POST \ -H "Authorization: Bearer secret-token" \ -H "Content-Type: application/json" \ -d '{"name":"My Phone"}' \ http://localhost:8081/api/v1/users/alice/app-accounts # Delete account curl -X DELETE \ -H "Authorization: Bearer secret-token" \ http://localhost:8081/api/v1/users/alice/app-accounts/alice_c12345678 ``` ## Use Cases ### Mobile App Credentials Users can create separate credentials for mobile devices: ```bash # User creates account for phone POST /api/v1/users/alice/app-accounts {"name": "iPhone"} # Returns: { uid: "alice_c11111111", pwd: "..." } # User enters credentials in phone app # Phone uses alice@example.com + generated password ``` If phone is lost: ```bash # Revoke phone access DELETE /api/v1/users/alice/app-accounts/alice_c11111111 # Other devices continue working ``` ### Application-Specific Passwords Users can create passwords for different applications: ```bash POST /api/v1/users/alice/app-accounts {"name": "Email Client"} POST /api/v1/users/alice/app-accounts {"name": "Calendar Sync"} POST /api/v1/users/alice/app-accounts {"name": "Backup Tool"} ``` Each application gets its own password that can be revoked independently. ### Security Compliance - Enforce maximum accounts per user - Track which devices have access (via description) - Revoke compromised credentials without resetting main password - Audit trail via LDAP change logs ## Security Considerations 1. **Password Visibility**: Generated passwords are only returned once during creation. Store them securely on the client side. 2. **Token Protection**: API requires bearer token authentication. Keep tokens secure and rotate regularly. 3. **Account Limits**: Configure `max_app_accounts` based on your security policy to prevent abuse. 4. **Password Policy**: Ensure OpenLDAP ppolicy overlay is configured to enforce password hashing and quality requirements. 5. **Audit Logging**: Enable `weblogs` plugin to track account creation/deletion: ```bash --plugin core/weblogs ``` 6. **HTTPS**: Always use HTTPS in production to protect passwords in transit. ## Troubleshooting ### "User not found" error **Cause**: User doesn't exist in LDAP or username incorrect **Solution**: Verify user exists: ```bash ldapsearch -x -b "dc=example,dc=com" "(uid=alice)" ``` ### "User has no mail attribute" error **Cause**: User entry missing mail attribute **Solution**: Add mail to user: ```bash PUT /api/v1/ldap/users/alice {"mail": "alice@example.com"} ``` ### "Maximum number of accounts reached" error **Cause**: User already has max_app_accounts applicative accounts **Solution**: Delete unused accounts or increase limit: ```bash DELETE /api/v1/users/alice/app-accounts/alice_c11111111 ``` ### Principal account not created **Cause**: `twake/appAccountsConsistency` plugin not loaded **Solution**: Load plugin before `appAccountsApi`: ```bash --plugin twake/appAccountsConsistency \ --plugin twake/appAccountsApi ``` ### Password rejected by LDAP **Cause**: OpenLDAP ppolicy configuration issue **Solution**: Check ppolicy configuration: ```bash ldapsearch -x -LLL -b "cn=config" "(objectClass=olcPPolicyConfig)" ``` Ensure `olcPPolicyHashCleartext: TRUE` is set. ## Dependencies This plugin requires: 1. **authToken plugin** (or another auth plugin) - For API authentication 2. **twake/appAccountsConsistency plugin** - For automatic principal account creation Optional: - **core/ldap/onChange plugin** - Already required by appAccountsConsistency ## Related Plugins - **[twake/appAccountsConsistency](./appAccountsConsistency.md)** - Automatic principal account creation - **[authentication](./authentication.md)** - API authentication - **[onChange](./onChange.md)** - LDAP change detection ## See Also - [REST API Reference](./api/REST_API.md) - [Developer Guide](./DEVELOPER_GUIDE.md) - [Plugin Dependencies](./PLUGIN_DEPENDENCIES.md) linagora-ldap-rest-a4bb78e/docs/usage/plugins/integrations/calendar.md000066400000000000000000000216461527331160600262300ustar00rootroot00000000000000# Twake Calendar Plugin Plugin to keep Twake Calendar in sync with LDAP via its WebAdmin API: - resources (meeting rooms, equipment, etc.) stored in an LDAP branch - registered users, whose email, first name and last name follow LDAP > Formerly `core/twake/calendarResources`. That name still loads the plugin, > registered as `calendarResources`, but logs a deprecation warning and will > be removed in a future major release. ## Features - **Automatic Creation**: When a resource is added to the configured LDAP branch, it's automatically created in Twake Calendar - **Automatic Updates**: When a resource is modified in LDAP, changes are synced to Twake Calendar - **Automatic Deletion**: When a resource is removed from LDAP, it's deleted from Twake Calendar - **Flexible Configuration**: Configure which LDAP branch and objectClass to monitor - **User Identity Propagation**: When a user's email, first name or last name changes in LDAP, the matching Calendar registered user is updated ## Configuration ### Environment Variables - `DM_CALENDAR_WEBADMIN_URL`: URL of the Twake Calendar WebAdmin API (default: `http://localhost:8080`) - `DM_CALENDAR_WEBADMIN_TOKEN`: Bearer token for WebAdmin API authentication - `DM_CALENDAR_RESOURCE_BASE`: LDAP branch to monitor for resources, as a full DN (e.g., `ou=resources,dc=example,dc=com`). It is compared to an entry's DN part by part, so a partial value such as `ou=resources` names no branch and matches nothing (the plugin warns at startup when the value is not a DN of the directory). - `DM_CALENDAR_RESOURCE_OBJECTCLASS`: Optional objectClass filter, applied when a resource is **created** (see [Resource identity](#resource-identity)) - `DM_CALENDAR_RESOURCE_CREATOR`: Default creator email for resources (default: `admin@example.com`) - `DM_CALENDAR_RESOURCE_DOMAIN`: Default domain for resources (extracted from DN if not specified) - `DM_CALENDAR_FIRSTNAME_ATTRIBUTE`: LDAP attribute holding a user's first name (default: `givenName`) - `DM_CALENDAR_LASTNAME_ATTRIBUTE`: LDAP attribute holding a user's last name (default: `sn`) The resource variables are only needed for resource synchronization; user identity propagation only needs the WebAdmin URL and token. ### Required Dependencies This plugin requires: - `ldapFlat` plugin with a schema configured for calendar resources - `onLdapChange` plugin to detect LDAP modifications ## LDAP Schema The plugin works with any ldapFlat schema that has `entity.name` set to `calendarResource`. Example schema: ```json { "entity": { "name": "calendarResource", "mainAttribute": "cn", "objectClass": ["top", "device"], "singularName": "resource", "pluralName": "resources", "base": "ou=resources,__ldap_base__" }, "attributes": { "objectClass": { "type": "array", "default": ["top", "device"], "required": true, "fixed": true }, "cn": { "type": "string", "required": true }, "description": { "type": "string" } } } ``` ## Twake Calendar API The plugin uses the following WebAdmin API endpoints: - `POST /resources` - Create a new resource - `PATCH /resources/{id}` - Update an existing resource - `DELETE /resources/{id}` - Delete a resource - `GET /registeredUsers?email={mail}` - Find the registered user to update - `PATCH /registeredUsers?id={id}` - Update a registered user's email, first and last name - `POST /users/{mail}?action=deleteData` - Delete a user's data (see `deleteUserData`) The resource id goes into the path percent-encoded, so a `/` or a `#` in it cannot build a path naming something else. The user's address is left as it is written — `user@example.com`, `@` being legal in a path segment — which is what `plugins/twake/james` does with the same WebAdmin; whether the two should encode it is issue #175. ### Registered Users A change of the configured mail attribute updates the registered user found by its **previous** address; a change of the first or last name attribute updates the one found by its current address. In both cases the email, first name and last name are re-read from LDAP and sent together. Adding or removing the mail attribute, and users not registered in Calendar, are skipped. Calendar lower-cases the address before looking it up, which is also how it stores addresses, so a case difference between LDAP and Calendar does not matter. The exception is a legacy record whose stored address kept upper case (written without Calendar's normalisation, e.g. migrated data): it is not found, and its user is logged as not registered. Calendar releases before 1.0.0.1 ignore the `email` parameter and answer the full list of registered users; the plugin then picks the user from it, which works but costs a full listing per change. ### Resource identity A resource is known to Calendar by an `id`, which is the value of its LDAP entry's own RDN with the escapes removed: `cn=Meeting Room 1,ou=resources,…` gives `Meeting Room 1`, and `o=Room 12,ou=resources,…` gives `Room 12`. The creation, the update and the deletion hooks all read it that way, so the three name the same resource; an entry whose DN yields no value is not synchronised. Only entries in `DM_CALENDAR_RESOURCE_BASE` are synchronised, on all three hooks. `DM_CALENDAR_RESOURCE_OBJECTCLASS` filters creations only: a modification carries the attributes that changed, so an untouched objectClass is not there to compare, and a deleted entry cannot be read any more. The branch is the guard that remains on those two paths. Renaming an entry changes its RDN, hence its id: Calendar is not told, and the resource keeps its former id there. ### Resource Data Format ```json { "id": "resource-id", "name": "Resource Name", "description": "Optional description", "creator": "admin@example.com", "domain": "example.com" } ``` ## Example Configuration ```bash # Twake Calendar WebAdmin API DM_CALENDAR_WEBADMIN_URL="https://calendar.example.com/webadmin" DM_CALENDAR_WEBADMIN_TOKEN="your-api-token" # LDAP Resources Configuration DM_CALENDAR_RESOURCE_BASE="ou=resources,dc=example,dc=com" DM_CALENDAR_RESOURCE_OBJECTCLASS="device" DM_CALENDAR_RESOURCE_CREATOR="calendar-admin@example.com" DM_CALENDAR_RESOURCE_DOMAIN="example.com" # ldapFlat schema DM_LDAP_FLAT_SCHEMA="/path/to/calendar-resources-schema.json" ``` ## Usage 1. Configure the environment variables 2. Create an ldapFlat schema for calendar resources (see example above) 3. Add the plugin to `DM_PLUGINS`: ```bash DM_PLUGINS="core/ldap/onChange,core/ldap/flatGeneric,twake/calendar" ``` 4. Start ldap-rest - resources will be automatically synced ## Logging The plugin logs all API calls with the following information: - Success/failure status - HTTP status code - Resource DN - Resource name/ID Example log: ```json { "plugin": "calendar", "event": "ldapcalendarResourceadddone", "result": "success", "http_status": 201, "dn": "cn=Meeting Room 1,ou=resources,dc=example,dc=com", "resourceName": "Meeting Room 1" } ``` ## Troubleshooting ### Resources not syncing 1. Check that `DM_CALENDAR_WEBADMIN_URL` is correctly configured and accessible 2. Verify that `DM_CALENDAR_WEBADMIN_TOKEN` is valid 3. Check logs for API errors 4. Ensure the ldapFlat schema has `entity.name` set to `calendarResource` 5. Ensure `DM_CALENDAR_RESOURCE_BASE` is the full DN of the branch the entries are in: a partial value matches no entry, and the plugin says so at startup ### Authentication errors Ensure `DM_CALENDAR_WEBADMIN_TOKEN` is set and valid. The token is sent as a Bearer token in the Authorization header. ### Wrong resources being synced Use `DM_CALENDAR_RESOURCE_BASE` and `DM_CALENDAR_RESOURCE_OBJECTCLASS` to filter which LDAP entries are considered resources. Note that the objectClass filter only applies to creations, so an entry of the resource entity that must never reach Calendar has to sit outside the branch. ## Public Methods Public methods available on the Calendar plugin instance. ### Usage ```typescript import type { DM } from 'ldap-rest'; import type Calendar from 'ldap-rest/plugin-twake-calendar'; // Get the plugin instance from another plugin // (declare `dependencies = { calendar: 'core/twake/calendar' }` so it loads first) const calendar = this.requirePlugin('calendar'); ``` ### deleteUserData(mail) Delete all user data from Twake Calendar via WebAdmin API. Useful for GDPR "right to be forgotten" compliance. **Signature:** ```typescript async deleteUserData(mail: string): Promise<{ taskId: string } | null> ``` **Parameters:** - `mail` (string): User's email address **Returns:** Task information with `taskId`, or `null` on error. **Example:** ```typescript // requirePlugin() returns null when the plugin is not loaded const result = await calendar?.deleteUserData('user@example.com'); if (result) { console.log(`Deletion task started: ${result.taskId}`); } ``` **Reference:** [Twake Calendar deleteData API](https://github.com/linagora/twake-calendar-side-service/blob/main/docs/apis/webadmin.md#deleting-user-data) linagora-ldap-rest-a4bb78e/docs/usage/plugins/integrations/drive.md000066400000000000000000000413021527331160600255570ustar00rootroot00000000000000# Twake Drive Plugin Synchronize LDAP user changes with Twake Drive via Admin API. ## Overview The `twake/drive` plugin automatically synchronizes email address, display name, and disk quota changes from LDAP to [Twake Drive](https://twake.app/). It listens to `onChange` hooks and updates Twake Drive instances via the Admin API. ## Prerequisites 1. **Twake Drive / Cozy** instance with Admin API enabled 2. **onChange plugin** loaded to detect LDAP changes 3. **Cozy domain** - either: - Per-user attribute in LDAP schema (e.g., `twakeCozyDomain`), or - Default domain template configured (e.g., `{uid}.mycompany.cloud`) ## Configuration ```bash npx ldap-rest \ --plugin core/ldap/onChange \ --plugin core/ldap/flatGeneric \ --plugin twake/drive \ --ldap-flat-schema ./static/schemas/twake/users.json \ --mail-attribute mail \ --twake-drive-domain-attribute twakeCozyDomain \ --twake-drive-webadmin-url http://cozy-admin:6060 \ --twake-drive-webadmin-token "your-admin-token" \ ... ``` **Environment Variables:** ```bash DM_TWAKE_DRIVE_WEBADMIN_URL="http://cozy-admin:6060" DM_TWAKE_DRIVE_WEBADMIN_TOKEN="your-admin-token" DM_TWAKE_DRIVE_DOMAIN_ATTRIBUTE="twakeCozyDomain" ``` ### Parameters | Parameter | Description | Default | | --------------------------------------- | ------------------------------------------------------------- | ----------------- | | `--twake-drive-webadmin-url` | Twake Drive Admin API base URL (required) | - | | `--twake-drive-webadmin-token` | Bearer token for Admin authentication | - | | `--mail-attribute` | LDAP attribute for email | `mail` | | `--display-name-attribute` | LDAP attribute for display name | `displayName` | | `--drive-quota-attribute` | LDAP attribute for disk quota (in bytes) | `twakeDriveQuota` | | `--twake-drive-domain-attribute` | LDAP attribute storing user's Twake Drive domain | `twakeCozyDomain` | | `--twake-drive-default-domain-template` | Template for Twake Drive domain (e.g., `{uid}.company.cloud`) | - | | `--twake-drive-concurrency` | Maximum concurrent API requests | `10` | ## Cozy Domain Resolution The plugin determines each user's Cozy domain using the following logic: 1. **Per-user attribute** (if present): Use the `twakeCozyDomain` LDAP attribute value 2. **Default template** (fallback): Build domain from template with LDAP attribute placeholders ### Per-user Cozy domain attribute Users can have an explicit `twakeCozyDomain` attribute for custom domains: ```ldif dn: uid=jdoe,ou=users,dc=example,dc=com uid: jdoe mail: jdoe@example.com twakeCozyDomain: john-doe.partner.cloud ``` ### Default domain template Configure a template for users without an explicit attribute. Use `{attribute}` placeholders: ```bash --twake-drive-default-domain-template "{uid}.mycompany.cloud" ``` Users without `twakeCozyDomain` will have their domain built from the template: - User `uid=jdoe` → Cozy domain `jdoe.mycompany.cloud` Other template examples: - `"{mail}"` → use mail attribute directly as domain - `"{cn}.cloud.example.com"` → use common name ### Combined usage Both can be used together. The per-user attribute takes precedence: ```ldif # User with custom domain (uses twakeCozyDomain) dn: uid=partner1,ou=users,dc=example,dc=com uid: partner1 twakeCozyDomain: partner1.external.cloud # Regular user (uses default suffix → jdoe.mycompany.cloud) dn: uid=jdoe,ou=users,dc=example,dc=com uid: jdoe ``` ## How It Works ### Mail Address Changes When a user's mail attribute changes: 1. **LDAP modify operation** ```bash PUT /api/v1/ldap/users/jdoe { "replace": { "mail": "john.doe@company.com" } } ``` 2. **onChange plugin** detects mail change and triggers `onLdapMailChange` hook 3. **Drive plugin** receives hook and calls Cozy Admin API: ```http PATCH /instances/jdoe.mycompany.cloud?Email=john.doe@company.com&FromCloudery=true ``` 4. **Cozy** updates the instance owner's email address ### Display Name Changes When a user's display name changes: 1. **LDAP modify operation** ```bash PUT /api/v1/ldap/users/jdoe { "replace": { "displayName": "John M. Doe" } } ``` 2. **onChange plugin** triggers `onLdapDisplayNameChange` hook 3. **Drive plugin** updates Cozy instance: ```http PATCH /instances/jdoe.mycompany.cloud?PublicName=John%20M.%20Doe&FromCloudery=true ``` ### Display Name Fallback Logic If `displayName` is not set, the plugin uses fallback logic: 1. `displayName` attribute (first choice) 2. `cn` (Common Name) 3. `givenName` + `sn` (First Name + Last Name) ### Disk Quota Changes When a user's drive quota changes: 1. **LDAP modify operation** ```bash PUT /api/v1/ldap/users/jdoe { "replace": { "twakeDriveQuota": "5368709120" } } ``` 2. **onChange plugin** triggers `onLdapDriveQuotaChange` hook 3. **Drive plugin** updates Twake Drive instance: ```http PATCH /instances/jdoe.company.cloud?DiskQuota=5368709120&FromCloudery=true ``` 4. **Twake Drive** updates the instance disk quota **Note:** The quota value is in bytes. Common values: - 1 GB = `1073741824` - 5 GB = `5368709120` - 10 GB = `10737418240` ## Public Methods Public methods available on the Drive plugin instance: ### Usage ```typescript import type { DM } from 'ldap-rest'; import type Drive from 'ldap-rest/plugin-twake-drive'; // Get the Drive plugin instance const drive = server.getPlugin('drive') as Drive; ``` ### getCozyDomain(dn) Retrieve the Cozy domain for a user given their LDAP DN. **Signature:** ```typescript async getCozyDomain(dn: string): Promise ``` **Example:** ```typescript const domain = await drive.getCozyDomain('uid=jdoe,ou=users,dc=example,dc=com'); // Returns: "jdoe.mycompany.cloud" ``` --- ### getDisplayNameFromDN(dn) Retrieve the display name for a user using fallback logic. **Signature:** ```typescript async getDisplayNameFromDN(dn: string): Promise ``` **Example:** ```typescript const name = await drive.getDisplayNameFromDN( 'uid=jdoe,ou=users,dc=example,dc=com' ); // Returns: "John Doe" ``` --- ### getMailFromDN(dn) Retrieve the email address for a user. **Signature:** ```typescript async getMailFromDN(dn: string): Promise ``` **Example:** ```typescript const mail = await drive.getMailFromDN('uid=jdoe,ou=users,dc=example,dc=com'); // Returns: "jdoe@example.com" ``` --- ### getDriveQuotaFromDN(dn) Retrieve the drive quota for a user (in bytes). **Signature:** ```typescript async getDriveQuotaFromDN(dn: string): Promise ``` **Example:** ```typescript const quota = await drive.getDriveQuotaFromDN( 'uid=jdoe,ou=users,dc=example,dc=com' ); // Returns: 5368709120 (5 GB) ``` --- ### syncUserToCozy(dn) Manually synchronize a user's attributes (email, display name, disk quota) to their Twake Drive instance. Useful for initial sync or manual refresh. **Signature:** ```typescript async syncUserToCozy(dn: string): Promise ``` **Example:** ```typescript const success = await drive.syncUserToCozy( 'uid=jdoe,ou=users,dc=example,dc=com' ); if (success) { console.log('User synced successfully'); } ``` --- ### blockInstance(dn, reason?) Block a user's Cozy instance with an optional reason. **Signature:** ```typescript async blockInstance(dn: string, reason?: string): Promise ``` **Parameters:** - `dn` - User's LDAP DN - `reason` - Optional blocking reason. Common values: - `PAYMENT_FAILED` - Payment issue - `LOGIN_FAILED` - Too many failed login attempts - `SUSPENDED` - Account suspended by admin - Any custom string **Example:** ```typescript // Block with reason await drive.blockInstance( 'uid=jdoe,ou=users,dc=example,dc=com', 'PAYMENT_FAILED' ); // Block without reason await drive.blockInstance('uid=jdoe,ou=users,dc=example,dc=com'); ``` --- ### unblockInstance(dn) Unblock a user's Cozy instance. **Signature:** ```typescript async unblockInstance(dn: string): Promise ``` **Example:** ```typescript await drive.unblockInstance('uid=jdoe,ou=users,dc=example,dc=com'); ``` --- ### deleteInstance(dn) Delete a Cozy instance and all its data. **Signature:** ```typescript async deleteInstance(dn: string): Promise ``` Calls `DELETE /instances/:domain` on the Cozy Admin API. Returns `true` if the instance was successfully deleted or if the instance was not found (404). Returns `false` on error. **Example:** ```typescript await drive.deleteInstance('uid=jdoe,ou=users,dc=example,dc=com'); ``` ## Cozy Admin API ### Endpoints Used | Operation | Endpoint | Method | | --------------- | ----------------------------------------------------------------------------------------- | ------ | | Update email | `/instances/{domain}?Email={email}&FromCloudery=true` | PATCH | | Update name | `/instances/{domain}?PublicName={name}&FromCloudery=true` | PATCH | | Update quota | `/instances/{domain}?DiskQuota={bytes}&FromCloudery=true` | PATCH | | Combined update | `/instances/{domain}?Email={email}&PublicName={name}&DiskQuota={bytes}&FromCloudery=true` | PATCH | | Delete instance | `/instances/{domain}` | DELETE | ### Available Cozy Parameters The Cozy Admin API supports the following parameters (query string): | Parameter | Type | Description | | ---------------- | ------- | ---------------------------------------------------- | | `Email` | string | Owner email address (supported by this plugin) | | `PublicName` | string | Display name (supported by this plugin) | | `Locale` | string | Locale (e.g. `fr`, `en`) | | `Timezone` | string | Timezone (e.g. `Europe/Paris`) | | `Phone` | string | Phone number | | `DiskQuota` | integer | Disk quota in bytes | | `Blocked` | bool | Block or unblock the instance | | `BlockingReason` | string | Reason code (e.g. `PAYMENT_FAILED`, `LOGIN_FAILED`) | | `FromCloudery` | bool | Skip Cloudery callback (always true for this plugin) | See the [Cozy Admin Documentation](https://docs.cozy.io/en/cozy-stack/admin/#patch-instancesdomain) for the complete list. ### FromCloudery Parameter The `FromCloudery=true` query parameter is always included to: - Prevent the Cozy stack from sending callback notifications - Avoid notification loops when changes originate from the LDAP directory - Indicate the change is administrative (from the cloudery/provisioning system) ### Authentication The Cozy Admin API uses bearer token authentication: ```bash --twake-drive-webadmin-token "your-admin-token" ``` The plugin automatically adds the `Authorization: Bearer {token}` header to all requests. **Note:** The Cozy Admin API typically runs on port 6060 and should be protected at the network level. ## Examples ### Example 1: Basic Setup ```bash --plugin core/ldap/onChange \ --plugin core/ldap/flatGeneric \ --plugin twake/drive \ --ldap-flat-schema ./schemas/twake/users.json \ --mail-attribute mail \ --twake-drive-webadmin-url http://cozy-admin:6060 ``` ### Example 2: With Authentication ```bash --plugin core/ldap/onChange \ --plugin core/ldap/flatGeneric \ --plugin twake/drive \ --ldap-flat-schema ./schemas/twake/users.json \ --mail-attribute mail \ --twake-drive-webadmin-url http://cozy-admin:6060 \ --twake-drive-webadmin-token "cozy-admin-secret" ``` ### Example 3: Complete Twake Setup (James + Drive) ```bash --plugin core/auth/token \ --plugin core/ldap/onChange \ --plugin core/ldap/flatGeneric \ --plugin core/ldap/groups \ --plugin twake/james \ --plugin twake/drive \ --ldap-flat-schema ./schemas/twake/users.json \ --mail-attribute mail \ --quota-attribute mailQuotaSize \ --james-webadmin-url http://james:8000 \ --james-webadmin-token "james-admin-token" \ --twake-drive-webadmin-url http://cozy-admin:6060 \ --twake-drive-webadmin-token "cozy-admin-token" \ --twake-drive-domain-attribute twakeCozyDomain \ --auth-token "api-admin-token" ``` ## Logging The plugin logs all operations: ### Successful Operations ```json { "level": "info", "plugin": "drive", "event": "onLdapMailChange", "result": "success", "http_status": 200, "dn": "uid=jdoe,ou=users,dc=example,dc=com", "cozyDomain": "jdoe.mycompany.cloud", "Email": "john.doe@company.com" } ``` ### Skipped Operations ```json { "level": "debug", "message": "Skipping mail change for uid=jdoe,...: no Cozy domain attribute (twakeCozyDomain)" } ``` ### Failed Operations ```json { "level": "error", "plugin": "drive", "event": "onLdapMailChange", "result": "error", "http_status": 500, "http_status_text": "Internal Server Error", "dn": "uid=jdoe,ou=users,dc=example,dc=com", "cozyDomain": "jdoe.mycompany.cloud" } ``` ### Instance Not Found ```json { "level": "debug", "plugin": "drive", "event": "onLdapMailChange", "result": "ignored", "http_status": 404, "message": "Cozy instance not found (may not be provisioned yet)" } ``` ## Error Handling ### Non-Existent Instance (404) If the Cozy instance doesn't exist yet: - Error is logged at DEBUG level (not ERROR) - LDAP operation succeeds - This is normal during user provisioning flow ### Cozy Server Down If the Cozy Admin API is unreachable: - Error is logged - LDAP operation succeeds - Manual sync may be required later using `syncUserToCozy()` ### Invalid Token If the admin token is invalid: - 401 or 403 error is logged - LDAP operation succeeds - Check token configuration ## Synchronization Scenarios ### Scenario 1: User Creation 1. Create user in LDAP with `twakeCozyDomain` attribute 2. Provision Cozy instance separately (via cloudery or API) 3. Future mail/name changes are synced automatically 4. Use `syncUserToCozy()` for initial sync if needed ### Scenario 2: Email Change 1. User changes email in LDAP: `old@company.com` → `new@company.com` 2. Drive plugin updates Cozy instance email 3. Cozy updates instance owner's email 4. User receives notifications at new email ### Scenario 3: Name Change 1. Admin updates user's display name in LDAP 2. Drive plugin updates Cozy instance PublicName 3. User's name is updated across Cozy apps ### Scenario 4: Manual Sync ```typescript // Sync all attributes for a user const drive = server.getPlugin('drive') as Drive; await drive.syncUserToCozy('uid=jdoe,ou=users,dc=example,dc=com'); ``` ## Limitations 1. **One-way sync**: LDAP → Cozy only. Cozy changes don't sync back to LDAP. 2. **No instance creation**: Plugin doesn't create Cozy instances, only updates existing ones. 3. **Domain required**: Users must have the `twakeCozyDomain` attribute to be synced. ## Troubleshooting ### Problem: Changes Not Syncing **Solutions:** 1. Verify onChange plugin is loaded: ```bash --plugin core/ldap/onChange ``` 2. Check Cozy domain attribute is set for the user: ```bash ldapsearch -b "uid=jdoe,ou=users,dc=example,dc=com" twakeCozyDomain ``` 3. Enable debug logging: ```bash --log-level debug ``` 4. Verify Cozy Admin API is reachable: ```bash curl http://cozy-admin:6060/instances ``` ### Problem: 404 Errors **Symptoms:** ```json { "level": "debug", "message": "Cozy instance not found" } ``` **Solutions:** 1. Provision Cozy instance for the user first 2. Verify `twakeCozyDomain` attribute matches the actual Cozy domain 3. Check Cozy logs for instance existence ### Problem: Authentication Errors **Symptoms:** ```json { "http_status": 401, "http_status_text": "Unauthorized" } ``` **Solutions:** 1. Verify admin token is correct 2. Check token hasn't expired 3. Ensure Admin API is configured to accept bearer tokens ## Integration with James When using both James and Drive plugins, email changes are propagated to both services: ```bash --plugin twake/james \ --plugin twake/drive ``` Both plugins listen to `onLdapMailChange` and update their respective services in parallel. ## See Also - [onChange.md](onChange.md) - LDAP change detection - [james-mail.md](james-mail.md) - James mail server integration - [Cozy Admin Documentation](https://docs.cozy.io/en/cozy-stack/admin/) - Admin API reference linagora-ldap-rest-a4bb78e/docs/usage/plugins/integrations/groups.md000066400000000000000000000300761527331160600257730ustar00rootroot00000000000000# Organization Groups `core/twake/groups` is [`core/ldap/groups`](../ldap/groups.md) with one group branch per organization. It serves the groups of an organization under `/api/v1/organizations/:id/groups`, in place of the flat `/api/v1/ldap/groups`. ## Behaviour - An organization's groups and users live in branches given by DN patterns, where `{org}` stands for the organization id. - A group is a `groupOfNames` with a generated `cn` (a UUID), its name in the display name attribute, an optional color and its creation date. - A group's members belong to its organization: adding a user of another organization, or of no organization, is refused with a 400, whichever API writes the group (REST, SCIM, another plugin), and so is moving a group with its members, tombstones included, straight into another organization's group branch: that refusal names none of them. The placeholder (`--group-dummy-user`) is the one exception. Not checked: moving a group out of the organizations' branches (to `ou=archive,…`, for instance) or deeper than one level under another organization, which takes its members along, and moving a user to another organization, whose groups in the one it left keep it as a member. - An empty group holds the placeholder, because `groupOfNames` needs a member. The placeholder is never listed. - A tombstone (see [tombstone](tombstone.md)) keeps its memberships until it is erased, and is hidden from every member list read through LDAP-Rest. A SCIM `PUT` of a group replaces its whole member list, so it drops the hidden tombstones too; the routes below add and remove one member at a time and keep them. Hiding them costs one search of each organization's user branch per search that returns a group with its members, whichever plugin makes it. - An erased member leaves its groups through the directory: enable the `refint` overlay on `member`. Unlike `core/ldap/groups`, this plugin does not remove a deleted entry from its groups itself, since that write would announce a member removed on an erase. - Every route of an organization whose status says deleted answers 410. ## Routes Every route answers 404 `ORGANIZATION_NOT_FOUND` when the organization entry is missing or the caller cannot read it, and 410 `ORGANIZATION_DELETED` when it is deleted. Errors are `{ "error": "...", "code": "..." }`. A request an authorization plugin refuses answers 403 `REFUSED`. The routes read as the caller: the authorization plugins decide which groups and members it gets. A group it cannot read answers 404, a user it cannot read is not found (404 `USER_NOT_FOUND` when adding it), and a member it cannot read lists as `{ "uid": "..." }`. A write is judged before anything is looked for, so a caller who may not make it gets 403 whether the name is taken, the users exist or they are already members. What the caller may read is the authorization model's to say: - A model judging by branch (`core/auth/authzPerBranch`) gives an organization's groups and users to the callers granted its branch. - A model judging entries by their organization link (`core/auth/authzLinid1`, or `--authz-filter-attached-entries`) gives an entry carrying no link to every administrator. Under such a model only, every group written in an organization's branch — through these routes, SCIM or another plugin — is given the organization link (`--ldap-organization-link-attribute`, with the auxiliary class `twakeGroup` when it holds neither it nor `twakeStaticGroup`), and follows it when moved to another organization; a link naming another organization is refused (400). A group is then judged on its organization: creating or changing one needs write on the organization, not on its group branch only. What stays every administrator's: whether an organization exists or is deleted, its spaces, and users carrying no link, whose link is the provisioning tool's to set. Such users are in transit: any administrator may claim them into an organization they manage, the first to do so taking them — see [transit](../auth/authz-linid1.md#transit-handing-an-entry-over). With `core/ldap/organizations` loaded too, the organizations must lie under `--ldap-top-organization`, where it accepts a link. - A plugin judging every read made for the request, as `core/auth/authzDynamic` does, judges the organization check too: an organization its token does not reach answers 404, as one that is not there. A group reads: ```json { "id": "8f14e45f-ceea-467d-9a3e-7c2b0a1d5e10", "cn": "8f14e45f-ceea-467d-9a3e-7c2b0a1d5e10", "displayName": "Engineering", "description": "Engineering team", "color": "#3366FF", "organizationId": "acme", "baseDN": "cn=8f14e45f-ceea-467d-9a3e-7c2b0a1d5e10,ou=groups,ou=acme,dc=example,dc=com", "members": ["jdoe"], "createdAt": "2026-01-23T11:00:00.000Z" } ``` `members` are the RDN values of the organization's users. A group without a display name reads its `cn`. - `GET /groups?page&limit&search&sortBy&sortOrder`: `search` (2 characters or more; an empty one lists everything) matches the display name — the `cn` when the group has none — and the description; `sortBy` is `displayName`, `description` or `createdAt`. Answers `{ organizationId, groups, pagination: { page, limit, total, totalPages } }`. - `POST /groups` with `{ name, description?, color? }`: `name` is at most 256 characters, not blank, without control characters, and unique in the organization among groups made here (409 `GROUP_EXISTS`); `color` is `#RGB` or `#RRGGBB`. Answers 201 with the group. - `GET /groups/:groupId`: the group, or 404 `GROUP_NOT_FOUND`. - `PATCH /groups/:groupId` with any of `name`, `description`, `color`: an empty `description` or `color` clears it; any other field is a 400. - `DELETE /groups/:groupId`. - `GET /groups/:groupId/members?page&limit&search&sortBy&sortOrder`: the members' public profiles, `sortBy` `uid` (default), `displayName`, `mail` or `jobTitle`. A member that cannot be read is `{ "uid": "..." }`. - `POST /groups/:groupId/members` with `{ usernames: [...] }`: every user must exist in the organization (404 `USER_NOT_FOUND`, nothing added), at most `--twake-group-max-page-limit` per request. - `DELETE /groups/:groupId/members/:userId`: 404 `MEMBER_NOT_FOUND` for a user who is not a member. Name uniqueness holds for groups made through these routes only: an identity provider may push two groups of one name, and a SCIM create is never refused for it. ## Configuration ```bash --plugin core/twake/groups \ --twake-group-base 'ou=groups,ou={org},dc=example,dc=com' \ --twake-group-user-base 'ou=users,ou={org},dc=example,dc=com' \ --twake-group-organization-dn 'ou={org},dc=example,dc=com' \ --group-class top,groupOfNames,twakeGroup \ --group-dummy-user cn=placeholder \ --group-schema static/schemas/twake/organizationGroups.json ``` - `--group-schema`: `static/schemas/twake/organizationGroups.json` declares the groups' `cn`, members and description. The default group schema describes department groups and requires `twakeDepartmentLink`, which these groups do not have. Groups created through SCIM are written directly and are not checked against this schema. - `--twake-group-base`, `--twake-group-user-base`: required, each holding `{org}` once. - `--twake-group-organization-dn`: the organization entry. Empty means no organization is checked. - `--twake-group-organization-status-attribute` (default `twakeOrgStatus`), `--twake-group-organization-deleted-value` (default `deleted`). - `--twake-group-display-name-attribute` (default `twakeDisplayName`), `--twake-group-color-attribute` (default `twakeGroupColor`), `--twake-group-created-at-attribute` (default `twakeCreatedAt`): the group's attributes. The group classes (`--group-class`) must allow them. The plugin adds them to the loaded group schema under these names, in any case, so a renamed attribute needs no schema of its own. - `--twake-group-max-page-limit` (default 1000): the largest page, and the most users added in one request. - `--twake-group-member-fields`: JSON of `{ field: attribute }` for the member profile, over the defaults `uid`, `_id` (`entryUUID`), `cn`, `sn`, `givenName`, `displayName`, `mail`, `mobile`, `jobTitle` (`twakeJobTitle`), `company` (`twakeCompany`), `organizationRole` (`twakeOrganizationRole`) and `organizationId` (`twakeOrganizationId`). A profile also has `name` from `sn`, `givenName`, `twakeAdditionalName` and `twakeNamePrefix`, and `isTechnical` from `twakeIsTechnical`. - Tombstones are recognized by `--twake-lifecycle-deleted-attribute` and `--twake-lifecycle-deleted-value`, as [tombstone](tombstone.md) writes them. ## Events [Lifecycle events](lifecycle-events.md) publishes a group's changes from the directory writes, whichever API made them. A rule for the pattern above: ```json { "dn": "^cn=(?[^,]+),ou=groups,ou=(?[^,]+),dc=example,dc=com$", "exchange": "groups", "events": { "created": { "routingKey": "group.created", "payload": { "organizationId": "$dn.org", "id": "$dn.id", "name": "$twakeDisplayName", "description": "$description", "color": "$twakeGroupColor", "createdAt": "$twakeCreatedAt", "members": { "$members": { "username": "$uid", "email": "$mail" } }, "timestamp": "$now" } }, "updated": { "routingKey": "group.updated", "payload": { "organizationId": "$dn.org", "id": "$dn.id", "name": "$changed.twakeDisplayName", "description": "$changed.description", "color": "$changed.twakeGroupColor", "timestamp": "$now" } }, "memberAdded": { "routingKey": "group.member.added", "payload": { "organizationId": "$dn.org", "id": "$dn.id", "members": { "$added": { "username": "$uid", "email": "$mail", "firstName": "$givenName", "lastName": "$sn" } }, "timestamp": "$now" } }, "memberRemoved": { "routingKey": "group.member.removed", "payload": { "organizationId": "$dn.org", "id": "$dn.id", "members": { "$removed": { "username": "$uid", "email": "$mail" } }, "timestamp": "$now" } }, "deleted": { "routingKey": "group.deleted", "payload": { "organizationId": "$dn.org", "id": "$dn.id", "timestamp": "$now" } } } } ``` Erasing a member publishes no `memberRemoved` when the directory's `refint` removes it from its groups, since that write does not go through LDAP-Rest. Without `refint`, the [tombstone](tombstone.md) erase takes the member out of the groups under `--twake-tombstone-group-bases` itself, through LDAP-Rest: each of those groups then publishes a `memberRemoved` naming the member by its RDN. That option defaults to the flat group base, which holds no group here; point it at the organizations' group branches. ## SCIM groups SCIM groups take the organization's group branch through SCIM's per-request group base (`--scim-group-base-header`, `--scim-base-header-root`), the same classes through `--scim-group-object-class`, and the provider's `externalId` through `--scim-group-external-id-attribute`. Map SCIM `displayName` to the display name attribute in `--scim-group-mapping`: the name is kept there, SCIM reads, updates and `displayName` filters go through it, and a SCIM group of an organization gets a generated `cn` (a UUID), which is also its SCIM `id`, and a creation date, as a group made by the routes does. The date is added only to a group that holds the display name attribute, so classes that allow the display name must allow the creation date too: the default attributes both come with `twakeGroup`, but a display name kept in an attribute `groupOfNames` allows, such as `description`, next to the default `twakeCreatedAt` makes every SCIM create fail. Without that mapping, a SCIM group's `cn` is its `displayName`, and the plugin logs a warning at startup. A group whose `cn` is already its `displayName` keeps that `cn` and `id`. ## Dependencies ``` core/twake/groups └─ extends: core/ldap/groups ``` linagora-ldap-rest-a4bb78e/docs/usage/plugins/integrations/instances.md000066400000000000000000000253561527331160600264500ustar00rootroot00000000000000# Workplace Instances `core/twake/instances` gives every account this server creates its workplace instance. It replaces `core/twake/cozyProvision` and `core/twake/clouderyProvision`, and refuses to start when either is loaded too. What changes when moving to it is in the [upgrade notes](../../upgrading.md#twakeinstances-replaces-cozyprovision-and-clouderyprovision). Every account must be created through this server (REST, SCIM, the LSC plugin, scripts through the API). An entry written straight to the directory gets no instance. ## Behaviour - Once an add has written an entry whose DN matches `--twake-instance-dn`, the instance is looked up, then asked for if missing, before the add answers (through the [`ldapaddafter`](../../../plugin-development/hooks.md#ldapaddafter) hook). An entry holding `--twake-instance-skip-value` in `--twake-instance-skip-attribute`, a technical account for instance, gets none, and neither does a tombstone (`--twake-lifecycle-deleted-attribute`) put back in place. Each expression is tried against the canonical DN first, then against the DN as sent: an account created under a branch written with other spacing or case is still one, and an expression written with the directory's own spelling (`\2C`, a doubled space) still matches. An expression naming a value that holds a comma writes it `\2C` or `\,`: a value comma never stands in for an RDN separator in the form matched. - The address (`--twake-instance-fqdn-attribute`) and the mark (`--twake-instance-sent-attribute`) are this plugin's to write: an add that carries them has them removed, and an address written by a modify is taken only when it is the one the provider gives the account, or once the provider confirms it. - A refused, unreachable or slow provider leaves the entry written and without an address: it is pending, and the error is logged. The add itself succeeds, whatever the door (REST, SCIM, bulk import). `--twake-instance-timeout` bounds each call, so a lookup, a creation and, with cozy-stack, the onboarding can hold an add up to three times as long. - Once the instance exists, its address is written, `user.created` is published on the auth exchange, without a password hash, and the entry is marked with the date. The event is sent at least once: a failure between the publication and the mark sends it again on the next attempt, and two replicas handling the same account at once may both send it. Within one replica, the work on one account is done one step at a time. - An entry another tool created, whose classes do not allow the address or the mark, is given the auxiliary class of `--user-class` that allows it, as the [repair of entries created by other tools](../ldap/flat-generic.md#entries-created-by-other-tools) does, whether the address comes from `workplace.created` or from making sure of its instance. - The server does not start without a reachable broker, and an event that cannot be published fails its attempt rather than being marked as sent. - With cozy-stack, deleting an account destroys its instance, when that instance's OIDC id is this account's. ## Making sure an account has its instance `POST /api/v1/twake/instances/ensure` with `{ "dn": … }` or `{ "mail": … }` (or `ensureInstance(dn)` from another plugin) writes a missing address and announces the account when its instance exists, and asks for the instance when it does not. It answers `ready` or `pending`, and `400` when the body names no account. The route reads and writes the account with the caller's rights: an account the caller cannot read answers `404`, as a missing one does, and one it cannot write is refused before the provider or the broker hear of it. Pending accounts are the entries under the `--twake-instance-dn` branches with a mail and no address, for instance `(&(mail=*)(!(twakeWorkspaceUrl=*)))`, technical accounts aside. The route can be called for each of them: an account already announced answers `ready` and is left as it is. ## Organizations `ensureOrganization({ id, name, domain })` asks for an organization's own instance, for the application that creates organizations to call once it has written the organization entry. With the Cloudery it creates the Cloudery organization first. The organization entry, found under `--twake-instance-organization-base` by `--twake-instance-organization-id-attribute`, gets the address when `--twake-instance-organization-fqdn-attribute` names where. With the Cloudery the organization is then linked to its instance (`PATCH /api/v2/organizations/` with `instance_fqdn`), which the Cloudery needs before it takes its members' `user.created`. Then `organization.created` is published on the b2b exchange, and the entry is marked. The message carries the organization's id, address, domain, and its name as `organization`, read from `--twake-instance-organization-name-attribute`, which `ensureOrganization` fills from `name` when the entry has none. An id captured from a `--twake-instance-dn` group is read from the organization entry when one exists, so the case a caller spells the branch in is not sent. The mark, not the address, says the organization is done. With the Cloudery, a member's `user.created` waits for its organization's: a member under an organization entry that is not marked yet gets its address but no event and no mark. A member outside its organization entry is not held, since nothing would find it to release it: it is announced with a warning, and needs sending again once the organization is linked. A stored address the provider does not confirm keeps a member held, with a warning, until `ensureInstance` sends it. Once the organization is marked, every member under its entry with an address and no mark gets its `user.created`, each on its own: one that fails is logged, the others are still sent, and the message is redelivered. A stored address is sent only if it is the one the provider gives the account, or once the provider confirms it. Nothing of this is kept in memory: the organization's `workplace.created` again, or `ensureOrganization` on a marked organization, links the organization again and sends whatever is still held, from any replica. That also links an organization marked before the link existed. The Cloudery builds no member instance for an organization it does not have. Before asking for a member's instance, the plugin asks for its organization and the organization's instance when the Cloudery has neither, so making sure of a member also repairs an organization whose creation failed. An organization created before 0.13.0 has a built instance but was never announced, and that instance is owned at the platform domain rather than `@`, as a personal account's is. The plugin takes it as the organization's only when the Cloudery organization is linked to it, and announces the organization when one of its members is added or made sure of. Announcing writes the organization with the plugin's rights, so making sure of a member through the API announces it only for a caller who may write the organization entry, and is refused with a `403` otherwise. ## Providers - `cloudery`: the Cloudery accepts the request and builds the instance afterwards. An instance already at the account's address is taken when it is this account's, so a re-created account keeps its instance. The address arrives with `workplace.created`, consumed on `--twake-instance-queue`, with a dead-letter queue. The message goes to an organization when its `twakeId` is an organization id and its `internalEmail` that organization's own (`@`); otherwise to the account with that `internalEmail`. - `cozy-stack`: the admin API builds the instance before answering, and the address is written in the request. The instance's onboarding is marked as finished, so its first login does not open the setup wizard. An instance already there is taken only when its email and OIDC id are this account's. The instance id is `--twake-instance-id`, a template of the account's `uid` and the named groups of the matching DN expression, for example `{uid}{org}`. It is lowercased and reduced to `[a-z0-9-]`: the Cloudery refuses other characters in a slug, such as `_` or `@`, so `jane.doe_stg` gives `janedoestg`. Two `uid`s that reduce to the same slug cannot both have an instance: the second is refused, as an instance already there that is not its own. A name no expression captures stops the server at startup. It is the slug of the instance's address; the OIDC id is this slug with the Cloudery, and the `uid` with cozy-stack. `user.created` names the account by its `uid` (`twakeId`), and gives its Matrix ID as `matrixId`, `@local:domain` from its lowercased `mail`; a mail that gives no valid Matrix ID sends none, and [cozy-stack](https://github.com/cozy/cozy-stack/blob/3c05972703d8f6c1fe71c9cf1b7a747607a4b395/model/settings/common/common.go#L321-L329) derives one from the instance's email. A group named `org` is the organization id sent to the provider and in `user.created`, with the mail's domain as its domain; with cozy-stack and no such group, `--twake-instance-cozy-org-id` and `--twake-instance-cozy-org-domain` stand for them. ## Configuration ```bash --plugin core/twake/instances \ --twake-instance-dn '^uid=(?[^,]+),ou=users,ou=(?[^,]+),ou=organizations,dc=example,dc=com$' \ --twake-instance-id '{uid}{org}' \ --twake-instance-skip-attribute employeeType \ --twake-instance-skip-value technical \ --twake-instance-provider cloudery \ --twake-instance-cloudery-url https://manager.example.com \ --twake-instance-cloudery-token … \ --twake-instance-cloudery-domain example.com \ --twake-instance-cloudery-organization-offer organization \ --twake-instance-organization-base ou=organizations,dc=example,dc=com ``` A DN holds commas, so in `DM_TWAKE_INSTANCE_DN` end each expression with `;`. With the Cloudery, `--twake-instance-cloudery-offer` is the offer of every account's instance, or one offer per `--twake-instance-dn` expression, separated by `;` in the same order: `personal;business` gives the accounts the first expression matches `personal`, those of the second `business`. A final `;` is ignored. An expression with an empty item, or none, leaves the choice to the Cloudery. More offers than expressions stop the server at startup. An organization's own instance keeps `--twake-instance-cloudery-organization-offer`. Every option and its default is listed in the [configuration reference](../../configuration.md). The plugin needs `--rabbitmq-url`. The directory schema must define the address and mark attributes, and with `--twake-instance-organization-base` the organization domain (and address) attributes: the defaults `twakeWorkspaceUrl`, `twakeCreatedEventAt` and `twakeDomain` come from the Twake schema, which this repository does not ship. The server refuses to start when one is missing. linagora-ldap-rest-a4bb78e/docs/usage/plugins/integrations/james-mail.md000066400000000000000000000762111527331160600264740ustar00rootroot00000000000000# Twake James Mail Server Plugin Synchronize LDAP user changes with Apache James mail server via WebAdmin API. ## Overview The `twake/james` plugin automatically synchronizes email address and quota changes from LDAP to [Apache James](https://james.apache.org/) mail server. It listens to `onChange` hooks and updates James via its WebAdmin REST API. ## Prerequisites 1. **Apache James** mail server with WebAdmin API enabled 2. **onChange plugin** loaded to detect LDAP changes 3. **Mail and quota attributes** configured in LDAP schema ## Configuration ```bash --plugin core/ldap/onChange \ --plugin core/ldap/flatGeneric \ --plugin twake/james \ --ldap-flat-schema ./static/schemas/twake/users.json \ --mail-attribute mail \ --quota-attribute mailQuotaSize \ --james-webadmin-url http://james:8000 \ --james-webadmin-token "your-admin-token" ``` **Environment Variables:** ```bash DM_JAMES_WEBADMIN_URL="http://james:8000" DM_JAMES_WEBADMIN_TOKEN="your-admin-token" ``` ### Parameters - `--james-webadmin-url`: James WebAdmin API base URL (required) - `--james-webadmin-token`: Bearer token for James WebAdmin authentication (optional) - `--mail-attribute`: LDAP attribute for email (default: `mail`) - `--quota-attribute`: LDAP attribute for quota (default: `mailQuotaSize`) - `--alias-attribute`: LDAP attribute for email aliases (default: `mailAlternateAddress`) - `--forward-attribute`: LDAP attribute for forwards (default: `mailForwardingAddress`) - `--james-sync-forwards`: copy that attribute to Twake Mail's forwards (default: `false`), see [Forwards](#forwards) - `--james-mailbox-type-attribute`: LDAP attribute for mailbox type (default: `twakeMailboxType`) - `--james-retry-attempts`: attempts in all of a call about a user James may not see yet (default: `5`), see [Replication lag](#replication-lag) - `--james-retry-delay`: base delay between those attempts, in ms (default: `1000`); the total wait is delay × n(n−1)/2 for n attempts ## How It Works ### Mailing Lists Mailing lists are not synchronized: Twake Mail reads them from LDAP, see [Mailing lists](james-mailboxes.md#mailing-lists). Groups whose `twakeMailboxType` is `teamMailbox` become James team mailboxes, see [James Team Mailboxes](james-mailboxes.md). ### Groups James has accounts for users only. An entry whose classes include `groupOfNames`, `groupOfUniqueNames` or `groupOfURLs` is a group: the plugin creates no identity, quota, alias, forward or delegation for its address, and does not rename it in James when its `mail` changes. Its address is a mailing list or a team mailbox, as above. A deployment whose groups (`--group-class`) carry none of these classes has its groups handled as users. So is an entry whose classes cannot be read when the change is followed. ### Mail Address Changes When a user's mail attribute changes: 1. **LDAP modify operation** ```bash PUT /api/v1/ldap/users/jdoe { "replace": { "mail": "john.doe@company.com" } } ``` 2. **onChange plugin** detects mail change and triggers `onLdapMailChange` hook 3. **James plugin** receives hook and calls James WebAdmin API: ```http POST /users/old@company.com/rename/john.doe@company.com?action=rename ``` 4. **James** renames the mail account and its mailboxes, and adds a forward from the old address to the new one, so that mail sent to the old address still arrives. It leaves the identities under the old address. 5. **James plugin** moves the aliases (see [Aliases and Mail Changes](#aliases-and-mail-changes)) and gives the new address its [default identity](#display-name-and-identity) back, with the signatures of the old one when `--james-signature-template` is not set. #### Forwards Left on an Address The forward a rename leaves outlives the account. When an address becomes the `mail` or an alias of an account, at its creation, a rename, a mail added to an existing entry or an alias add, the plugin removes the forwards James holds from it, except the entry's own when the directory owns forwards (`--forward-attribute`, with `--james-sync-forwards`): ```http GET /address/forwards/old@company.com DELETE /address/forwards/old@company.com/targets/john.doe@company.com ``` Otherwise the new owner of the address would not get its mail. ### Quota Changes When a user's quota changes: 1. **LDAP modify operation** ```bash PUT /api/v1/ldap/users/jdoe { "replace": { "mailQuotaSize": "5000000000" } } ``` 2. **onChange plugin** triggers `onLdapQuotaChange` hook 3. **James plugin** updates quota via WebAdmin API: ```http PUT /quota/users/jdoe@company.com/size Content-Type: text/plain 5000000000 ``` Removing the quota, or setting it to `0`, deletes the user quota instead: James would read `0` as a 0 byte mailbox. The user then gets the domain or global quota set in James. ```http DELETE /quota/users/jdoe@company.com/size ``` ### Forwards Forwards are a setting of the user in Twake Mail, beside "keep a copy in my mailbox", and Twake Mail Admin can change them too. By default the plugin does not copy `--forward-attribute` to them. It still removes the [forwards left](#forwards-left-on-an-address) on an address an account takes: Twake Mail keeps a user's forwards under their own address, and none can have been set on an address the account is only now taking. With `--james-sync-forwards`, the directory owns them: each change of `--forward-attribute` is applied to Twake Mail, and overwrites what the user set there. ```http PUT /address/forwards/jdoe@company.com/targets/john@partner.com DELETE /address/forwards/jdoe@company.com/targets/old@partner.com ``` Only the change is applied: a forward the user added in Twake Mail stays until the directory names it and removes it, and the directory is never told about the forwards set in Twake Mail. ### Mail Domains James accepts mail only for the domains it knows. The plugin creates in James each mail domain the directory declares, and removes it once no entry declares it any more. A domain entry is an entry under the `base` of a flat schema giving one of its attributes the `domainName` role, at any depth: with the shipped `twake/nomenclature/twakeDomain.json`, the `associatedDomain` of the entries under `ou=domains,ou=nomenclature,`. A deployment may nest them, `dc=customs,dc=gov,dc=mn,ou=domains,…`, under nodes declaring no domain: those are left alone. When no loaded schema declares the role, the plugin falls back to that branch and to `--enterprise-domain-name-attribute` (`associatedDomain` by default). | Directory write | WebAdmin call | | ---------------------------------------------------- | ------------------------------------- | | Domain entry added | `PUT /domains/{domain}`, for each one | | Domain value added or changed (`a.org` → `b.org`) | `PUT /domains/b.org` | | Domain value removed or changed, entry deleted | `DELETE /domains/a.org` | | Entry moved into, out of, or between domain branches | as an add, a delete, or a change | Values are lower-cased, as James stores them. `*`, which the [enterprise rules](../ldap/enterprise-rules.md) read as "every domain allowed", is no domain and is never pushed, nor is a value that is not a fully qualified domain name. A domain another entry still declares is not removed. The plugin reads nothing from James first, and does not rely on its answer to a repeated call. In Apache James's WebAdmin ([`DomainsRoutes`](https://github.com/apache/james-project/blob/master/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/routes/DomainsRoutes.java), as of [`53b40cd`](https://github.com/apache/james-project/commit/53b40cdffc5826a94c9a16593e72ad98f49019d3)), `PUT /domains/{domain}` answers `204` for a domain it already has, and `DELETE /domains/{domain}` answers `204` for one it does not know, and also removes the domain aliases pointing to it; it answers `400` to the removal of an auto-detected domain. A call James answers `500`, `502`, `503` or `504` is retried, `--james-retry-attempts` times in all, as the [account calls](#replication-lag) are on `400` and `404`; one that fails still, or cannot reach James at all, is logged, and the directory write stands. A delete, a move out of the domain branches, or a move into another one taking a domain from the entry, is refused with `409` while the domain is still in use: - an organization links the entry (on a delete, or a move out of every domain branch): its link attribute names the entry's DN, compared as a DN — linking `dc=gov,dc=mn,…` is no link to `dc=customs,dc=gov,dc=mn,…`. The attribute is the one the organization schema (`--organization-schema`, `twake/organizations.json`: `twakeDomainLink`) gives the `domainLink` role, or `--enterprise-domain-link-attribute`; with neither, nothing is checked; - an entry holds an address at the domain, in `--mail-attribute` or `--alias-attribute`, the `smtp:` form of an alias included: James would drop the domain, and their mail would bounce. The entries holding either attribute are read and their domains compared by the plugin, a substring filter (`*@domain`) finding nothing where the directory declares no substring rule for the attribute: on a large directory, a delete takes the time of that walk. Removing or changing a domain value is refused on the second ground only: the organizations linking the entry follow it, whatever it names. The searches run as the server, so that an entry the caller cannot read still counts; the error names neither the organization nor the address, the log does. #### Trash The [trash](../ldap/trash.md) turns a delete into a move that no request hook judges, and that runs before or after this plugin's delete guard depending on the order the plugins were loaded in. Keep the domain branch out of `--trash-watched-bases`, which watches every branch when empty: the plugin warns at startup when the domains are watched. If it is, a domain moved to the trash is removed from James unless an address is still at it, in which case it is kept and an error logged; one moved back from the trash is created again. The organizations linking it are not checked. The domains are pushed as the directory changes: those it held before the plugin was loaded are not created in James at startup. With `authzLinid1`, nobody writes outside the organization tree: name the domain branch in `--authz-global-write-branches` so that the administrators of the whole directory add and delete domains, while every administrator still reads them ([global branches](../auth/authz-linid1.md#global-branches)). ### Display Name and Identity When a user is added, renamed, or their display name changes, the plugin names their default identity in Twake Mail after the directory. The name is the first one found in `--display-name-attribute` (`displayName` by default), `cn`, then `givenName` and `sn`. The optional signature is `--james-signature-template`. 1. **James plugin** reads the default identity, the deletable one with the lowest `sortOrder`: ```http GET /users/jdoe@company.com/identities?default=true ``` 2. It updates that identity, or creates it when the user has none (404): ```http PUT /users/jdoe@company.com/identities/{id} POST /users/jdoe@company.com/identities {"email": "jdoe@company.com", "name": "John Doe", "sortOrder": 0} ``` The server-set identity, which James derives from the address and which cannot be deleted, is left alone: an update of it makes Twake Mail add a second identity for the same address, named "givenName sn". Calls for one address run one after the other, so that the two hooks an add fires create a single identity. ### Email Aliases Management The plugin automatically manages email aliases stored in LDAP and syncs them to James. #### Creating a User with Aliases When a user is created with aliases: 1. **LDAP add operation** ```bash POST /api/v1/ldap/users { "uid": "jdoe", "mail": "john.doe@company.com", "mailAlternateAddress": [ "john@company.com", "j.doe@company.com" ] } ``` 2. **James plugin** creates all aliases in James: ```http PUT /address/aliases/john.doe@company.com/sources/john@company.com PUT /address/aliases/john.doe@company.com/sources/j.doe@company.com ``` Emails sent to any alias address will be delivered to `john.doe@company.com`. #### Modifying Aliases When aliases are added or removed: 1. **LDAP modify operation** ```bash PUT /api/v1/ldap/users/jdoe { "replace": { "mailAlternateAddress": [ "john@company.com", "jdoe@company.com" ] } } ``` 2. **onChange plugin** detects alias changes and triggers `onLdapAliasChange` hook 3. **James plugin** performs diff and updates only changed aliases: ```http DELETE /address/aliases/john.doe@company.com/sources/j.doe@company.com PUT /address/aliases/john.doe@company.com/sources/jdoe@company.com ``` #### Aliases and Mail Changes When the primary email address changes, all aliases are automatically updated: 1. **LDAP modify operation** ```bash PUT /api/v1/ldap/users/jdoe { "replace": { "mail": "john.smith@company.com" } } ``` 2. **James plugin** renames the account and updates alias destinations: ```http POST /users/john.doe@company.com/rename/john.smith@company.com?action=rename DELETE /address/aliases/john.doe@company.com/sources/john@company.com PUT /address/aliases/john.smith@company.com/sources/john@company.com DELETE /address/aliases/john.doe@company.com/sources/jdoe@company.com PUT /address/aliases/john.smith@company.com/sources/jdoe@company.com ``` James forwards the old address to the new one, see [Forwards Left on an Address](#forwards-left-on-an-address). #### Active Directory Format Support The plugin supports Active Directory's `proxyAddresses` format: ```ldap proxyAddresses: smtp:john@company.com proxyAddresses: smtp:j.doe@company.com proxyAddresses: SMTP:john.doe@company.com ``` The `smtp:` prefix is automatically stripped. The capitalization (SMTP vs smtp) is ignored - only the email address after the colon is used. ## REST API The James plugin exposes a REST API to retrieve quota usage and limits for users. ### GET /api/v1/users/:user/quota-usage Retrieve quota usage and limits for a user from James. This endpoint returns both the quota limits configured for the user and their current mailbox usage. **Request:** ```bash GET /api/v1/users/jdoe/quota-usage ``` **Response (200 OK):** ```json { "global": { "count": 1000, "size": 1000000000 }, "domain": { "count": 800, "size": 800000000 }, "user": { "count": 500, "size": 75000000 }, "computed": { "count": 500, "size": 75000000 }, "occupation": { "size": 50000000, "count": 250, "ratio": { "size": 0.67, "count": 0.5, "max": 0.67 } } } ``` **Response Fields:** - `global`: Global quota limit (server-wide) - `domain`: Domain-specific quota limit - `user`: User-specific quota limit from LDAP - `computed`: Effective quota limit applied (resolved from upper values) - `occupation`: **Current mailbox usage statistics** - `size`: Storage currently used (bytes) - `count`: Number of messages in mailbox - `ratio`: Usage ratios (used / limit) **Error Responses:** - `404`: User not found in LDAP or quota not found in James - `400`: User has no mail attribute - `502`: Failed to retrieve quota from James - `500`: Internal server error **Example:** ```bash # Get quota usage for user 'jdoe' curl http://localhost:8081/api/v1/users/jdoe/quota-usage \ -H "Authorization: Bearer admin-token" ``` ## Public Methods Public methods available on the James plugin instance. Useful for building GDPR workflows, custom integrations, or calling from other plugins. ### Usage ```typescript import type { DM } from 'ldap-rest'; import type James from 'ldap-rest/plugin-twake-james'; // Get the James plugin instance const james = server.getPlugin('james') as James; ``` ### deleteUserData(mail, fromStep?) Delete all user data via James WebAdmin API. Useful for GDPR "right to be forgotten" compliance. **Signature:** ```typescript async deleteUserData(mail: string, fromStep?: string): Promise<{ taskId: string } | null> ``` **Parameters:** - `mail` (string): User's email address - `fromStep` (string, optional): Resume deletion from a specific step if a previous attempt failed **Returns:** Task information with `taskId`, or `null` on error. **Example:** ```typescript const result = await james.deleteUserData('user@example.com'); if (result) { console.log(`Deletion task started: ${result.taskId}`); } // Resume from a failed step const resumed = await james.deleteUserData( 'user@example.com', 'DeleteMailboxesStep' ); ``` **Reference:** [James deleteData API](https://james.apache.org/server/manage-webadmin.html#delete-data-of-a-user) --- ### getMailFromDN(dn) Retrieve the email address for a user given their LDAP DN. **Signature:** ```typescript async getMailFromDN(dn: string): Promise ``` **Parameters:** - `dn` (string): LDAP Distinguished Name of the user **Returns:** Email address string, or `null` if not found. **Example:** ```typescript const mail = await james.getMailFromDN('uid=jdoe,ou=users,dc=example,dc=com'); // Returns: "jdoe@example.com" ``` --- ### getDisplayNameFromDN(dn) Retrieve the display name for a user given their LDAP DN. Uses fallback logic: `displayName` → `cn` → `givenName sn` → `mail`. **Signature:** ```typescript async getDisplayNameFromDN(dn: string): Promise ``` **Parameters:** - `dn` (string): LDAP Distinguished Name of the user **Returns:** Display name string, or `null` if not found. **Example:** ```typescript const name = await james.getDisplayNameFromDN( 'uid=jdoe,ou=users,dc=example,dc=com' ); // Returns: "John Doe" ``` --- ### generateSignature(dn) Generate an HTML email signature for a user using the configured template (`--james-signature-template`). **Signature:** ```typescript async generateSignature(dn: string): Promise ``` **Parameters:** - `dn` (string): LDAP Distinguished Name of the user **Returns:** HTML signature string with placeholders replaced, or `null` if no template configured. **Example:** ```typescript // With template: "

{displayName}

{title} - {department}

" const signature = await james.generateSignature( 'uid=jdoe,ou=users,dc=example,dc=com' ); // Returns: "

John Doe

Engineer - IT

" ``` --- ### updateJamesIdentity(dn, mail, displayName, signatures?) Update a user's JMAP identity in James (name and optional signature). **Signature:** ```typescript async updateJamesIdentity( dn: string, mail: string, displayName: string, signatures?: { htmlSignature?: string; textSignature?: string } ): Promise ``` **Parameters:** - `dn` (string): LDAP Distinguished Name (used for signature generation) - `mail` (string): User's email address - `displayName` (string): New display name for the identity - `signatures` (object, optional): written when `--james-signature-template` is not set **Example:** ```typescript await james.updateJamesIdentity( 'uid=jdoe,ou=users,dc=example,dc=com', 'jdoe@example.com', 'John Doe' ); ``` --- ### getMemberEmails(memberDns) Retrieve email addresses for multiple LDAP members. Useful for bulk operations on groups. **Signature:** ```typescript async getMemberEmails(memberDns: string[]): Promise ``` **Parameters:** - `memberDns` (string[]): Array of LDAP Distinguished Names **Returns:** Array of email addresses (excludes members without mail attribute). **Example:** ```typescript const emails = await james.getMemberEmails([ 'uid=alice,ou=users,dc=example,dc=com', 'uid=bob,ou=users,dc=example,dc=com', ]); // Returns: ["alice@example.com", "bob@example.com"] ``` --- ### getGroupMail(groupDn) Retrieve the email address for a group given its LDAP DN. **Signature:** ```typescript async getGroupMail(groupDn: string): Promise ``` **Parameters:** - `groupDn` (string): LDAP Distinguished Name of the group **Returns:** Group email address, or `null` if not found. **Example:** ```typescript const mail = await james.getGroupMail( 'cn=engineering,ou=groups,dc=example,dc=com' ); // Returns: "engineering@example.com" ``` --- ### getGroupMailboxType(groupDn) Retrieve the mailbox type for a group (mailing list, team mailbox, or simple group). **Signature:** ```typescript async getGroupMailboxType(groupDn: string): Promise<'group' | 'mailingList' | 'teamMailbox' | null> ``` **Parameters:** - `groupDn` (string): LDAP Distinguished Name of the group **Returns:** Mailbox type string, or `null` if not determinable. **Example:** ```typescript const type = await james.getGroupMailboxType( 'cn=engineering,ou=groups,dc=example,dc=com' ); // Returns: "mailingList" or "teamMailbox" or "group" ``` ## James WebAdmin API ### Endpoints Used | Operation | Endpoint | Method | | ----------------- | ------------------------------------------ | ------ | | Rename account | `/users/{old}/rename/{new}?action=rename` | POST | | Update quota | `/quota/users/{mail}/size` | PUT | | Get quota | `/quota/users/{mail}` | GET | | Add delegation | `/users/{mail}/authorizedUsers/{delegate}` | PUT | | Remove delegation | `/users/{mail}/authorizedUsers/{delegate}` | DELETE | | Add alias | `/address/aliases/{mail}/sources/{alias}` | PUT | | Remove alias | `/address/aliases/{mail}/sources/{alias}` | DELETE | | List forwards | `/address/forwards/{mail}` | GET | | Remove forward | `/address/forwards/{mail}/targets/{to}` | DELETE | | Create domain | `/domains/{domain}` | PUT | | Remove domain | `/domains/{domain}` | DELETE | ### Authentication James WebAdmin API can be secured with bearer token authentication: ```bash --james-webadmin-token "your-admin-token" ``` The plugin automatically adds the `Authorization: Bearer {token}` header to all requests. **Without token:** ```http POST /users/old@company.com/rename/new@company.com?action=rename ``` **With token:** ```http POST /users/old@company.com/rename/new@company.com?action=rename Authorization: Bearer your-admin-token ``` **Note:** The `action=rename` query parameter is required by James WebAdmin API. Alternative authentication methods (Basic Auth, JWT) can be configured via reverse proxy. ## Examples ### Example 1: Basic Setup (No Authentication) ```bash --plugin core/ldap/onChange \ --plugin core/ldap/flatGeneric \ --plugin twake/james \ --ldap-flat-schema ./schemas/twake/users.json \ --mail-attribute mail \ --james-webadmin-url http://localhost:8000 ``` ### Example 1b: With Bearer Token ```bash --plugin core/ldap/onChange \ --plugin core/ldap/flatGeneric \ --plugin twake/james \ --ldap-flat-schema ./schemas/twake/users.json \ --mail-attribute mail \ --james-webadmin-url http://james:8000 \ --james-webadmin-token "admin-secret-token" ``` ### Example 2: With Quota Management ```bash --plugin core/ldap/onChange \ --plugin core/ldap/flatGeneric \ --plugin twake/james \ --ldap-flat-schema ./schemas/twake/users.json \ --mail-attribute mail \ --quota-attribute mailQuotaSize \ --james-webadmin-url http://james:8000 ``` ### Example 3: Complete Twake Setup ```bash --plugin core/auth/token \ --plugin core/ldap/onChange \ --plugin core/ldap/flatGeneric \ --plugin core/ldap/groups \ --plugin core/ldap/organization \ --plugin twake/james \ --plugin core/static \ --ldap-flat-schema ./schemas/twake/users.json \ --ldap-flat-schema ./schemas/twake/positions.json \ --mail-attribute mail \ --quota-attribute mailQuotaSize \ --james-webadmin-url http://james:8000 \ --james-webadmin-token "james-admin-token" \ --static-path ./static \ --auth-token "api-admin-token" ``` ## Logging The plugin logs all operations: ### Successful Operations ```json { "level": "info", "message": "James operation succeeded", "plugin": "james", "event": "onLdapMailChange", "url": "http://james:8000/users/old@company.com/rename/new@company.com", "status": 204, "oldmail": "old@company.com", "newmail": "new@company.com" } ``` ### Failed Operations ```json { "level": "error", "message": "James operation failed", "plugin": "james", "event": "onLdapMailChange", "url": "http://james:8000/users/old@company.com/rename/new@company.com", "status": 404, "error": "User not found", "oldmail": "old@company.com", "newmail": "new@company.com" } ``` ## Error Handling ### Replication lag James reads its users from LDAP, and may read them from a replica that has not received the latest write yet: for up to a few seconds after a user is created or renamed, James does not know the new address. A call about that address fails meanwhile, with 400 (rename) or 404 (quota, alias, identity). The calls that need James to know the user are therefore retried on 400 and 404: the rename, the quota, an alias or a forward added, a delegation added, and the identity. Each failed attempt logs a warning (`"result": "retry"`, with the attempt number); the error is logged when the last attempt fails. With the defaults, 5 attempts, the waits are 1, 2, 3 and 4 seconds: `--james-retry-delay` times the number of attempts made, so `--james-retry-delay` × n(n−1)/2 in all for n attempts: 10 seconds with the defaults, 190 seconds with 20 attempts. `--james-retry-attempts 1` disables the retries. Other failures, such as 401 or 500, are not retried, nor are deletions. The team mailbox calls are not retried: their member routes do not check the user in James's users repository; their only 404 is a missing team mailbox, which is avoided by creating it before adding the members. The retries run in the background, after the LDAP write has answered; a call waiting for its next attempt does not take a slot of `--james-concurrency`. ### Non-Existent User If mail account doesn't exist in James: ```json { "level": "warn", "message": "James user not found (expected for new users)", "status": 404 } ``` This is normal for new users - create the account in James separately. ### James Server Down If James WebAdmin is unreachable: ```json { "level": "error", "message": "Failed to connect to James", "error": "ECONNREFUSED" } ``` The LDAP operation succeeds, but James is not updated. Manual sync may be required. ### Invalid Quota Value If quota value is invalid: ```json { "level": "error", "message": "Invalid quota value", "status": 400, "error": "Quota must be a positive integer" } ``` ## Integration Testing ### Test Mail Rename ```bash # Update mail in LDAP curl -X PUT http://localhost:8081/api/v1/ldap/users/jdoe \ -H "Authorization: Bearer admin-token" \ -H "Content-Type: application/json" \ -d '{"replace": {"mail": "john.doe@company.com"}}' # Verify in James curl http://james:8000/users/john.doe@company.com ``` ### Test Quota Update ```bash # Update quota in LDAP curl -X PUT http://localhost:8081/api/v1/ldap/users/jdoe \ -H "Authorization: Bearer admin-token" \ -H "Content-Type: application/json" \ -d '{"replace": {"mailQuotaSize": "5000000000"}}' # Verify in James curl http://james:8000/quota/users/jdoe@company.com/size ``` ## Quota Format Quota values are in **bytes**: | Size | Bytes | Example | | --------- | ----------- | -------------------------------- | | 1 GB | 1000000000 | `"mailQuotaSize": "1000000000"` | | 5 GB | 5000000000 | `"mailQuotaSize": "5000000000"` | | 10 GB | 10000000000 | `"mailQuotaSize": "10000000000"` | | Unlimited | -1 | `"mailQuotaSize": "-1"` | ## Synchronization Scenarios ### Scenario 1: User Creation 1. Create user in LDAP with mail attribute: James reads its users from LDAP 2. James plugin sets the quota, the aliases and the default identity, after dropping the [forwards left](#forwards-left-on-an-address) on its addresses 3. A mail added later to an existing entry is handled the same way 4. Future mail/quota changes are synced automatically ### Scenario 2: Mail Address Change 1. User changes email in LDAP: `old@company.com` → `new@company.com` 2. James plugin renames account in James 3. All mail data (inbox, sent, folders) is preserved 4. Old address is no longer valid ### Scenario 3: Quota Increase 1. Admin increases user quota in LDAP: `1GB` → `5GB` 2. James plugin updates quota in James 3. User can now receive more email ### Scenario 4: Quota Decrease 1. Admin decreases user quota in LDAP: `5GB` → `1GB` 2. James plugin updates quota in James 3. If user is over new quota, James may block incoming mail 4. User must delete mail to get under quota ## Limitations 1. **One-way sync**: LDAP → James only. James changes don't sync back to LDAP. 2. **No account creation**: Plugin doesn't create James accounts, only updates existing ones. 3. **No user deletion**: deleting an LDAP user leaves its James data (mailboxes, quota, aliases) as it is; purging it is left to James. 4. **Synchronous**: James API calls block LDAP response. Slow James = slow LDAP. ## Advanced Configuration ### Custom James Attributes Extend the plugin for custom James operations: ```typescript import James from './plugins/twake/james'; class CustomJames extends James { hooks = { ...super.hooks, onLdapDepartmentChange: async (dn, oldDept, newDept) => { // Custom logic for department changes await this._try( 'onLdapDepartmentChange', `${this.config.james_webadmin_url}/users/${mail}/metadata/department`, 'PUT', dn, newDept, { oldDept, newDept } ); }, }; } ``` ### Async Operations For high-latency James servers, consider async operations: ```typescript hooks: { onLdapMailChange: async (dn, oldMail, newMail) => { // Fire and forget - don't block LDAP response setImmediate(async () => { try { await jamesClient.renameUser(oldMail, newMail); } catch (err) { logger.error('Failed to sync mail change', err); } }); }; } ``` ## Troubleshooting ### Problem: Changes Not Syncing **Solutions:** 1. Verify onChange plugin is loaded: ```bash --plugin core/ldap/onChange ``` 2. Check mail/quota attributes configured: ```bash --mail-attribute mail --quota-attribute mailQuotaSize ``` 3. Enable debug logging: ```bash --log-level debug ``` 4. Verify James URL is correct: ```bash curl http://james:8000/healthcheck ``` ### Problem: 404 Errors **Symptoms:** ```json { "level": "warn", "message": "James user not found", "status": 404 } ``` **Solutions:** 1. Create mail account in James first 2. Verify mail address matches between LDAP and James 3. Check James logs for account existence ### Problem: Slow LDAP Responses **Symptoms:** LDAP modify operations take several seconds. **Solutions:** 1. Check James WebAdmin performance 2. Implement async operations (fire and forget) 3. Use local James instance (avoid network latency) 4. Add timeout to James API calls ## See Also - [onChange.md](onChange.md) - LDAP change detection - [ldapFlatGeneric.md](ldapFlatGeneric.md) - Schema-driven LDAP management - [Apache James Documentation](https://james.apache.org/server/manage-webadmin.html) - WebAdmin API reference linagora-ldap-rest-a4bb78e/docs/usage/plugins/integrations/james-mailboxes.md000066400000000000000000000347671527331160600275470ustar00rootroot00000000000000# James Team Mailboxes `twake/james` creates James/TMail team mailboxes from LDAP groups. Team mailboxes are shared mailboxes with IMAP access, as opposed to mailing lists which only distribute emails to members, and which `twake/james` leaves to Twake Mail: see [Mailing lists](#mailing-lists). ## Overview Groups in LDAP can now have three different mailbox types: - **group**: Simple group without mail functionality (default if no mail attribute) - **mailingList**: Mailing list, read from LDAP by Twake Mail; nothing is created in James - **teamMailbox**: Shared team mailbox (shared IMAP mailbox via James `/domains/{domain}/team-mailboxes/` API) ## Configuration ### LDAP Schema Amendment First, apply the schema amendment to add the `twakeMailboxType` attribute: ```bash ldapmodify -Y EXTERNAL -H ldapi:/// -f docs/examples/twake-mailbox-type-schema-amendment.ldif ``` This adds: - New attribute type: `twakeMailboxType` - Updates `twakeStaticGroup` and `twakeDynamicGroup` to include the new attribute ### Nomenclature Setup Create the nomenclature entries in your LDAP directory: ```bash # Replace {ldap_base} with your actual LDAP base (e.g., dc=example,dc=com) sed 's/{ldap_base}/dc=example,dc=com/g' docs/examples/twake-mailbox-type-nomenclature.ldif > /tmp/nomenclature.ldif ldapadd -x -D "cn=admin,dc=example,dc=com" -W -f /tmp/nomenclature.ldif ``` This creates three nomenclature entries: - `cn=group,ou=twakeMailboxType,ou=nomenclature,{ldap_base}` - `cn=mailingList,ou=twakeMailboxType,ou=nomenclature,{ldap_base}` - `cn=teamMailbox,ou=twakeMailboxType,ou=nomenclature,{ldap_base}` With [team mailboxes in a branch of their own](#team-mailboxes-in-a-branch-of-their-own), leave the last one out. ### LDAP-Rest Configuration Add to your ldap-rest configuration: ```bash # Optional: branches holding mailing lists, where team mailboxes are refused --james-mailing-list-branches "ou=lists,dc=example,dc=com" # Optional: Custom mailbox type attribute (default: twakeMailboxType) --james-mailbox-type-attribute "mailboxType" # Optional: let team mailboxes hold members of any mail domain --james-team-mailbox-any-domain ``` Or via environment variables: ```bash DM_JAMES_MAILING_LIST_BRANCHES="ou=lists,dc=example,dc=com" DM_JAMES_MAILBOX_TYPE_ATTRIBUTE="mailboxType" DM_JAMES_TEAM_MAILBOX_ANY_DOMAIN=true ``` If `james-mailing-list-branches` is empty (default), team mailboxes can be created anywhere. ### Team mailboxes in a branch of their own Twake Mail expands every group of its `mailingLists.baseDn` as a list: a team mailbox kept there would deliver twice, to the shared mailbox and to each member. The Twake schema `twake/teamMailboxes.json` keeps them apart, as a flat entity of their own under `ou=teammailboxes,{ldap_base}`, outside the group base as long as one is set: ```bash DM_LDAP_FLAT_SCHEMA=…,/app/node_modules/ldap-rest/static/schemas/twake/teamMailboxes.json DM_LDAP_GROUP_BASE="ou=lists,ou=groups,dc=example,dc=com" DM_JAMES_MAILING_LIST_BRANCHES="ou=lists,ou=groups,dc=example,dc=com" ``` `--ldap-group-base` has to be set, to the lists branch: left empty, the group base is `--ldap-base`, which holds `ou=teammailboxes`, and the groups routes list and edit the team mailboxes with the groups schema. - Create the branch first: nothing creates it, and a team mailbox added under a missing one answers `404`. ```ldif dn: ou=teammailboxes,dc=example,dc=com objectClass: organizationalUnit ou: teammailboxes ``` - Its entries are `twakeStaticGroup`s whose `twakeMailboxType` is fixed to `teamMailbox`, as a value. Leave `cn=teamMailbox` out of the nomenclature (see [Nomenclature Setup](#nomenclature-setup)): the groups form then offers only groups and mailing lists, and a team mailbox has one place to be created in. - A team mailbox needs an address TMail accepts (letters, digits, `#`, `-` and `_` before the `@`) and a member. - `--james-mailing-list-branches` on the lists branch refuses a team mailbox created there by another way. To keep them elsewhere, copy the schema and change its `base`, outside `--ldap-group-base`: the groups routes list and edit every group under it, with the groups schema, where the mailbox type can be changed. ## Mailing lists `twake/james` creates nothing in James for a mailing list: a group with a `mail` whose `twakeMailboxType` is `mailingList`, or is not set. Twake Mail reads the list from LDAP when a mail reaches it, with its `LDAPMailingList` mailet: a member change applies at once, and the list type held in `businessCategory` (open, members only, owners only…) decides who may write to it. James address groups (`/address/groups`) are not used: they cannot restrict senders, so anyone, external senders included, could write through one to a members-only list. ## Usage ### Creating a Team Mailbox Where Twake Mail reads mailing lists from the groups branch, create team mailboxes [in a branch of their own](#team-mailboxes-in-a-branch-of-their-own) instead: the example below, a team mailbox among the groups, only suits a deployment whose groups branch holds no mailing list. ```javascript await dm.ldap.add('cn=sales-team,ou=groups,dc=example,dc=com', { objectClass: ['top', 'groupOfNames', 'twakeStaticGroup'], cn: 'sales-team', mail: 'sales@example.com', twakeMailboxType: 'cn=teamMailbox,ou=twakeMailboxType,ou=nomenclature,dc=example,dc=com', member: [ 'uid=alice,ou=users,dc=example,dc=com', 'uid=bob,ou=users,dc=example,dc=com', ], twakeDepartmentLink: 'ou=groups,dc=example,dc=com', twakeDepartmentPath: 'Sales', }); ``` This will: 1. Create the team mailbox via `PUT /domains/example.com/team-mailboxes/sales` 2. Add each member via `PUT /domains/example.com/team-mailboxes/sales/members/{member-email}` ### Creating a Mailing List ```javascript await dm.ldap.add('cn=announce,ou=lists,dc=example,dc=com', { objectClass: ['top', 'groupOfNames', 'twakeStaticGroup'], cn: 'announce', mail: 'announce@example.com', twakeMailboxType: 'cn=mailingList,ou=twakeMailboxType,ou=nomenclature,dc=example,dc=com', member: [ 'uid=alice,ou=users,dc=example,dc=com', 'uid=bob,ou=users,dc=example,dc=com', ], twakeDepartmentLink: 'ou=lists,dc=example,dc=com', twakeDepartmentPath: 'Lists', }); ``` Nothing is sent to James: Twake Mail reads the list from LDAP. ### Creating a Simple Group (no mailbox) ```javascript await dm.ldap.add('cn=developers,ou=groups,dc=example,dc=com', { objectClass: ['top', 'groupOfNames', 'twakeStaticGroup'], cn: 'developers', twakeMailboxType: 'cn=group,ou=twakeMailboxType,ou=nomenclature,dc=example,dc=com', member: ['uid=alice,ou=users,dc=example,dc=com'], twakeDepartmentLink: 'ou=groups,dc=example,dc=com', twakeDepartmentPath: 'Engineering', }); ``` This creates a simple group without any James integration (no mail attribute, no mailbox). ## Following the group `twake/james` compares a team mailbox group before and after each write made through LDAP-Rest, whatever made it: the groups API, its `/members` routes, or any other write to the entry. - A member added or removed is added to or removed from the mailbox. - A group created as a team mailbox, or that becomes one, gets the mailbox created with all its members. - A group that stops being one (another type, no `mail`, moved into a mailing list branch, or deleted) leaves its mailbox in James with no member. The mailbox is never deleted: it may hold mail. - A new `mail` empties the mailbox of the old address and creates the one of the new address. The address of the mailbox and of its members is read from `--mail-attribute` (`mail` by default), and the members under any spelling of `member`, an option such as `member;lang-en` included. The writes to one group reach James one after the other. A deleted user stays in the team mailboxes it belonged to: once its entry is gone, its address cannot be read. ## API Endpoints Used ### Team Mailboxes - **Create**: `PUT /domains/{domain}/team-mailboxes/{name}` - **Add member**: `PUT /domains/{domain}/team-mailboxes/{name}/members/{memberEmail}` - **Delete member**: `DELETE /domains/{domain}/team-mailboxes/{name}/members/{memberEmail}` TMail names a team mailbox by the local part of its address, `{name}@{domain}`, and accepts only letters, digits, `#`, `-` and `_` in it, up to 255 characters. A group whose address has any other character, such as a dot, gets no team mailbox: the plugin logs an error once, when the group comes to have such an address, and does not call TMail. The name is lower-cased, so a change of case of the address keeps the same team mailbox. ## Validation Rules ### Branch Restrictions When `--james-mailing-list-branches` is configured, a team mailbox is not created in James when the group is within one of these branches. It can be created anywhere else in the LDAP tree. **Example Configuration:** ```bash --james-mailing-list-branches "ou=lists,dc=example,dc=com" ``` **Allowed:** - `cn=sales-team,ou=teams,dc=example,dc=com` with `twakeMailboxType=teamMailbox` ✅ - `cn=sales-team,ou=groups,dc=example,dc=com` with `twakeMailboxType=teamMailbox` ✅ **Rejected:** - `cn=sales-team,ou=lists,dc=example,dc=com` with `twakeMailboxType=teamMailbox` ❌ (reserved for lists) **Validation Points:** every write to the group. A team mailbox in these branches gets no member either. **Error Handling:** - Validation failures are logged as errors - Group is created in LDAP but the team mailbox is NOT created in James - No exception thrown (silent failure with error log) ### Members belong to the mailbox's domain A team mailbox belongs to one mail domain, and so do its members: the members of `sales@example.com` have their address at `example.com`. Twake Mail does not check it — its WebAdmin adds any address to a team mailbox — so the directory does, on every write: - adding a member whose `mail` is at another domain is refused; - so is creating a team mailbox holding one, turning a group holding one into a team mailbox, or changing the address of a team mailbox to a domain its members are not at. The refusal is a `409`. It names the addresses of the members the write brings, not those the mailbox already holds, which the caller may not be allowed to read. A member without an address is not pushed to Twake Mail, and is accepted. Mailing lists are not concerned: they hold members of any domain, external ones included. `--james-team-mailbox-any-domain` (`DM_JAMES_TEAM_MAILBOX_ANY_DOMAIN=true`) lifts the rule, for a deployment that wants cross-domain team mailboxes. `/api/v1/config` publishes it as `features.james.teamMailboxAnyDomain`, so a client offers only the members the server accepts. ### Mailbox Type Defaults 3. **Mailbox Type**: - If `twakeMailboxType` is not set and the group has a `mail` attribute, it is a mailing list - If `twakeMailboxType` is not set and the group has no `mail` attribute, it's a simple group (no James integration) ## Mailbox Type Transitions You can transition groups between different mailbox types by modifying the `twakeMailboxType` attribute. The system automatically handles the cleanup of the old type and setup of the new type. These transitions keep the group where it is. With [team mailboxes in a branch of their own](#team-mailboxes-in-a-branch-of-their-own), a mailing list does not become a team mailbox: create the team mailbox, then delete the list. ### Supported Transitions #### mailingList → teamMailbox ```javascript await dm.ldap.modify('cn=sales,ou=groups,dc=example,dc=com', { replace: { twakeMailboxType: 'cn=teamMailbox,ou=twakeMailboxType,ou=nomenclature,dc=example,dc=com', }, }); ``` **What happens:** 1. Creates the team mailbox (`PUT /domains/{domain}/team-mailboxes/{name}`) 2. Adds all current members to the team mailbox #### teamMailbox → mailingList ```javascript await dm.ldap.modify('cn=sales,ou=groups,dc=example,dc=com', { replace: { twakeMailboxType: 'cn=mailingList,ou=twakeMailboxType,ou=nomenclature,dc=example,dc=com', }, }); ``` **What happens:** 1. Removes all members from the team mailbox (`DELETE /domains/{domain}/team-mailboxes/{name}/members/{member}`) 2. **Preserves the team mailbox** (does NOT delete it - can be recovered later) #### group → mailingList or teamMailbox ```javascript // Add mail attribute and mailbox type await dm.ldap.modify('cn=developers,ou=groups,dc=example,dc=com', { add: { mail: 'developers@example.com', }, replace: { twakeMailboxType: 'cn=teamMailbox,ou=twakeMailboxType,ou=nomenclature,dc=example,dc=com', }, }); ``` **What happens:** 1. No cleanup needed (was a simple group) 2. For a team mailbox: creates it and adds all current members; nothing is sent to James for a mailing list #### mailingList or teamMailbox → group ```javascript await dm.ldap.modify('cn=sales,ou=groups,dc=example,dc=com', { replace: { twakeMailboxType: 'cn=group,ou=twakeMailboxType,ou=nomenclature,dc=example,dc=com', }, }); ``` **What happens:** 1. If team mailbox: removes all members (preserves mailbox) 2. Group becomes a simple group without mail functionality ### Deleting Groups with Team Mailboxes When you delete a group that has `twakeMailboxType=teamMailbox`: ```javascript await dm.ldap.delete('cn=sales,ou=groups,dc=example,dc=com'); ``` **What happens:** 1. Removes all members from the team mailbox 2. **Preserves the team mailbox** (does NOT delete it) 3. The mailbox can be recovered later if needed **Rationale:** Team mailboxes may contain important emails and should not be automatically deleted. Administrators can manually delete them through James WebAdmin if needed. ## Testing Tests require the LDAP schema amendment to be applied first: ```bash # Apply schema amendment to your test LDAP server ldapmodify -Y EXTERNAL -H ldapi:/// -f docs/examples/twake-mailbox-type-schema-amendment.ldif # Load nomenclature sed 's/{ldap_base}/dc=example,dc=com/g' docs/examples/twake-mailbox-type-nomenclature.ldif | \ ldapadd -x -D "cn=admin,dc=example,dc=com" -W # Run the group tests npm run test:one test/plugins/twake/jamesGroups.test.ts ``` ### Test Coverage **jamesGroups.test.ts** drives the groups API and records every call made to James: - Mailing lists, typed or not: no address group, from creation to deletion - A group without mail: no call - Team mailboxes: creation, the `/members` routes, a `replace` of the members, deletion, type changes, a new address, a mailing list branch ## References - [TMail Team Mailboxes Documentation](https://github.com/linagora/tmail-backend/blob/master/docs/modules/ROOT/pages/tmail-backend/webadmin.adoc#team-mailboxes) - [GitHub Issue #5](https://github.com/linagora/ldap-rest/issues/5) linagora-ldap-rest-a4bb78e/docs/usage/plugins/integrations/lifecycle-events.md000066400000000000000000000247261527331160600277220ustar00rootroot00000000000000# Lifecycle Events `core/twake/lifecycleEvents` publishes an account's or a group's lifecycle to RabbitMQ from the directory write itself. REST, SCIM and any other plugin writing through LDAP-Rest announce the same events, with the same payloads. Which entries are accounts or groups, which attributes carry the role, the lock, the deletion and the members, and where each event goes are configuration. ## Events Each event is a change of state, read from the entry before and after a write (`onLdapEntryChange`, from `core/ldap/onChange`). A write that changes nothing publishes nothing. - `created`: an entry is added. An entry added locked, as a SCIM create with `active: false` does, publishes `created` then `disabled`. - `roleChanged`: the role attribute gets different values. Case and order are ignored: `Admin` and `admin` are one role. A multi-valued role attribute is compared as a set, but `$title` carries its first value only, so `[x, y]` to `[x, z]` publishes `x` as both role and previous role: keep the role in a single-valued attribute. - `disabled`: the lock attribute takes the lock value. - `enabled`: the lock attribute no longer holds the lock value. - `updated`: a target whose payload has `$changed.` sources publishes when one of them changed, with those that did not left out. A target without them publishes on every change of the entry. - `memberAdded`, `memberRemoved`: the member attribute gains or loses values. The old and new lists are compared as sets, so a whole list replaced publishes only the members that came or went. The group placeholder (`--group-dummy-user`) is never a member here. A group created with members publishes `created` only, its members in `$members`. - `deleted`: the entry becomes a tombstone (the deleted attribute takes the deleted value), or an entry that is not a tombstone is removed. An account is locked when its lock attribute holds the lock value, by default the ppolicy administrative lock `000001010000Z`. Any other value, such as the timestamp of a ppolicy lockout after failed binds, is not a lock here. A tombstone announces nothing else: the write that makes it does not also publish `disabled`, later changes to it (a lock written afterwards included) publish nothing, and removing it publishes nothing, since its deletion was already announced. A publish that fails is logged as an error, and the next targets are still published. It never fails the write that caused it, and the event is not replayed: writing the same value again changes nothing, so it publishes nothing. With `core/twake/tombstone`, a lost `deleted` is published again by deleting the tombstone once more (see below); a lost `disabled`, `enabled`, `created` or `roleChanged` has no such path. The lock attribute is requested by name when the entry is read, so an operational one such as `pwdAccountLockedTime` is seen on both sides. ## What is not published - A rename or a move publishes nothing. The `$dn.*` groups of the new DN may then differ from the ones consumers know, and an entry moved into or out of a rule's pattern publishes neither `created` nor `deleted`. - A tombstone cannot be restored. Clearing its deleted attribute is not a supported operation: it publishes nothing, whatever else the same write changes, and later changes are published again as for any account, to consumers that consider it deleted. - A change made outside LDAP-Rest is not seen. A ppolicy lockout after failed binds is written by the directory itself, and holds a timestamp rather than the lock value: it publishes nothing, and neither does clearing it. ## Deployments with other plugins - `core/twake/tombstone` answers a second delete of a tombstone without writing anything, and fires `twakedeletionreplay`; this plugin then publishes `deleted` again, with the tombstone as it is (same deletion date). This explicit delete is how a `deleted` event a broker outage lost is replayed. - `core/twake/cozyProvision` and `core/twake/clouderyProvision` publish their own deletion event on `--cozy-user-deleted-routing-key`. When a `deleted` rule here publishes it, set that option to `""`. Turning it off without a `deleted` rule leaves no deletion event at all; keeping both publishes it twice. - `core/ldap/trash` moves a deleted entry instead of removing it, and a move made by the trash is not seen by `core/ldap/onChange`. A deletion in a branch the trash watches publishes nothing. ## Configuration ```bash --plugin core/twake/lifecycleEvents \ --twake-lifecycle-role-attribute title \ --twake-lifecycle-lock-attribute pwdAccountLockedTime \ --twake-lifecycle-lock-value 000001010000Z \ --twake-lifecycle-deleted-attribute employeeType \ --twake-lifecycle-deleted-value deleted \ --twake-lifecycle-deleted-at-attribute roomNumber \ --twake-lifecycle-deleted-at-format iso8601 \ --twake-lifecycle-rules /etc/ldap-rest/lifecycle-rules.json ``` - `--twake-lifecycle-role-attribute`: empty means no `roleChanged`. - `--twake-lifecycle-lock-attribute`, `--twake-lifecycle-lock-value`: default to `--scim-user-lock-attribute` and `--scim-user-lock-value`, so SCIM `active` and the events agree; SCIM's value only goes with SCIM's attribute. `pwdAccountLockedTime` defaults to `000001010000Z`; any other attribute needs its value, or the server does not start, as for [SCIM](scim.md). Only changes written through LDAP-Rest are seen: a lockout set by the directory publishes nothing. - `--twake-lifecycle-deleted-attribute`, `--twake-lifecycle-deleted-value` (default `TRUE`, compared case-insensitively): what marks a tombstone. Empty means entries are never tombstones, and only a removal publishes `deleted`. - `--twake-lifecycle-deleted-at-attribute`, `--twake-lifecycle-deleted-at-format` (`iso8601`, the default, or `generalizedTime`): the deletion date. It is read whether it is written as a GeneralizedTime or as an ISO 8601 date, and always published as ISO 8601; a value of neither shape is left out. A GeneralizedTime needs its zone; an ISO 8601 date and time without one is read in the server's time zone. - `--twake-lifecycle-member-attribute` (default `member`): the attribute `memberAdded`, `memberRemoved` and member lists read. - `--twake-lifecycle-rules`: a JSON file, or the JSON itself. Each option has a `DM_` environment variable, for example `DM_TWAKE_LIFECYCLE_RULES`. The events go through `core/rabbitmq`. With rules configured, the server does not start without `--rabbitmq-url`, nor when that broker cannot be reached. An event that finds no broker later on is lost, and logged as an error. ## Rules The rules are an array. The first rule whose `dn` regular expression matches the entry's DN (case-insensitively) decides what is published; an entry no rule matches publishes nothing. The DN is matched without spaces around its separators, and with escaped commas written `\2C`: `uid=a\, b, ou=users,…` is read as `uid=a\2C b,ou=users,…`, so `(?[^,]+)` takes the whole value. A `$dn.name` group is unescaped: `a, b`. ```json [ { "dn": "^uid=(?[^,]+),ou=users,dc=example,dc=com$", "exchange": "accounts", "payload": { "id": "$dn.id", "email": "$mail", "domain": "$mail|domain", "role": "$title", "timestamp": "$now" }, "events": { "created": "account.created", "roleChanged": { "routingKey": "account.role.changed", "payload": { "id": "$dn.id", "role": "$title", "previousRole": "$previous.title" } }, "disabled": "account.disabled", "enabled": "account.enabled", "deleted": [ "account.deleted", { "exchange": "notifications", "routingKey": "account.deleted", "when": { "$businessCategory": "user_request" }, "payload": { "id": "$dn.id", "mobile": "$previous.mobile" } } ] } } ] ``` An event takes one target or a list of them, published in order. A target is a routing key, or an object with: - `routingKey` - `exchange`: defaults to the rule's - `payload`: replaces the rule's `payload` - `when`: publish only if every source equals its value; a value starting with `!` means "differs from" Payload and `when` values are sources: - `$attr`: the attribute after the write; for a removed entry, which has no "after", the attribute before it - `$previous.attr`: the attribute before the write - `$dn.name`: a named group of the rule's `dn` expression - `$context.actor`, `$context.requestId`, `$context.source`: who made the write, the request it belongs to, and the API it came through (`rest`, `scim`); left out for a write no request made, such as a scheduled task - `$changed.attr`: the attribute after the write, only if the write changed it; an attribute the write removed gives `""` - `$attr|domain`, `$previous.attr|domain`: what follows the `@` - `$now`: the current time, ISO 8601 - anything else: the value itself `$attr` and `$previous.attr` carry the first value of a multi-valued attribute. A source with no value leaves its field out. Every attribute a source names is requested by name when the entry is read, so an operational one such as `entryUUID` works anywhere in a payload or a `when`. A payload field can also list members, as an object with one key: - `$members`: the members after the write - `$added`, `$removed`: the members `memberAdded` and `memberRemoved` are about ```json "members": { "$added": { "username": "$uid", "email": "$mail" } } ``` Each member is read once, and shaped by the object it maps to, whose sources are its own attributes (`$attr`) or plain values; any other source is refused at startup. A tombstone is left out, and a target whose `$added` or `$removed` is left empty publishes nothing. A member no longer in the directory is known by its RDN alone: `uid=jdoe,…` gives `{ "username": "jdoe" }` above. A group of 1,000 members takes 1,000 reads, one after the other, off the path of the write's response. A member that cannot be read, for a reason other than being gone, drops that target, logged as an error: a member the service account may not read silences every target of the group that lists its members. Every message carries a random AMQP `messageId`. A source can read any attribute of the entry: `$userPassword` would put the password hash in the message. The rules are checked at startup: a malformed one, such as a payload value that is not a string, stops the server with an error naming its `dn`. ## Dependencies ``` core/twake/lifecycleEvents ├─ requires: core/ldap/onChange └─ requires: core/rabbitmq ``` linagora-ldap-rest-a4bb78e/docs/usage/plugins/integrations/mail-migration.md000066400000000000000000000115021527331160600273560ustar00rootroot00000000000000# Mail migration proxy `core/twake/migrationProxy` keeps the migrated-users list of the [Twake Mail migration proxy](https://github.com/linagora/tmail-backend/blob/master/docs/modules/ROOT/pages/migration-proxy/objective.adoc) in step with the directory, while a mail platform is moved to Twake Mail. The proxy sits in front of the old and the new mail backends. It relays each IMAP (and, when enabled, submission) connection to the backend hosting the authenticated user, and routes each inbound recipient to the backend hosting it. An address on its migrated list lives on the new backend; any other address lives on the old one. An account created during the migration has no mailbox on the old system, so it has to be on the list before its first login and its first mail: this plugin puts it there, and keeps the addresses of a migrated account on the list as they change. Load it for the duration of the migration, next to `core/twake/james`, and drop it once the old backend is gone. It requires `core/ldap/onChange`. ## Scope Only the entries under `--migration-proxy-branch` are followed, the branches holding the accounts whose addresses the proxy routes: users, and the groups whose address is a team mailbox or a mailing list when those are created during the migration, as inbound mail for them is routed by recipient too. Keep out the branches whose entries carry an address that is not their own, the applicative accounts of [app-accounts](app-accounts.md) for instance: they would put their owner on the list or take it off. The addresses of an entry are every value of `--mail-attribute` and of `--alias-attribute`, the first mail being the one that says where the account lives. The proxy folds usernames to lower case, so a change of case is no change. ## Behaviour Through the `onLdapEntryChange` hook, after the write has answered, one entry at a time: - An entry that gains its mail, when created or later, is put on the list with all its addresses: `PUT /migratedUsers/{address}`. - An entry that keeps its mail and whose addresses change asks the proxy about its first mail (`HEAD /migratedUsers/{mail}`). When the account is migrated, the addresses it gained are put on the list, then the ones it lost are taken off, once every addition holds; a user the proxy does not know is on the old system, and its change is left to the migration, which puts the new addresses on the list when it moves the mailbox. - An entry deleted, or losing its mail, is taken off the list with all its addresses: `DELETE /migratedUsers/{address}`. A tombstone or a trashed entry keeps its address, so it stays on the list until it is erased. The plugin cannot tell a new account from an existing one whose mail was cleared and set again, or from one created anew over its tombstone: those are put on the list too. Marking the existing accounts migrated is the migration's job, through the proxy's webadmin or its `migration:switch` exchange. ### Failures A call the proxy answers with 500, 502, 503 or 504 is made again, `--migration-proxy-retry-attempts` times in all, after `--migration-proxy-retry-delay` milliseconds times the number of attempts made. A refusal, such as 400 for an address the proxy does not take as a username or 401 for a wrong token, and a call the proxy does not answer at all, connection refused or reset, are not. Each failed attempt logs a warning, the last failure an error naming the address, the call and the entry. Every call is counted in the [outbound metrics](../../operations.md#metrics), under the target `migrationProxy`. A failed `PUT` leaves the account routed to the old backend, which has no mailbox for it: watch those errors, and put the address on the list by hand through the proxy's webadmin. When the proxy has not said whether a renamed account is migrated, nothing is changed and the error is logged. ## Where an account lives `GET /api/v1/users/:user/mail-migration` answers `{ "migrated": true }` when the proxy routes the user to the new backend, `{ "migrated": false }` otherwise, from the proxy's `HEAD`. The user is found by `uid` across the directory, and answered only when the caller may read the entry: another one gets 404, as a user that is not there. A user without mail gets 400, and a proxy that does not answer 502. ## Readiness The plugin asks the proxy's `GET /healthcheck` for `/readyz`, and reports `degraded` when it answers an error or not within 1.5 s, see [what the shipped plugins report](../../operations.md#what-the-shipped-plugins-report). ## Configuration ```bash --plugin core/ldap/onChange \ --plugin core/twake/james \ --plugin core/twake/migrationProxy \ --migration-proxy-url http://migration-proxy:8000 \ --migration-proxy-token "$MIGRATION_PROXY_TOKEN" \ --migration-proxy-branch "ou=users,dc=example,dc=com" ``` The options are listed in [configuration](../../configuration.md#coretwakemigrationproxy). linagora-ldap-rest-a4bb78e/docs/usage/plugins/integrations/scim.md000066400000000000000000001103521527331160600254030ustar00rootroot00000000000000# SCIM 2.0 Plugin The `scim` plugin exposes a [RFC 7643](https://datatracker.ietf.org/doc/html/rfc7643) / [RFC 7644](https://datatracker.ietf.org/doc/html/rfc7644) compliant endpoint at `/scim/v2/*`, allowing identity providers like Okta, Microsoft Entra ID (Azure AD), Google Workspace, and JumpCloud to provision users and groups against the underlying LDAP directory. ## Overview - Resources: `/Users`, `/Groups` - Operations: GET, POST, PUT, PATCH, DELETE, Bulk - Discovery: `/ServiceProviderConfig`, `/ResourceTypes`, `/Schemas` - SCIM filter syntax (RFC 7644 §3.4.2.2) translated to LDAP filters - SCIM PATCH (RFC 7644 §3.5.2) translated to LDAP `ModifyRequest` - Bulk (RFC 7644 §3.7) with cross-operation `bulkId` references - Per-request base resolution (multi-tenant) via template or JSON map The plugin sits on top of `ldapActions` and can operate fully independently from the generic `ldap/users` / `ldap/groups` plugins — it only needs LDAP credentials and target branches. ## Quick start ```bash node lib/bin/index.js \ --ldap-url ldap://localhost:389 \ --ldap-base dc=example,dc=com \ --ldap-dn cn=admin,dc=example,dc=com \ --ldap-pwd admin \ --plugin core/auth/token --auth-token 'my-secret:idp1' \ --plugin core/scim \ --scim-user-base ou=users,dc=example,dc=com \ --scim-group-base ou=groups,dc=example,dc=com ``` All SCIM endpoints sit behind the auth middleware registered before the plugin (Bearer token in this example). ## Configuration ### CLI / environment | Argument | Environment | Default | Description | | ------------------------------------ | ------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | `--scim-prefix` | `DM_SCIM_PREFIX` | `/scim/v2` | Base URL path for all SCIM endpoints | | `--scim-user-base` | `DM_SCIM_USER_BASE` | `{ldap_base}` | LDAP branch containing users | | `--scim-group-base` | `DM_SCIM_GROUP_BASE` | `{ldap_base}` | LDAP branch containing groups | | `--scim-user-base-template` | `DM_SCIM_USER_BASE_TEMPLATE` | — | DN template with `{user}` placeholder (see _multi-tenant_) | | `--scim-group-base-template` | `DM_SCIM_GROUP_BASE_TEMPLATE` | — | Same for groups | | `--scim-base-map` | `DM_SCIM_BASE_MAP` | — | Path to JSON file mapping authenticated user → `{userBase, groupBase}` | | `--scim-user-object-class` | `DM_SCIM_USER_OBJECT_CLASSES` | `top, inetOrgPerson, organizationalPerson, person` | Object classes for created Users | | `--scim-user-rdn-attribute` | `DM_SCIM_USER_RDN_ATTRIBUTE` | `uid` | RDN attribute for Users | | `--scim-user-lock-attribute` | `DM_SCIM_USER_LOCK_ATTRIBUTE` | `pwdAccountLockedTime` | LDAP attribute whose lock value marks a User as `active: false` | | `--scim-user-lock-value` | `DM_SCIM_USER_LOCK_VALUE` | — | Value written to that attribute when deactivating (required unless the attribute is the default) | | `--scim-group-object-class` | `DM_SCIM_GROUP_OBJECT_CLASSES` | `top, groupOfNames` | Object classes for created Groups | | `--scim-group-rdn-attribute` | `DM_SCIM_GROUP_RDN_ATTRIBUTE` | `cn` | RDN attribute for Groups | | `--scim-group-external-id-attribute` | `DM_SCIM_GROUP_EXTERNAL_ID_ATTRIBUTE` | — | LDAP attribute storing a Group's `externalId` (unset: not supported) | | `--scim-id-attribute` | `DM_SCIM_ID_ATTRIBUTE` | `rdn` | `rdn` (default) or `entryUUID` for SCIM `id` | | `--scim-user-mapping` | `DM_SCIM_USER_MAPPING` | — | Path to JSON mapping override | | `--scim-group-mapping` | `DM_SCIM_GROUP_MAPPING` | — | Same for Groups | | `--scim-max-results` | `DM_SCIM_MAX_RESULTS` | `200` | Maximum page size (caps `count`) | | `--scim-max-scanned` | `DM_SCIM_MAX_SCANNED` | `10000` | Entries a list may walk before answering `400 tooMany` | | `--scim-bulk-max-operations` | `DM_SCIM_BULK_MAX_OPERATIONS` | `100` | Max `/Bulk` operations per request | | `--scim-bulk-max-payload-size` | `DM_SCIM_BULK_MAX_PAYLOAD_SIZE` | `1048576` | Max body size of a SCIM request (`/Bulk` included), in bytes | | `--scim-etag` | `DM_SCIM_ETAG` | `false` | Advertise ETag support in discovery (not yet implemented) | | `--scim-base-url` | `DM_SCIM_BASE_URL` | auto from request | Override external base URL for `meta.location` values | ### Authorization Every SCIM operation now reaches `ldapActions` with the request attached, so the authorization plugins (`core/auth/authzPerBranch`, `core/auth/authzDynamic`, …) apply to reads as well as writes. An identity denied `read` on a branch gets `403` from `GET /Users`, `GET /Users/{id}` and their Group counterparts, instead of the data. An identity that writes needs `read` too: every SCIM write answers with the resource it just changed, so it reads the entry back. A write-only grant cannot be served, and answers 403 on the read-back. Base resolution (see _multi-tenant_ below) is unchanged and independent: it confines an identity to its own subtree whether or not an authorization plugin is loaded. ### Authentication SCIM piggybacks on whichever auth plugin you load. The discovery endpoint `/ServiceProviderConfig` reflects the active scheme automatically: - `core/auth/token` → `oauthbearertoken` - `core/auth/openidconnect` → `oauth2` - `core/auth/hmac` → `httpbasic` For Okta / Entra, load `core/auth/token` with a provisioning token. ## Endpoints All responses have `Content-Type: application/scim+json`. | Method | Path | Description | | ------------------------- | ------------------------------------------ | ------------------------------------- | | GET | `/Users` | List Users (filter, pagination, sort) | | GET | `/Users/{id}` | Get a User | | POST | `/Users` | Create a User | | PUT | `/Users/{id}` | Replace a User | | PATCH | `/Users/{id}` | Partial update (add/remove/replace) | | DELETE | `/Users/{id}` | Delete a User | | GET/POST/PUT/PATCH/DELETE | `/Groups[/{id}]` | Same operations on Groups | | POST | `/Bulk` | Batch operations with `bulkId` refs | | GET | `/ServiceProviderConfig` | Capabilities | | GET | `/ResourceTypes` / `/ResourceTypes/{name}` | Resource metadata | | GET | `/Schemas` / `/Schemas/{urn}` | Schema definitions | ### Default mapping (LDAP ⇄ SCIM User) | SCIM | LDAP | | ------------------------------------ | --------------------------------------------------------- | | `id` | `uid` (or `entryUUID`) | | `userName` | `uid` | | `externalId` | `employeeNumber` | | `name.familyName` | `sn` | | `name.givenName` | `givenName` | | `name.formatted` | `cn` | | `displayName` | `displayName` | | `title` | `title` | | `preferredLanguage` | `preferredLanguage` | | `emails[primary=true].value` | `mail` | | `emails[primary=false].value` | `mailAlternateAddress` | | `phoneNumbers[primary=true].value` | `telephoneNumber` | | `phoneNumbers[primary=false].value` | `mobile` | | `active` | pseudo (false if the lock attribute holds the lock value) | | `meta.created` / `meta.lastModified` | `createTimestamp` / `modifyTimestamp` | Override with `--scim-user-mapping /path/to/user-mapping.json` (same schema as `static/schemas/scim/default-mapping.json`). ## Deactivating an account (`active`) SCIM models account status with the boolean `active` (RFC 7643 §4.1.1), and it is how Okta, Entra ID and JumpCloud disable a user — they PATCH `active` to `false` rather than DELETE the resource. LDAP has no single equivalent, so the plugin models it on **one attribute holding one value**: `--scim-user-lock-value` in `--scim-user-lock-attribute` means locked, anything else means active. A ppolicy lockout after failed binds is a timestamp in `pwdAccountLockedTime`, so it reads as active, and `"active": true` leaves it in place. It lasts `pwdLockoutDuration`, or until an administrator clears it when that is 0. | SCIM | LDAP | | ----------------- | ----------------------------------------------- | | `"active": false` | write `--scim-user-lock-value` to the attribute | | `"active": true` | remove the attribute if it holds the lock value | | lock value absent | `"active": true` | The default targets the ppolicy overlay: ```bash --scim-user-lock-attribute pwdAccountLockedTime --scim-user-lock-value 000001010000Z # "locked forever" ``` `pwdAccountLockedTime` only exists when slapd loads the ppolicy overlay. On a directory without it — plain OpenLDAP, 389-ds, AD — the directory refuses the write against its schema, and the plugin answers `400 invalidValue` naming the attribute and the flags to change, on `/Bulk` as on the single-resource routes. Point the two flags elsewhere: ```bash --scim-user-lock-attribute nsAccountLock --scim-user-lock-value TRUE ``` **Name the value whenever you name the attribute.** `000001010000Z` is the ppolicy convention and means nothing to any other attribute: 389-ds reads `nsAccountLock` as locked only when it says `true`. Setting the attribute alone would write a value the directory ignores — every deactivation answers 200 and reads back `active: false` while the account keeps binding. The plugin refuses to start instead. Two more pairings get a warning at startup rather than a refusal, because neither can be told apart from a deliberate local schema: - the ppolicy value `000001010000Z` pinned on some other attribute, which is what a deployment template that always sets both flags produces by default - a value `pwdAccountLockedTime` cannot hold, `TRUE` for instance: it holds a GeneralizedTime **None of this can tell whether your directory honours the value you chose.** The checks are on the shape of the configuration. `active` is read back from the value this plugin wrote, so a value the directory stores and ignores still reads as `false`. Verify once, by hand, that a deactivation actually prevents a bind. `active` is not a mapping entry and must not be added to a mapping file: it is handled by these two flags on every path — create, PUT, PATCH and the `active eq true|false` filter alike. PUT is a full replacement (RFC 7644 §3.5.1) for every attribute except this one: a body that omits `active` leaves the lock as it stands. Clearing it would release locks SCIM never set — a ppolicy auto-lockout, or one an administrator placed — so a routine profile sync would defeat the brute-force control. Send `"active": true` to reactivate deliberately. ## Writing a custom mapping schema The mapping file is a plain JSON document describing how each SCIM attribute is projected to LDAP, and vice versa. A separate file is loaded for `--scim-user-mapping` and `--scim-group-mapping`. ### File shape ```json { "resourceType": "User", "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "entries": [ { "scim": "", "ldap": "" }, ... ] } ``` Each element of `entries` is a `MappingEntry` with the following optional fields: | Field | Purpose | | --------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `scim` | **Required.** Top-level SCIM attribute name (`userName`, `displayName`, `emails`, …). | | `ldap` | Simple 1:1 mapping to an LDAP attribute. | | `ldapPrimary` | For multi-valued SCIM attributes (`emails`, `phoneNumbers`, …), the LDAP attribute that stores the _primary_ value. | | `ldapSecondary` | LDAP attribute that stores the _non-primary_ values of the same SCIM attribute (array). | | `sub` | Object that maps SCIM sub-attributes to LDAP attributes. Used for complex SCIM values like `name.familyName` → `sn`. | | `subDn` | Sub-attribute names in `sub` whose value is a DN (for example `["value"]` for `manager`); they are held to RFC 4514 on write. | | `multi` | `"array"` or `"single"` (default) — hints array serialization for `ldap`. | | `readOnly` | `true` to skip this attribute on write (create/update ignore it). | | `operational` | `true` for LDAP operational attributes (e.g. `entryUUID`) — loaded on read only. | ### Merging The file you provide is **merged on top of the default mapping**: entries whose `scim` key matches a default entry replace it; new entries are appended. So you can override only what's different and inherit the rest. ### Example — adding the Enterprise User extension ```json { "resourceType": "User", "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User", "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" ], "entries": [ { "scim": "userName", "ldap": "sAMAccountName" }, { "scim": "name", "sub": { "familyName": "sn", "givenName": "givenName", "formatted": "displayName" } }, { "scim": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber", "ldap": "employeeNumber" }, { "scim": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department", "ldap": "department" }, { "scim": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager", "sub": { "value": "manager" }, "subDn": ["value"] } ] } ``` Save it as `/etc/ldap-rest/scim-enterprise.json` and load with `--scim-user-mapping /etc/ldap-rest/scim-enterprise.json`. ### Example — multi-valued phone numbers with a custom LDAP attribute ```json { "resourceType": "User", "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "entries": [ { "scim": "phoneNumbers", "ldapPrimary": "telephoneNumber", "ldapSecondary": "otherTelephone", "multi": "array" } ] } ``` On write, the entry marked `primary: true` (or the first one) lands in `telephoneNumber`; the remaining values are written as an array to `otherTelephone`. On read, both attributes are collected back into a SCIM `phoneNumbers` array with the primary flag set on the `telephoneNumber` value. ### Filter and PATCH behavior The mapping is authoritative for the **SCIM filter parser** (RFC 7644 §3.4.2.2) and the **PATCH applicator** (RFC 7644 §3.5.2). A SCIM filter like `department eq "Engineering"` will only translate to an LDAP filter if your mapping contains an entry with `scim: "department"`; otherwise the server returns `400 invalidFilter`. Same for PATCH: operations on unmapped paths produce `400 invalidPath`. A User or Group body is checked against RFC 7643 before anything is written and before any hook reads it: `userName`, `displayName` and `id` are strings; a simple attribute takes a string, a number or a boolean, or a list of them; a complex attribute takes an object whose sub-attributes are scalars (or, for a reference such as `manager`, an object whose `value` is one); a multi-valued complex attribute such as `emails` takes a list of objects whose `value` is a scalar. Anything else is answered `400 invalidValue`, as is a PATCH operation that is not an object or whose `op` is not a string. A reference the mapping declares a DN (`subDn`) is refused with `400 invalidValue` when its value holds an escape RFC 4514 does not define, as the flat routes hold a client-written DN. The directory refuses such a value itself on an attribute of DN syntax; this names the escape in the SCIM answer and applies wherever the mapping declares one. A PATCH `add`, or a `remove` naming values, changes only the values it names: values the caller's read did not show, such as group members an `ldapsearchfilter` hides, stay. Two PATCHes changing different values of the same attribute both apply. Adding a value already there succeeds where the directory honours the permissive modify control; removing one another write just removed replays the PATCH on a fresh read, then answers `409`. A `replace`, or a `remove` of a whole attribute, sets the attribute to what the caller sent, and so does a group `PUT` for its members, hidden ones included. A group left with no member the caller saw gets the `--group-dummy-user` placeholder. A member is resolved as the caller sees the directory, whether it is given by id or by DN: an account the caller cannot read is left out of the group, as one that does not exist is. Under an authorization model judging by branch, a member in a branch the caller may not read refuses the whole write instead, whether the account exists or not. A DN outside the user base is left out. ### Schema advertisement Schemas listed under `GET /scim/v2/Schemas` are read from `static/schemas/scim/User.json` and `Group.json`. To expose a custom schema to clients, extend those JSON files (or drop additional files in the same folder and adjust the loader). Exposing a schema there is **independent** from the LDAP↔SCIM projection: the schema file describes the public SCIM contract, the mapping file describes the internal translation. ### Group `externalId` RFC 7643 §3.1 defines `externalId` as the **provisioning client's** identifier — the id Okta or Entra ID keeps for its own copy of the resource, which it later uses to find the group again (`filter=externalId eq "…"`). For Users it lands in `employeeNumber`. groupOfNames has no attribute meant to hold one, so nothing is stored unless the deployment names an attribute: ```bash --scim-group-external-id-attribute description ``` Without it, `externalId` sent on a Group is ignored and a filter on it answers `400 invalidFilter` — which is at least honest. Any free attribute of your group entries works; use a mapping override if you would rather name it there. ## Filter syntax SCIM filters are translated to LDAP filters (RFC 4515). Values are always escaped via `escapeLdapFilter()` — no LDAP injection is possible. | SCIM | LDAP | | -------------------------------- | ----------------------------------------- | | `userName eq "alice"` | `(uid=alice)` | | `name.familyName sw "Du"` | `(sn=Du*)` | | `emails.value co "@example.com"` | `(mail=*@example.com*)` | | `displayName pr` | `(displayName=*)` | | `active eq true` | `(!(pwdAccountLockedTime=000001010000Z))` | | `active pr` | `(objectClass=*)` | | `a eq "x" and b eq "y"` | `(&(a=x)(b=y))` | | `not (a eq "x")` | `(!(a=x))` | Unknown SCIM attributes raise `400 invalidFilter`, as does a filter nested more than 32 levels deep (a parenthesis and a `not (` each count one). ## Pagination `GET /Users` and `GET /Groups` answer a window over the matching resources (RFC 7644 §3.4.2.4): ``` GET /scim/v2/Users?startIndex=201&count=100 ``` ```json { "totalResults": 4213, "startIndex": 201, "itemsPerPage": 100, "Resources": [...] } ``` - `count` is capped at `--scim-max-results` (200). A `count` of 0 is legal and answers `totalResults` with no resource. - `startIndex` is 1-based, and is not bounded by the page size: any offset into the collection is reachable. - `totalResults` is the real size of the result set, not the size of the page. Only the requested page is held in memory, but counting the rest still means walking to the end of the result set. `--scim-max-scanned` bounds that walk at 10000 entries; a query matching more answers `400 tooMany`, which RFC 7644 §3.12 defines for "a filter [that] yields many more results than the server is willing to calculate or process". Raise the flag, or narrow the query. If the directory's own size limit is lower than the collection, it refuses the search first and the answer is the same `tooMany` — raise `sizelimit` in slapd. ## Partial representations (`attributes` / `excludedAttributes`) Any route that answers a resource accepts the two RFC 7644 §3.9 parameters — the lists, the single-resource GETs, and the POST/PUT/PATCH answers alike. ```bash curl '.../Users/alice?attributes=userName,name.familyName' curl '.../Users?excludedAttributes=emails' ``` - `attributes` replaces the default set: only what you name comes back. - `excludedAttributes` keeps the default set minus what you name. - Both accept standard attribute notation, so `name.familyName` and `emails.value` narrow a complex or multi-valued attribute rather than dropping it whole, and a core schema URN prefix is accepted and stripped. - `id` and `schemas` are `returned: always` (RFC 7643 §7): they survive an exclusion and are added back to an `attributes` list. - `meta` is `returned: default`, so an `attributes` list that does not name it does not get it. Ask for `meta` explicitly if you need `meta.location`. - The two are mutually exclusive; sending both answers `400 invalidValue`. The projection applies to the response only — the LDAP search still reads the attributes the mapping needs. ## Multi-tenant: per-user LDAP bases SCIM does not expose any tenant concept to clients — resource `id`s are opaque strings. Internally the plugin can route each authenticated identity to a different LDAP subtree, so one `ldap-rest` instance can serve many tenants without any client-visible distinction. ### How `req.user` is populated Whichever auth plugin runs before SCIM must set `req.user` to a string that identifies the caller. Supported out of the box: | Auth plugin | `req.user` value | | ------------------------- | ---------------------------------------------------------------- | | `core/auth/token` | the `name` field of `--auth-token "id:secret:name"` | | `core/auth/openidconnect` | the subject / preferred_username from the OIDC token | | `core/auth/hmac` | the HMAC key id | | `core/auth/trustedProxy` | the value of `--trusted-proxy-auth-header` (default `Auth-User`) | | `core/auth/llng` | the LemonLDAP::NG user id | In the examples below we use `core/auth/token`, but any of the above works the same way — the SCIM plugin only reads `req.user`. ### Three modes, evaluated in order The plugin resolves `userBase` and `groupBase` on **every request**, in this precedence: 1. Explicit entry in a JSON map keyed by `req.user` (`--scim-base-map`). 2. Wildcard `"*"` entry in the same JSON map (applied when no explicit match). 3. DN template with `{user}` placeholder (`--scim-user-base-template` / `--scim-group-base-template`). 4. Static value (`--scim-user-base` / `--scim-group-base`). 5. Global fallback (`--ldap-base`). ### Mode 1 — JSON map (explicit, arbitrary routing) Use when different tenants live in unrelated branches of the tree, or when you need to override group base independently of user base. Create `/etc/ldap-rest/scim-tenants.json`: ```json { "okta-acme": { "userBase": "ou=users,ou=acme,dc=example,dc=com", "groupBase": "ou=groups,ou=acme,dc=example,dc=com" }, "entra-globex": { "userBase": "ou=users,ou=globex,dc=example,dc=com", "groupBase": "ou=groups,ou=globex,dc=example,dc=com" }, "*": { "userBase": "ou=users,dc=example,dc=com", "groupBase": "ou=groups,dc=example,dc=com" } } ``` Run with: ```bash node lib/bin/index.js \ --ldap-base dc=example,dc=com \ --ldap-dn cn=admin,dc=example,dc=com --ldap-pwd admin \ --plugin core/auth/token \ --auth-token 'okta-token:okta-acme' \ --auth-token 'entra-token:entra-globex' \ --plugin core/scim \ --scim-base-map /etc/ldap-rest/scim-tenants.json ``` Okta authenticates with `Bearer okta-token` → `req.user = "okta-acme"` → SCIM operates under `ou=acme`. Entra ID authenticates with `Bearer entra-token` → `req.user = "entra-globex"` → operates under `ou=globex`. Any other caller falls back to the `"*"` entry. ### Mode 2 — Template (symmetric, naming-based) Use when all tenants follow the same branch pattern and the tenant id happens to be a safe DN value. ```bash node lib/bin/index.js \ ... \ --plugin core/auth/token \ --auth-token 'acme-secret:acme' \ --auth-token 'globex-secret:globex' \ --plugin core/scim \ --scim-user-base-template 'ou=users,ou={user},dc=example,dc=com' \ --scim-group-base-template 'ou=groups,ou={user},dc=example,dc=com' ``` The `{user}` placeholder is substituted with `req.user` **after being sanitized by `escapeDnValue`**, so any DN-special characters in the identity are properly escaped and cannot break out of the template. A caller authenticating as `acme` will see `ou=users,ou=acme,dc=example,dc=com` as its user base. The template is also honoured for the `"*"` wildcard values in a JSON map, if those contain `{user}`. ### Mode 3 — Static (single-tenant) If every caller targets the same subtree, just set: ```bash --scim-user-base ou=users,dc=example,dc=com --scim-group-base ou=groups,dc=example,dc=com ``` No template, no map. ### Mixing modes Nothing prevents combining them. A common pattern is: explicit map for a few named partners, template for the common case, static fallback for untagged tokens. ```bash --scim-base-map /etc/ldap-rest/partners.json # explicit overrides --scim-user-base-template 'ou=users,ou={user},dc=example,dc=com' --scim-user-base ou=users,dc=example,dc=com # absolute fallback ``` ### Debugging the resolution Enable `--log-level debug` and look for messages such as `LDAP search` with the computed `base` — you will see the per-request base chosen for each SCIM call. If your token is missing a `name` part, `req.user` will be `"token "`, which is unlikely to match any map key — always use `id:secret:name`. ## Examples ### Create a User ```bash curl -X POST http://localhost:8081/scim/v2/Users \ -H 'Authorization: Bearer my-secret' \ -H 'Content-Type: application/scim+json' \ -d '{ "schemas":["urn:ietf:params:scim:schemas:core:2.0:User"], "userName":"alice", "name":{"familyName":"Doe","givenName":"Alice"}, "emails":[{"value":"alice@example.com","primary":true}] }' ``` ### PATCH ```bash curl -X PATCH http://localhost:8081/scim/v2/Users/alice \ -H 'Authorization: Bearer my-secret' \ -H 'Content-Type: application/scim+json' \ -d '{ "schemas":["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "Operations":[ {"op":"replace","path":"displayName","value":"Alice Doe"} ] }' ``` ### Bulk with `bulkId` cross-reference ```bash curl -X POST http://localhost:8081/scim/v2/Bulk \ -H 'Authorization: Bearer my-secret' \ -H 'Content-Type: application/scim+json' \ -d '{ "schemas":["urn:ietf:params:scim:api:messages:2.0:BulkRequest"], "Operations":[ {"method":"POST","bulkId":"u1","path":"/Users", "data":{"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],"userName":"alice"}}, {"method":"POST","path":"/Groups", "data":{"schemas":["urn:ietf:params:scim:schemas:core:2.0:Group"], "displayName":"admins","members":[{"value":"bulkId:u1"}]}} ] }' ``` ## Error envelope All errors conform to RFC 7644 §3.12: ```json { "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], "status": "400", "scimType": "invalidFilter", "detail": "Unknown attribute path 'foo'" } ``` ## Hooks Other plugins can observe and transform SCIM operations via the dynamic hooks `scimusercreate`, `scimusercreatedone`, `scimuserupdate`, `scimuserupdatedone`, `scimuserdelete`, `scimuserdeletedone`, and their `scimgroup*` equivalents, plus `scimbulkdone` for Bulk. `scimgroupcreate` receives the group, the request and the group base. A group's RDN value is its `displayName`, unless the hook sets `id` on the group and the group mapping stores nothing in the RDN attribute: that `id` is then the RDN value. An `id` sent by the client is ignored. A hook returns all three, so that the hooks after it still get the base. ## Exposing the configuration to client apps (`configApi`) Load `core/configApi` alongside the SCIM plugin, and `GET /api/v1/config` will auto-discover SCIM and publish its metadata under `features.scim`. This lets browser-based editors and dashboards build SCIM-aware UIs without re-parsing server flags. ```bash node lib/bin/index.js \ ... \ --plugin core/scim \ --plugin core/static \ --plugin core/configApi ``` Response shape (truncated): ```json { "apiPrefix": "/api", "ldapBase": "dc=example,dc=com", "features": { "scim": { "enabled": true, "version": "2.0", "prefix": "/scim/v2", "endpoints": { "users": "/scim/v2/Users", "groups": "/scim/v2/Groups", "bulk": "/scim/v2/Bulk", "serviceProviderConfig": "/scim/v2/ServiceProviderConfig", "resourceTypes": "/scim/v2/ResourceTypes", "schemas": "/scim/v2/Schemas" }, "schemaUrls": { "user": "/static/schemas/scim/User.json", "group": "/static/schemas/scim/Group.json", "defaultMapping": "/static/schemas/scim/default-mapping.json" }, "capabilities": { "patch": true, "bulk": true, "filter": true, "sort": true, "etag": false, "changePassword": false, "maxResults": 200, "bulkMaxOperations": 100, "bulkMaxPayloadSize": 1048576 }, "resourceTypes": [ { "id": "User", "endpoint": "/scim/v2/Users", "schema": "urn:ietf:params:scim:schemas:core:2.0:User", "rdnAttribute": "uid", "objectClass": [ "top", "inetOrgPerson", "organizationalPerson", "person" ], "filterableAttributes": [ "userName", "externalId", "name", "displayName", "...", "id", "active" ], "mapping": [/* same shape as default-mapping.json */] }, { "id": "Group", "endpoint": "/scim/v2/Groups", "schema": "urn:ietf:params:scim:schemas:core:2.0:Group", "rdnAttribute": "cn", "mapping": [/* ... */] } ], "idStrategy": "rdn", "baseResolution": { "userBaseTemplate": "ou=users,ou={user},dc=example,dc=com", "groupBaseTemplate": null, "hasBaseMap": false } } } } ``` Notable fields: - `schemaUrls` — direct download URLs of the schema files SCIM reads (`User.json` and `Group.json` under `--schemas-path`, with the `default-mapping.json` template beside them when it is there), when a `core/static` plugin serves them merged, through its schema routes, under its own `--static-name` (editor UIs typically fetch them to render attribute pickers). Among plugins serving different paths, the one registered as `static` is preferred; a path an earlier plugin already serves, lies under or holds, is left to that one. Absent when none serves them: read it with a guard. - `resourceTypes[].mapping` — the live SCIM↔LDAP mapping used server-side, so UI builders can render filter helpers / attribute selectors without second round-trip. - `resourceTypes[].filterableAttributes` — the list of attribute paths accepted by the SCIM filter parser. - `baseResolution` — advertises template/map presence but NOT concrete base DNs: those vary per authenticated identity and remain server-side. No configuration is needed on the SCIM plugin to enable this — it advertises the `configurable` role automatically, and `core/configApi` picks it up. ## Known limitations (v1) - No `/Me` endpoint (RFC 7644 §3.11). - ETag / `If-Match` advertised but not enforced. - No `/.search` POST endpoint (use GET with `?filter=…`). - A list walks its result set to count it, bounded by `--scim-max-scanned` (10000 by default). Past that bound it answers `400 tooMany`, as RFC 7644 §3.12 allows; raise the bound or narrow the query with a filter. linagora-ldap-rest-a4bb78e/docs/usage/plugins/integrations/spaces.md000066400000000000000000000321401527331160600257240ustar00rootroot00000000000000# Organization Spaces `core/twake/spaces` serves the spaces of an organization under `/api/v1/organizations/:id/spaces`. A space gathers users and groups of the organization, each with a role in it: `viewer`, `editor` or `admin`. ## Behaviour - A space lives in the organization's space branch, given by a DN pattern where `{org}` stands for the organization id. Its `cn` is a generated UUID, its name is in the display name attribute. - A space holds DNs in three attributes, one per role. Each holds users of the organization's user branch and groups of its group branch, the branches [groups](groups.md) serves. - A user or a group holds one role in a space. A user's role in a space is the strongest of their own role and the roles of the linked groups they belong to. - The routes keep at least one admin among a space's users: a request that would demote or remove the last one is refused with 409 `LAST_ADMIN`. A linked group does not count as an admin. A tombstoned or erased admin is not a request, and can leave a space with none. - A space holds users and groups of its organization only: any other value is refused with a 400, whichever API writes the space, or moves it straight into another organization's space branch; a move refused names none of them. Not checked: moving a space out of the organizations' branches (to `ou=archive,…`, for instance) or deeper than one level under another organization, which takes its holders along, and moving a user or group to another organization, whose spaces in the one it left keep it. The one role and the admin are kept by the routes below only; a user or group a direct write, or two requests at once, left under two roles reads with the strongest, and a role change or removal takes it out of all of them. - A tombstone (see [tombstone](tombstone.md)) keeps its roles until it is erased, and is hidden from the spaces read through these routes, whatever the caller may read of the user branch: it never counts as an admin. It cannot be added. - An erased user or group leaves its spaces through the directory: enable the `refint` overlay on the three role attributes. - When a user who is erased or becomes a tombstone was the last admin among a space's users, its editors become admins, or its viewers when it has no editor. A space left with no user of its own is deleted, whatever groups it links. Both happen shortly after the deletion, and publish their events. Erasing a tombstone checks its spaces again, for a hand-over that failed or was never made. The hand-over relies on refint: without it an erased admin's DN stays in the space and still counts as an admin, so erasing two admins one after the other hands nothing over. A write of the space made at the same moment is retried twice; past that the hand-over is logged and left undone. - With `--twake-space-user-role-attribute`, each user entry holds one `:` value per space they are in, their resolved role. It follows every write of a space, every change of a linked group's members, and the deletion of a linked group, whichever API makes them, shortly after the write. A change only tells which users to look at: each one's values are then rewritten from the directory as it is, so changes made close together end the same whatever order they are followed in. Values not ending in `:viewer`, `:editor` or `:admin` are left alone. A tombstone keeps the values it held when it was deleted. - With `--rabbitmq-url`, every write of a space and every change of a member's role is published as an event, see below. - With either of the two, the plugin refuses to start when core/ldap/trash watches the group or space branches: a group or space it moves away is never followed. ## Events Each event goes to the `--twake-space-exchange` topic exchange with the routing key `twake.space.`. Like the user role attribute, events follow every write, whichever API makes it, shortly after it. ```json { "organizationId": "acme", "organizationDomain": "acme.com", "id": "3b9e2c71-5d4a-4f0e-9c8b-1a2d6e7f8091", "members": [ { "uuid": "6f1c0a52-8e3b-4d7f-a9c2-5b0e1d4f7a83", "username": "jdoe", "email": "jdoe@acme.example.org", "firstName": "John", "lastName": "Doe", "role": "editor" } ], "actor": "jsmith", "timestamp": "2026-10-06T09:12:44.512Z" } ``` Every event carries `organizationId`, `id` (the space), `actor` (who made the write, when known) and `timestamp`. A member is described as above, its `uuid` the user's `entryUUID`; a group is `{ id, name, role }`. `organizationDomain` is the organization's domain, read on its entry (`--twake-group-organization-dn`) from `--twake-instance-organization-domain-attribute` (default `twakeDomain`), the option `twake/instances` reads too. It is kept until the entry is changed through this server: another replica, or a write made straight to the directory, keeps the old domain until restart. It is left out when `--twake-group-organization-dn` is empty or the entry has no such value. The server does not start when the directory schema does not define that attribute. Writes made close together blur the actor: the member events of two writes of one space or group followed together may go out under the first one's. With `--twake-space-user-role-attribute`, the follow of a write may find a role another write moved in another space, not followed yet: it publishes that event with no actor, as the later follow finds nothing left to announce. - `created`: `name`, `members` (every user in the space with their resolved role, linked groups' members included) and `groups`. - `updated`: the changed `name`. - `deleted`. - `member.added`, `member.role.changed`, `member.removed`: `members`, the one user whose resolved role changed, whatever the cause: a member write, a group linked, unlinked or given another role, a user joining or leaving a linked group. `member.removed` gives the role the user held. - `group.linked`, `group.role.changed`, `group.unlinked`: `groups`, the one group, with its name when the event is published. A deleted group is unlinked from each of its spaces once, whether refint or a later write takes its DN out of them; one unlinked, then deleted before the unlink is followed, is unlinked too, under its id for a name. Renaming a linked group publishes nothing. - `synced`: the whole space, as `created` gives it, published by a sync. - `sync.completed`: `spaceIds`, the ids of every space of the organization, after its `synced` events. It has no `id`. A user who is deleted or becomes a tombstone publishes no member event. ## Sync An app repairs its copy of the spaces by publishing `twake.space.sync.requested` on the exchange: - `{ "organizationId", "id" }`: the space's `synced`, or `deleted` when it is gone; - `{ "organizationId" }`: a `synced` for each space of the organization, then `sync.completed`; - `{}`: a `twake.space.sync.requested` with `organizationId` for each organization, the entries one level under the parent of `{org}` in `--twake-space-base`, published on the exchange, so every organization is synced as above. A request reaches every queue bound to its key, `twake.space.#` included. An app that follows the events binds `twake.space.*`, `twake.space.member.#`, `twake.space.group.#` and `twake.space.sync.completed`, or ignores `twake.space.sync.requested`. The plugin consumes these requests from the `--twake-space-sync-queue` quorum queue, with a dead letter queue `.dlq`. Every replica consumes it, several requests at once, so the events of two syncs of one organization may interleave: their timestamps order them. A request it cannot read goes to the dead letter queue. A missing organization is skipped. A deleted one has no space left: its `sync.completed` lists none, and a space asked is `deleted`. A sync whose events the broker does not take fails, and its request is retried, then goes to the dead letter queue. The events of a sync carry no `actor`. They all carry the time just before the spaces were read, so, with synchronized clocks, the event of a write followed after that is newer. An app that applies only events newer than the last one keeps the right state, and keeps a space created after a `sync.completed` that does not list it. The server does not start when the broker cannot be reached. An event the broker cannot take once the server runs is logged with `result: "error"`; the event of a write is not sent again. Member events compare each moved user's roles before and after a change: - With `--twake-space-user-role-attribute`, the roles before are the values on the user's entry, so changes made close together announce each role once, in whatever order they are followed. - Without it, the roles before are worked out from the directory with the change undone. Two changes followed late may announce a role twice, or not at all. Consumers that need an exact copy resync from the routes. ## Routes Every route answers 404 `ORGANIZATION_NOT_FOUND` when the organization entry is missing or the caller cannot read it, and 410 `ORGANIZATION_DELETED` when it is deleted. A user the caller cannot read is not found (`USER_NOT_FOUND`). Errors are `{ "error": "...", "code": "..." }`. A request an authorization plugin refuses answers 403 `REFUSED`. A write answers `{ "success": true }` unless stated otherwise. A space reads, strongest role first: ```json { "id": "3b9e2c71-5d4a-4f0e-9c8b-1a2d6e7f8091", "name": "Design Sprint", "organizationId": "acme", "members": [ { "username": "jsmith", "role": "admin" }, { "username": "jdoe", "role": "editor" } ], "groups": [{ "id": "c2a8e1f0-7b3d-4e9a-8f61-2d5b9c0e4a17", "role": "viewer" }] } ``` `username` is the RDN value of a user, `id` the `cn` of a group. - `GET /spaces?page&limit&search&sortBy&sortOrder&user`: `search` (2 characters or more) matches the name; `sortBy` is `name`. With `user`, the spaces that user is in, directly or through a linked group, each with the user's `role` (404 `USER_NOT_FOUND` for an unknown user). Answers `{ organizationId, spaces, pagination: { page, limit, total, totalPages } }`. - `POST /spaces` with `{ name, members: [{ username, role }], groups?: [{ id, role }] }`: `name` follows the [groups](groups.md) rule, `members` holds at least one admin (400 `ADMIN_REQUIRED`), every user and group must exist in the organization (404 `USER_NOT_FOUND`, `GROUP_NOT_FOUND`). Answers 201 with the space. - `GET /spaces/:spaceId`: the space, or 404 `SPACE_NOT_FOUND`. - `PATCH /spaces/:spaceId` with `{ name }`: renames the space; any other field is a 400. - `DELETE /spaces/:spaceId`. - `GET /spaces/:spaceId/members?page&limit&search&sortBy&sortOrder`: the members' public profiles, as [groups](groups.md) answers them, each with its `role`; `sortBy` is `uid` (default), `displayName`, `mail`, `jobTitle` or `role`. - `POST /spaces/:spaceId/members` with `{ usernames: [...], role }`: every user must exist in the organization (404 `USER_NOT_FOUND`, nothing added), at most `--twake-group-max-page-limit` per request, as for every list of users or groups below. A member already holding that role is left as is; one holding another role answers 409 `MEMBER_EXISTS`, nothing added. - `PATCH /spaces/:spaceId/members/:userId` with `{ role }`, and `DELETE /spaces/:spaceId/members/:userId`: 404 `MEMBER_NOT_FOUND` for a user who is not a member. - `GET /spaces/:spaceId/groups`: `{ organizationId, id, groups }`, each group `{ id, name, role }`, by name. - `POST /spaces/:spaceId/groups` with `{ groupIds: [...], role }`: every group must exist in the organization (404 `GROUP_NOT_FOUND`, nothing linked). A group already linked with another role answers 409 `GROUP_ALREADY_LINKED`. - `PATCH /spaces/:spaceId/groups/:groupId` with `{ role }`, and `DELETE /spaces/:spaceId/groups/:groupId`: 404 `GROUP_NOT_FOUND` for a group that is not linked. ## Configuration ```bash --plugin core/twake/spaces \ --twake-space-base 'ou=spaces,ou={org},dc=example,dc=com' ``` The plugin loads [groups](groups.md), and reads its options for the organization's users, groups and organization entry. - `--twake-space-base`: required, holding `{org}` once. - `--twake-space-class` (default `top,twakeSpace`): the classes of a space created. - `--twake-space-display-name-attribute` (default `twakeDisplayName`). - `--twake-space-admin-attribute` (default `twakeSpaceAdmin`), `--twake-space-editor-attribute` (default `twakeSpaceEditor`), `--twake-space-viewer-attribute` (default `twakeSpaceViewer`): the role attributes, holding DNs. - `--twake-space-user-role-attribute` (no default): the multi-valued user attribute holding the user's role in each space, such as `twakeSpaceRole`. Unset, no user entry is written. - `--twake-space-exchange` (default `space`): the topic exchange of the events, published only when `--rabbitmq-url` is set. - `--twake-space-sync-queue` (default `twake.space.sync.requested.ldap-rest`): the queue of the sync requests, consumed only when `--rabbitmq-url` is set. ## Dependencies ``` core/twake/spaces ├─ requires: core/twake/groups ├─ requires: core/ldap/onChange └─ requires: core/rabbitmq (with RabbitMQ) ``` linagora-ldap-rest-a4bb78e/docs/usage/plugins/integrations/tombstone.md000066400000000000000000000120511527331160600264570ustar00rootroot00000000000000# Tombstone `core/twake/tombstone` turns the delete of an account into a tombstone. The entry stays, so its identifiers cannot be taken by a new account, but it is flagged as deleted, dated, locked, carries the reason, and loses the attributes the deployment names. A tombstone is erased later, from an operator's decision: once the deletion is old enough, or right away when forced. ## Behaviour - A delete of an entry whose DN matches `--twake-tombstone-dn` — under any spelling the directory folds, so spaces, case and escapes do not matter — writes, in one modify: the deleted attribute, the deletion date (now), the reason, the lock, and removes `--twake-tombstone-clear-attributes`. Other entries are deleted as usual. A multi-valued RDN may not match: an expression anchored on `uid=` misses a DN written `cn=…+uid=…`, and that delete removes the entry for good. - A delete of an existing tombstone writes nothing and answers as a delete. It fires the `twakedeletionreplay` hook with the tombstone, so [lifecycle events](lifecycle-events.md) publishes the deletion again, with the date of the first one. This is the way to replay a `deleted` event that was lost, for instance while the broker was down. - The tombstone is written by the server, not as the caller: the caller's delete right is what was judged, and a write right is not required on top. So the `deleted` event of that write has no `$context.actor`, `$context.requestId` or `$context.source`. A replay has them. - The tombstone keeps its group memberships until it is erased. - SCIM treats a tombstone as gone: reads answer 404, lists leave it out, and a create with the same identity erases the tombstone and creates a new entry. If the directory then refuses the new entry, the tombstone and its memberships are put back once the response is sent. - Erasing removes the entry, then its group memberships. It publishes nothing. - A tombstone cannot be restored. Clearing its deleted attribute by hand is not a supported operation. The tombstone is written after every other plugin has judged the delete, so a delete that an authorization plugin refuses writes nothing. `core/ldap/trash` moves a deleted entry before this plugin sees the delete, so no tombstone would be written in a branch it watches. The server refuses to start when one of the `;`-separated `--trash-watched-bases` covers or sits under a `--twake-tombstone-dn` pattern, and an empty list watches every branch. ## Reason The reason is, in order: - the one given to `tombstone(dn, reason, req)` by another plugin; - the `--twake-tombstone-reason-header` request header; - `--twake-tombstone-default-reason`. When `--twake-tombstone-reasons` is set, any other reason is refused with a 400. Otherwise a reason is letters, digits, `_`, `.` or `-`. ## Erase ```http POST /api/v1/twake/tombstones/erase Content-Type: application/json { "dn": "uid=jdoe,ou=users,dc=example,dc=com", "force": false } ``` - `200`: erased. - `404`: no tombstone at this DN. - `409`: `force` is not true, and either the deletion is younger than `--twake-tombstone-erase-min-age` or its date cannot be read as [lifecycle events](lifecycle-events.md) reads it. Other plugins call `erase(dn, { force, req })`. ## Configuration The tombstone attributes are the lifecycle ones, shared with [lifecycle events](lifecycle-events.md): ```bash --plugin core/twake/tombstone \ --twake-lifecycle-lock-attribute pwdAccountLockedTime \ --twake-lifecycle-lock-value 000001010000Z \ --twake-lifecycle-deleted-attribute employeeType \ --twake-lifecycle-deleted-value deleted \ --twake-lifecycle-deleted-at-attribute roomNumber \ --twake-lifecycle-deleted-at-format iso8601 \ --twake-lifecycle-reason-attribute businessCategory \ --twake-tombstone-dn '^uid=[^,]+,ou=users,dc=example,dc=com$' \ --twake-tombstone-dn '^uid=[^,]+,ou=[^,]+,ou=organizations,dc=example,dc=com$' \ --twake-tombstone-reasons deleted,user_request \ --twake-tombstone-clear-attributes mobile,telephoneNumber \ --twake-tombstone-erase-min-age 2592000 \ --twake-tombstone-group-bases ou=groups,dc=example,dc=com ``` - `--twake-lifecycle-deleted-attribute`: required. - `--twake-lifecycle-lock-attribute`, `--twake-lifecycle-lock-value`: the lock written on a tombstone, read as [lifecycle events](lifecycle-events.md) reads it. A lock attribute other than `pwdAccountLockedTime` needs its value (`TRUE` for `nsAccountLock` on 389-ds), or the server does not start: `000001010000Z` means nothing there. - `--twake-lifecycle-reason-attribute`: empty means the reason is not recorded. - `--twake-tombstone-dn`: regular expressions, matched case-insensitively. A DN holds commas, so in `DM_TWAKE_TOMBSTONE_DN` end each one with `;`. - `--twake-tombstone-default-reason`: default `deleted`. - `--twake-tombstone-reason-header`: default `x-deletion-reason`. - `--twake-tombstone-erase-min-age`: seconds, default 30 days. - `--twake-tombstone-group-bases`: where memberships are looked for at erase, by `member`. Defaults to `--ldap-group-base`, then `--ldap-base`. In the environment variable, end each base with `;`. linagora-ldap-rest-a4bb78e/docs/usage/plugins/ldap/000077500000000000000000000000001527331160600223365ustar00rootroot00000000000000linagora-ldap-rest-a4bb78e/docs/usage/plugins/ldap/README.md000066400000000000000000000117471527331160600236270ustar00rootroot00000000000000# LDAP Plugins Plugins for LDAP entity management. ## Overview | Plugin | Entity | Features | | ----------------------------------------- | ------------------------ | --------------------------------------- | | [flat-generic](flat-generic.md) | Users, Positions, Custom | Schema-driven, Validation, Pointers | | [groups](groups.md) | Groups | Member validation, Nested groups | | [organizations](organizations.md) | Organizational Units | Tree navigation, Search | | [bulk-import](bulk-import.md) | All (bulk operations) | CSV import, Template generation | | [trash](trash.md) | All (soft delete) | Trash system, Recovery | | [external-users](external-users.md) | Contacts | Automatic creation | | [on-change](on-change.md) | All | Change detection | | [enterprise-rules](enterprise-rules.md) | All (business rules) | Uniqueness, Mail domains, Delete guards | | [account-lifecycle](account-lifecycle.md) | Accounts | Enable/disable, Password reset | | [password-policy](password-policy.md) | Password management | Policies, Expiration, Lockout, Unlock | | [raw](raw.md) | Whole directory (read) | Root DSE, Schema, Tree, Raw search | ## API Endpoints ### Entity Management (ldapFlatGeneric) ``` GET /api/v1/ldap/{pluralName} # List entries GET /api/v1/ldap/{pluralName}/{id} # Get entry POST /api/v1/ldap/{pluralName} # Create entry PUT /api/v1/ldap/{pluralName}/{id} # Modify entry DELETE /api/v1/ldap/{pluralName}/{id} # Delete entry ``` ### Groups (ldapGroups) ``` GET /api/v1/ldap/groups # List groups GET /api/v1/ldap/groups/{cn} # Get group POST /api/v1/ldap/groups # Create group PUT /api/v1/ldap/groups/{cn} # Modify group POST /api/v1/ldap/groups/{cn}/rename # Rename group POST /api/v1/ldap/groups/{cn}/move # Move group DELETE /api/v1/ldap/groups/{cn} # Delete group ``` ### Organizations (ldapOrganizations) ``` GET /api/v1/ldap/organizations/top # Root organization GET /api/v1/ldap/organizations/{dn}/subnodes # Sub-organizations GET /api/v1/ldap/organizations/{dn}/subnodes/search # Search GET /api/v1/ldap/organizations/{dn}/search # Search the whole subtree POST /api/v1/ldap/organizations # Create PUT /api/v1/ldap/organizations/{dn} # Modify POST /api/v1/ldap/organizations/{dn}/move # Move DELETE /api/v1/ldap/organizations/{dn} # Delete ``` ### Password Policy (passwordPolicy) ``` GET /api/v1/password-policy # Get ppolicy configuration GET /api/v1/users/{id}/password-status # Get user password status POST /api/v1/users/{id}/unlock # Unlock locked account GET /api/v1/password-policy/expiring-soon # List expiring passwords GET /api/v1/password-policy/locked-accounts # List locked accounts GET /api/v1/password-policies # List policies POST /api/v1/password-policies # Create a policy GET /api/v1/password-policies/{cn} # Read a policy PATCH /api/v1/password-policies/{cn} # Change a policy DELETE /api/v1/password-policies/{cn} # Delete a policy PUT /api/v1/users/{id}/password-policy # Assign a policy POST /api/v1/password/validate # Validate complexity (optional) ``` ### Low-level browsing (ldapRaw) ``` GET /api/v1/ldap/raw/bases # Exposed subtrees GET /api/v1/ldap/raw/rootdse # Server capabilities GET /api/v1/ldap/raw/schema # Parsed directory schema GET /api/v1/ldap/raw/entry/{dn} # Read one entry GET /api/v1/ldap/raw/children/{dn} # Direct children of an entry GET /api/v1/ldap/raw/search # Raw search (base, scope, filter) ``` ### Bulk Import (ldapBulkImport) ``` GET /api/v1/ldap/bulk-import/{resource}/template.csv # CSV template POST /api/v1/ldap/bulk-import/{resource} # Import from CSV ``` ## Schemas JSON schemas define the structure of LDAP entities. ### Standard Schemas - `./static/schemas/standard/users.json` - inetOrgPerson users - `./static/schemas/standard/groups.json` - groupOfNames groups - `./static/schemas/standard/organizations.json` - Organizational units ### Twake Schemas - `./static/schemas/twake/users.json` - Twake users - `./static/schemas/twake/groups.json` - Twake groups - `./static/schemas/twake/positions.json` - Positions/roles ### Active Directory Schemas - `./static/schemas/ad/users.json` - AD accounts - `./static/schemas/ad/groups.json` - AD security groups linagora-ldap-rest-a4bb78e/docs/usage/plugins/ldap/account-lifecycle.md000066400000000000000000000123711527331160600262550ustar00rootroot00000000000000# LDAP Account Lifecycle Plugin The two account operations an administrator performs most often — change an account's state, and reset its password — as first-class endpoints. Both are expressed against semantic roles, never attribute names. What "disabled" _is_ in a given directory — a DN in a nomenclature, a string, a boolean — is schema configuration. ## Configuration ```bash --plugin core/ldap/accountLifecycle ``` The plugin loads `core/ldap/flatGeneric` if it is not already there, and adds endpoints to each entity whose schema asks for them: - `POST /api/v1/ldap/{pluralName}/{id}/status` when the schema declares an `accountStatus` attribute carrying named `states` - `POST /api/v1/ldap/{pluralName}/{id}/password` when it declares a `password` attribute Changes go through the entity's own modify path, so schema validation, hooks and authorization apply exactly as they do to any other update. ## Declaring the states ```json { "twakeAccountStatus": { "type": "pointer", "branch": ["ou=twakeAccountStatus,ou=nomenclature,dc=example,dc=com"], "role": "accountStatus", "generated": true, "default": "cn=active,ou=twakeAccountStatus,ou=nomenclature,dc=example,dc=com", "states": { "enabled": "cn=active,ou=twakeAccountStatus,ou=nomenclature,dc=example,dc=com", "disabled": "cn=disabled,ou=twakeAccountStatus,ou=nomenclature,dc=example,dc=com", "noAccess": "cn=noaccess,ou=twakeAccountStatus,ou=nomenclature,dc=example,dc=com", "toDelete": "cn=todelete,ou=twakeAccountStatus,ou=nomenclature,dc=example,dc=com" } } } ``` `enabled` and `disabled` are the names the API speaks; a deployment adds its own freely. `default` is what a new account gets, applied by [enterprise-rules](enterprise-rules.md). ## Changing the state ```bash curl -X POST http://localhost:8081/api/v1/ldap/users/alice/status \ -H 'Content-Type: application/json' \ -d '{"state": "disabled"}' ``` ```json { "success": true, "state": "disabled" } ``` An unknown state is refused with a `400` that names the ones this directory knows, so a client never has to hardcode the list: ```json { "error": "Unknown state \"retired\" for user; known states: enabled, disabled, noAccess, toDelete" } ``` ## Resetting the password ```bash curl -X POST http://localhost:8081/api/v1/ldap/users/alice/password \ -H 'Content-Type: application/json' -d '{}' ``` ```json { "success": true, "generated": true, "forceChange": true, "password": "xY3k…" } ``` With no `password` in the body, one is generated and returned **once** — the credential attributes are marked `neverReturn` in the schema precisely so a later read cannot hand it back. Supply `password` to set a chosen one; the answer then carries `"generated": false` and does not echo it. A `password` that is present has to be a non-empty string. An empty one — or `null`, or anything that is not text — is refused with a `400`: ```json { "error": "Field password must be a non-empty string; omit it to have one generated" } ``` It used to be taken as an absent one, so `{"password": ""}` answered `success` after setting a random credential the caller never saw: a client that emptied the field by accident believed it had set the password it typed. Omitting the field is how a generated password is asked for. A password longer than 256 characters, or holding a control character (a NUL byte, a newline…), is refused too: nobody can type it at a login prompt. The endpoint applies no minimum length or complexity rule of its own. These rules belong to this endpoint: a password written as an attribute, through `PUT /api/v1/ldap/{pluralName}/{id}` or a creation, is not checked. `forceChange` (a boolean, true by default) also writes the `passwordReset`-role attribute so the directory asks for a new password at next login. For a boolean attribute the values written are `TRUE` and `FALSE`; a directory that spells them otherwise says so through `states.required` and `states.cleared`. | Field | Meaning | | ------------- | --------------------------------------------------- | | `generated` | The server chose the password | | `password` | Present only when generated | | `forceChange` | Whether the "change at next login" flag was written | > **Note** — `pwdReset` is an operational attribute of the OpenLDAP `ppolicy` > overlay. Writing it requires the overlay to be loaded; without it the > directory answers `attribute type undefined`. ## Errors | Status | When | | ------ | -------------------------------------------------------------- | | `400` | Unknown state, or a body with no `state` | | `400` | A `password` that is present but empty, `null` or not a string | | `400` | A `password` over 256 characters or with a control character | | `400` | A `forceChange` that is not a boolean | | `404` | No such account | ## See also - [flat-generic](flat-generic.md) — `role`, `states` and `neverReturn` - [enterprise-rules](enterprise-rules.md) — the default state of a new account - [password-policy](password-policy.md) — expiry, lockout and complexity linagora-ldap-rest-a4bb78e/docs/usage/plugins/ldap/bulk-import.md000066400000000000000000000446351527331160600251410ustar00rootroot00000000000000# LDAP Bulk Import Plugin Generic CSV-based bulk import plugin for LDAP resources, automatically configured from JSON schemas. ## Overview The `ldapBulkImport` plugin provides CSV import functionality for any LDAP resource type (users, groups, etc.) by reading JSON schema definitions. It automatically: - Generates CSV templates based on schema attributes - Excludes fixed attributes from CSV (auto-populated from schema) - Auto-calculates organization link and path from a single `organizationDn` column - Validates data against schema requirements - Supports dry-run mode for testing - Provides detailed success/failure reports ## Configuration ### CLI Arguments ```bash --plugin core/ldap/bulkImport \ --bulk-import-schemas "users:./static/schemas/twake/users.json,groups:./static/schemas/twake/groups.json" \ --bulk-import-max-file-size 10485760 \ --bulk-import-batch-size 100 ``` ### Configuration Options | Argument | Environment Variable | Default | Description | | ----------------------------- | ------------------------------ | ---------- | --------------------------------------------- | | `--bulk-import-schemas` | `DM_BULK_IMPORT_SCHEMAS` | (required) | Comma-separated list of resource:schema pairs | | `--bulk-import-max-file-size` | `DM_BULK_IMPORT_MAX_FILE_SIZE` | `10485760` | Maximum upload file size in bytes (10MB) | | `--bulk-import-batch-size` | `DM_BULK_IMPORT_BATCH_SIZE` | `100` | Number of records to process at once | ### Schema Configuration Format Format: `"resourceName:path/to/schema.json,resourceName2:path/to/schema2.json"` **Example**: ```bash --bulk-import-schemas "users:./static/schemas/twake/users.json,groups:./static/schemas/twake/groups.json" ``` This creates two import APIs: - `/api/v1/ldap/bulk-import/users/*` - `/api/v1/ldap/bulk-import/groups/*` ## REST API For each configured schema, the plugin automatically generates two endpoints: ### GET /api/v1/ldap/bulk-import/{resource}/template.csv Downloads a CSV template file with headers based on the schema. **Response**: CSV file with headers **Example**: ```bash curl -O "http://localhost:8081/api/v1/ldap/bulk-import/users/template.csv" ``` **Generated template** (for users): ```csv uid,cn,sn,givenName,mail,userPassword,telephoneNumber,organizationDn ``` **Notes**: - Excludes attributes with `"fixed": true` (auto-populated) - Excludes attributes with `"role": ["organizationLink"]` or `"role": ["organizationPath"]` (auto-calculated) - Includes special column `organizationDn` for organization assignment ### POST /api/v1/ldap/bulk-import/{resource} Bulk import entries from CSV file. **Request** (multipart/form-data): | Field | Type | Required | Default | Description | | ----------------- | ------- | -------- | ------- | --------------------------------------------- | | `file` | File | Yes | - | CSV file to import | | `dryRun` | Boolean | No | `false` | Validate without creating entries | | `updateExisting` | Boolean | No | `false` | Update entries if they already exist | | `continueOnError` | Boolean | No | `true` | Continue processing even if some entries fail | **Response (200)**: ```json { "success": true, "total": 100, "created": 95, "updated": 0, "skipped": 3, "failed": 2, "errors": [ { "line": 15, "identifier": "baduser", "error": "Missing required attribute: sn" }, { "line": 42, "identifier": "duplicate", "error": "Organization not found: ou=InvalidDept,dc=example,dc=com" } ], "details": { "duration": "2.5s", "linesProcessed": 100 } } ``` **Example**: ```bash curl -X POST "http://localhost:8081/api/v1/ldap/bulk-import/users" \ -F "file=@users.csv" \ -F "dryRun=false" \ -F "updateExisting=false" \ -F "continueOnError=true" ``` ## CSV Format ### Basic Structure ```csv uid,cn,sn,givenName,mail,userPassword,organizationDn jdoe,John Doe,Doe,John,john.doe@example.com,password123,ou=IT,ou=organization,dc=example,dc=com asmith,Alice Smith,Smith,Alice,alice.smith@example.com,secret456,ou=HR,ou=organization,dc=example,dc=com ``` A value must sit under a column whose name is a syntactically valid LDAP attribute name (`mail`, `2.5.4.3`, `cn;lang-fr`). Otherwise its line fails with `Invalid column name` and is counted in `failed`. The check is on syntax only: an unknown attribute such as `maill` passes it and fails on the directory side. Empty cells are ignored whatever their column is called, so a comment column left empty, or a trailing comma, does not prevent the import. ### Special Column: `organizationDn` The `organizationDn` column is special and triggers automatic calculation of: 1. **Organization Link** - Attribute with `role: ["organizationLink"]` in schema - Automatically set to the value of `organizationDn` 2. **Organization Path** - Attribute with `role: ["organizationPath"]` in schema - Automatically fetched from the organization's LDAP entry - Example: `"IT / organization"` for `ou=IT,ou=organization,dc=example,dc=com` ### Multi-Value Attributes Use semicolon (`;`) as separator for multi-value attributes: ```csv uid,cn,sn,mail,organizationDn jdoe,John Doe,Doe,john@example.com;j.doe@example.com;jdoe@company.com,ou=IT,dc=example,dc=com ``` This creates: ``` mail: john@example.com mail: j.doe@example.com mail: jdoe@company.com ``` ### Fixed Attributes Attributes marked as `"fixed": true` in the schema are automatically added. A row with a value under such a column fails, whatever the case of the column name; an empty cell is ignored as any other: **Schema**: ```json { "properties": { "objectClass": { "type": "array", "fixed": true, "default": ["inetOrgPerson", "organizationalPerson", "person", "top"] } } } ``` **CSV** (objectClass NOT included): ```csv uid,cn,sn,mail jdoe,John Doe,Doe,jdoe@example.com ``` **Result in LDAP**: ``` objectClass: inetOrgPerson objectClass: organizationalPerson objectClass: person objectClass: top uid: jdoe cn: John Doe sn: Doe mail: jdoe@example.com ``` ## Complete Example ### Step 1: Configure Plugin ```bash --plugin core/ldap/bulkImport \ --bulk-import-schemas "users:./static/schemas/twake/users.json" ``` ### Step 2: Download Template ```bash curl -O "http://localhost:8081/api/v1/ldap/bulk-import/users/template.csv" ``` **Generated `users-template.csv`**: ```csv uid,cn,sn,givenName,mail,userPassword,telephoneNumber,organizationDn ``` ### Step 3: Fill CSV File **users.csv**: ```csv uid,cn,sn,givenName,mail,userPassword,telephoneNumber,organizationDn jdoe,John Doe,Doe,John,john.doe@example.com,SecurePass123,+1-555-0100,ou=Engineering,ou=organization,dc=example,dc=com asmith,Alice Smith,Smith,Alice,alice.smith@example.com,SecretPass456,+1-555-0101,ou=Marketing,ou=organization,dc=example,dc=com bwilson,Bob Wilson,Wilson,Bob,bob.wilson@example.com,MyPass789,+1-555-0102,ou=Engineering,ou=organization,dc=example,dc=com ``` ### Step 4: Test with Dry Run ```bash curl -X POST "http://localhost:8081/api/v1/ldap/bulk-import/users" \ -F "file=@users.csv" \ -F "dryRun=true" ``` **Response**: ```json { "success": true, "total": 3, "created": 3, "failed": 0, "errors": [], "details": { "duration": "0.5s", "linesProcessed": 3 } } ``` ### Step 5: Import for Real ```bash curl -X POST "http://localhost:8081/api/v1/ldap/bulk-import/users" \ -F "file=@users.csv" \ -F "dryRun=false" ``` ### Step 6: Verify in LDAP ```bash ldapsearch -x -b "ou=users,dc=example,dc=com" "(uid=jdoe)" ``` **Result**: ``` dn: uid=jdoe,ou=users,dc=example,dc=com objectClass: inetOrgPerson objectClass: organizationalPerson objectClass: person objectClass: top uid: jdoe cn: John Doe sn: Doe givenName: John mail: john.doe@example.com userPassword: {SSHA}... telephoneNumber: +1-555-0100 twakeDepartmentLink: ou=Engineering,ou=organization,dc=example,dc=com twakeDepartmentPath: Engineering ``` ## Operation Modes ### Dry Run Mode Validate CSV without creating entries: ```bash curl -X POST "http://localhost:8081/api/v1/ldap/bulk-import/users" \ -F "file=@users.csv" \ -F "dryRun=true" ``` **Use cases**: - Test CSV format before actual import - Validate required attributes - Check organization DNs exist - Estimate import duration A dry run is judged as the import is: a row the caller may not write fails, and a row updating an existing entry counts as `updated`. ### Authorization Each row's write is judged by the authorization plugins before anything is looked for in the directory: the entry, its organization, whether it exists. A row is let through when the caller may create its entry, or change the entry it names (an administrator updating the accounts of their organization from a file naming none). Otherwise it fails with `Token does not have permission on this branch` (`code: forbidden`), whether its entry and its organization are there or not. A row the caller may write tells, as a single creation does, whether its identifier is taken: it is then skipped, or updated with `updateExisting`. The organization link of a row names one organization: a row naming several, or holding an empty value (`;` in a cell), fails as invalid, reported in `errors` (a 400 answer with `continueOnError=false`). ### Update Existing Mode Update entries that already exist: ```bash curl -X POST "http://localhost:8081/api/v1/ldap/bulk-import/users" \ -F "file=@users.csv" \ -F "updateExisting=true" ``` **Behavior**: - If entry exists: Updates all attributes from CSV - If entry doesn't exist: Creates new entry - Uses LDAP `modify` operation for updates ### Continue on Error Mode Process all records even if some fail: ```bash curl -X POST "http://localhost:8081/api/v1/ldap/bulk-import/users" \ -F "file=@users.csv" \ -F "continueOnError=true" ``` **Behavior** (default: `true`): - Continues processing remaining records after errors - Collects all errors in response - Reports partial success - Successfully created entries remain in LDAP even if later entries fail **Example with 5 entries**: - Entry 1: ✅ Created - Entry 2: ✅ Created - Entry 3: ❌ Failed (invalid organization) - Entry 4: ✅ Created (processed despite previous error) - Entry 5: ✅ Created **Result**: 4 created, 1 failed, all errors reported **Opposite** (`continueOnError=false`): - Stops at first error - Returns immediately with error - Previously created entries remain in LDAP - No rollback (LDAP doesn't support transactions) **Example with same 5 entries**: - Entry 1: ✅ Created - Entry 2: ✅ Created - Entry 3: ❌ Failed → **STOPS HERE** - Entry 4: ⏭️ Not processed - Entry 5: ⏭️ Not processed **Result**: 2 created, 1 failed, entries 4-5 not processed **Important**: Entries already created are NOT rolled back. If you need to undo, you must manually delete them or re-run with correct data using `updateExisting=true`. ## Error Handling ### Common Errors #### Missing Required Attribute **CSV**: ```csv uid,cn,mail jdoe,John Doe,jdoe@example.com ``` **Error**: ```json { "line": 2, "identifier": "jdoe", "error": "Missing required attribute: sn" } ``` #### Invalid Organization DN **CSV**: ```csv uid,cn,sn,organizationDn jdoe,John Doe,Doe,ou=NonExistent,dc=example,dc=com ``` **Error**: ```json { "line": 2, "identifier": "jdoe", "error": "Organization not found: ou=NonExistent,dc=example,dc=com" } ``` #### Duplicate Entry **Scenario**: User already exists in LDAP **CSV**: ```csv uid,cn,sn,mail existinguser,Updated Name,User,new@example.com newuser,New User,User,new2@example.com ``` **With `updateExisting=false` (default)**: ```json { "success": true, "total": 2, "created": 1, "skipped": 1, "failed": 0, "errors": [] } ``` - `existinguser`: ⏭️ Skipped (already exists) - `newuser`: ✅ Created **With `updateExisting=true`**: ```json { "success": true, "total": 2, "created": 1, "updated": 1, "skipped": 0, "failed": 0, "errors": [] } ``` - `existinguser`: ✏️ Updated with new values - `newuser`: ✅ Created **Behavior**: - With `updateExisting=false`: Skipped (not an error) - With `updateExisting=true`: Updated ### Error Response Format ```json { "success": true, "total": 10, "created": 7, "failed": 3, "errors": [ { "line": 5, "identifier": "user004", "error": "Missing required attribute: mail" }, { "line": 8, "identifier": "user007", "error": "Organization not found: ou=Invalid,dc=example,dc=com" }, { "line": 10, "identifier": "user009", "error": "Invalid DN format" }, { "line": 11, "identifier": "user010", "error": "user: \"jane@example.com\" is already used (mail must be unique)", "code": "unique", "attribute": "mail", "value": "jane@example.com" } ] } ``` A row refused by a rule a client is expected to explain — a value already used, a mail domain outside the authorised ones, with [enterprise rules](enterprise-rules.md) loaded — carries the same `code`, `attribute`, `value` and `domains` as the answer of a single write: see [the error format](../../../client-development/api/rest-api.md#error-response-format). Other rows only have `error`. ## Best Practices ### Handling Partial Failures When importing large batches, some entries may fail while others succeed: **Recommended workflow**: 1. **First import with continueOnError=true**: ```bash curl -X POST "http://localhost:8081/api/v1/ldap/bulk-import/users" \ -F "file=@users.csv" \ -F "continueOnError=true" ``` 2. **Check response for errors**: ```json { "created": 95, "failed": 5, "errors": [ { "line": 10, "identifier": "user10", "error": "..." }, { "line": 25, "identifier": "user25", "error": "..." } ] } ``` 3. **Fix failed entries in CSV**: - Extract lines 10, 25, etc. - Correct the errors - Create a new CSV with only failed entries 4. **Re-import fixed entries**: ```bash curl -X POST "http://localhost:8081/api/v1/ldap/bulk-import/users" \ -F "file=@failed-users-fixed.csv" \ -F "continueOnError=true" ``` **Result**: All entries successfully imported without duplicates. ### Re-running an Import If you need to re-run an import (e.g., after partial failure): **Option 1: Skip existing entries** (default) ```bash # Only creates new entries, skips existing ones -F "updateExisting=false" ``` - Safe for re-runs - Won't modify existing data - Good for: Adding new entries only **Option 2: Update existing entries** ```bash # Updates existing entries with CSV data -F "updateExisting=true" ``` - Updates all attributes from CSV - Good for: Correcting bulk data, synchronization **Option 3: Delete and re-create** ```bash # First, delete all entries manually or via API # Then re-import with fresh data ``` - Clean slate approach - Good for: Testing, development ## Authorization The bulk import plugin respects all configured authorization plugins: - Uses `ldapaddrequest` hook for each entry - Checks write permissions on target branch - Checks organization access permissions - Supports `authzPerBranch` plugin **Example with authzPerBranch**: If user only has write permission on `ou=Engineering`: - Can import users to `ou=Engineering` - Cannot import users to `ou=Marketing` (fails with permission error) ## Performance ### Batch Processing The plugin processes records in batches (default: 100): ```bash --bulk-import-batch-size 100 ``` **Recommendations**: - **Small files (<1000 records)**: Use default batch size - **Large files (>10000 records)**: Increase to 500-1000 - **Slow LDAP server**: Decrease to 50 ### File Size Limits Default maximum file size: 10MB ```bash --bulk-import-max-file-size 10485760 # 10MB --bulk-import-max-file-size 52428800 # 50MB ``` **File size estimates**: - ~100 bytes per user record - 10MB ≈ 100,000 users - 50MB ≈ 500,000 users ### Processing Speed Typical performance (depends on LDAP server and network): - **Dry run**: ~1000 records/second - **Create**: ~100-500 records/second - **Update**: ~50-200 records/second ## Integration with Other Plugins ### With ldapFlat ```bash --plugin core/ldap/flatGeneric \ --flat-resources "users:./static/schemas/twake/users.json" \ --plugin core/ldap/bulkImport \ --bulk-import-schemas "users:./static/schemas/twake/users.json" ``` Both plugins share the same schema for consistency. ### With authzPerBranch ```bash --plugin core/auth/authzPerBranch \ --authz-per-branch-config '{"users":{"admin":{"ou=users,dc=example,dc=com":{"write":true}}}}' \ --plugin core/ldap/bulkImport \ --bulk-import-schemas "users:./static/schemas/twake/users.json" ``` Bulk import respects authorization rules. ### With onChange/James ```bash --plugin core/ldap/onChange \ --plugin twake/james \ --plugin core/ldap/bulkImport \ --bulk-import-schemas "users:./static/schemas/twake/users.json" ``` Each imported user triggers `onChange` hooks (e.g., creates mailbox in James). ## Troubleshooting ### Problem: "No file uploaded" **Cause**: Missing file in request **Solution**: ```bash curl -X POST "..." -F "file=@users.csv" # Correct curl -X POST "..." -d "file=users.csv" # Wrong ``` ### Problem: "Only CSV files are allowed" **Cause**: File doesn't have `.csv` extension or correct mimetype **Solution**: - Rename file to `*.csv` - Check file mimetype: `file --mime-type users.csv` ### Problem: All records failed with "Missing required attribute" **Cause**: CSV headers don't match schema attribute names **Solution**: - Download template with `GET /template.csv` - Ensure CSV headers match exactly (case-sensitive) - Check for extra spaces in headers ### Problem: "Organization not found" **Cause**: `organizationDn` in CSV points to non-existent organization **Solution**: - Verify organization exists: `ldapsearch -x -b "ou=IT,..." -s base` - Check DN syntax (commas, spaces, escaping) - Create missing organizations first ### Problem: Slow import (timeout) **Cause**: Too many records or slow LDAP server **Solution**: - Split large CSV into smaller files - Increase timeout in HTTP client - Reduce `--bulk-import-batch-size` - Use `dryRun` to estimate duration first ## See Also - [LDAP Flat Generic Plugin](ldapFlatGeneric.md) - For individual CRUD operations - [Authorization Plugin](authzPerBranch.md) - For access control - [JSON Schemas](schemas/SCHEMAS.md) - For schema format reference - [LDAP Organizations](ldapOrganizations.md) - For organization management linagora-ldap-rest-a4bb78e/docs/usage/plugins/ldap/enterprise-rules.md000066400000000000000000000220041527331160600261660ustar00rootroot00000000000000# LDAP Enterprise Rules Plugin The business rules an enterprise directory needs on top of per-attribute validation: uniqueness across a shared namespace, mail addresses confined to the domains an organization owns, computed organization paths, normalised quotas, and referential integrity on deletion. Every rule is driven by the loaded entity schemas. The plugin contains no attribute name, no domain and no nomenclature value, so the same code serves a directory laid out differently without a change. ## Configuration ```bash --plugin core/ldap/enterpriseRules ``` It reads the schemas already loaded by `core/ldap/flatGeneric`, `core/ldap/groups` and `core/ldap/organizations`, so load it alongside them. The organizations plugin only exposes its schema when one is given: ```bash --organization-schema ./static/schemas/twake/organizations.json ``` | Option | Default | Purpose | | ------------------------------------ | ------------------- | ------------------------------------------------------------------- | | `--enterprise-domain-name-attribute` | `associatedDomain` | Fallback when no loaded schema declares a `domainName` role | | `--enterprise-domain-link-attribute` | _(from the schema)_ | Fallback when the organization schema declares no `domainLink` role | | `--group-dummy-user` | `cn=fakeuser` | Placeholder member that does not count towards `deleteGuard` | ## Order of evaluation Checks run **before** transformations, inside the same hook. A check therefore always sees the payload as the client sent it: uniqueness and domain rules validate the address that was submitted, never one the server has already rewritten, and an identifier derived from that address cannot influence the check that justified it. ## Rules ### Uniqueness ```json { "employeeNumber": { "type": "string", "unique": true }, "mail": { "type": "string", "role": "primaryEmail", "unique": { "attributes": ["mailAlternateAddress"], "branches": ["dc=example,dc=com"] } } } ``` - `attributes` names the other attributes sharing the value namespace — a mail address must not already be someone's alias. - `branches` widens the search beyond the entity's own branch, which is what makes an address unique across accounts _and_ distribution lists. - `sentinel` exempts one value, for directories that use a placeholder on non-individual entries. - `filter` narrows the search, e.g. `(objectClass=twakeAccount)`. On an update, a value the entry already holds, as stored and spelled the same, is not looked for again; only the values it does not hold yet are. Re-sending an unchanged value conflicts neither with the entry itself nor with the copies other plugins keep of it — the applicative accounts of `appAccountsConsistency` carry the account's mail. A value held under another attribute the constraint spans, an alias becoming the mail, is skipped only when that attribute's own constraint looks for it everywhere this one does; otherwise it is checked. A duplicate the directory already holds, from before the rule or another tool, is therefore not reported on a re-save. A value spelled differently is a new one, and looked for: changing only the case of an account's mail is refused while an applicative copy holds the old spelling, which the directory takes for the same address. ### Mail domains ```json { "mail": { "mailDomainScope": "organization", "allowSubdomains": true } } ``` `organization` walks up from the entry's `organizationLink`, collecting the domains each ancestor declares through its `domainLink` role, up to the root of the tree. `directory` accepts any domain declared anywhere. When nobody declares a domain, no restriction applies; a domain entry whose name is `*` authorises every domain. The host is taken after the **last** `@`, so a domain that merely ends with an authorised one (`evil-example.org` against `example.org`) is refused. `allowSubdomains` is what lets a distribution list live at `all@lists.example.org` under an organization that owns `example.org`. ### Computed organization path An entity declaring both an `organizationLink` and an `organizationPath` role gets its path filled from the organization it points at, on creation and whenever the link changes. Mark the path `generated` so a client cannot set it. ### Normalised sizes ```json { "mailQuotaSize": { "type": "number", "normalize": "byteSize", "default": 1000000000, "hint": "Expected pattern xGB, xMB, xKB or a number of bytes" } } ``` `5GB`, `500 MB`, `2KB` and `2048` all become a number of bytes, with decimal multipliers (1 GB = 10⁹ bytes) as mail servers count them. The same conversion applies to creations and to updates. A size above 2⁵³ − 1 bytes is refused (400), since its number of bytes would be rounded. A schema `number` or `integer` refuses a whole number beyond 2⁵³ on its own, whatever the type; see [what a value must look like](../../../client-development/schemas/README.md#what-a-value-must-look-like). ### Server-owned defaults On creation, an attribute that is `generated` or `normalize`d and carries a `default` is filled when the client said nothing — which is how a new account lands in its initial state: ```json { "twakeAccountStatus": { "type": "pointer", "role": "accountStatus", "generated": true, "default": "cn=active,ou=twakeAccountStatus,ou=nomenclature,dc=example,dc=com" } } ``` ### Scheduled removal An `accountExpiry` attribute must not carry a date already past. The comparison is against the start of the current day, so scheduling something for today is allowed. The date is an LDAP generalized time, read as written: the year keeps its four digits (`0029123123` is a date of year 29, not 1929), and a leap second (`20991231235960Z`) is the instant that follows the day. The zone is `Z` or a difference of hours and minutes from UTC (`+00`, `+02`, `-0530`, `+14:00`); a value past 23 hours or 59 minutes is answered 400 as invalid. ### Deletion and references ```json { "title": { "type": "pointer", "referentialIntegrity": "restrict" }, "twakeLocalAdminLink": { "type": "array", "items": { "type": "pointer", "branch": ["ou=users,dc=example,dc=com"] }, "referentialIntegrity": "remove" }, "member": { "type": "array", "role": "members", "deleteGuard": "nonEmpty" } } ``` `referentialIntegrity` is declared on the **referencing** attribute, and the pointer's `branch` tells the plugin which deletions concern it: - `restrict` refuses to delete an entry while another entry still points at it — a position somebody still holds, a nomenclature value still in use. - `remove` takes the deleted entry's DN out of the attribute once the deletion has landed: an account leaves the organizations it administered, the mailboxes delegated to it, the groups it owned. It also applies to an attribute carrying the `owners` role, which is not a pointer. A move into the trash (`--trash-base`, see [trash](trash.md)) counts as a deletion, [what a restore brings back](trash.md#limitations). This runs with the server's rights, whoever deleted the entry, since a link left behind would hand its rights to the next entry created with the same DN; the writes are attributed to the deletion's author. Use it on optional attributes only. Each deletion searches every attribute marked `remove`: give them an equality index. - `ignore`, the default, leaves the reference dangling. `deleteGuard: "nonEmpty"` refuses to delete a group that still has members. The placeholder member some directories keep to satisfy `groupOfNames` (`--group-dummy-user`) does not count. Both default to off: a deployment turns them on where it wants them. The shipped Twake schemas mark `remove` on the links to accounts: the managers and local administrators of an organization, the delegates of a mailbox, the owners of a group or a shared mailbox, the owner and manager of a device. Members leave their groups through the groups plugin. ## Errors | Status | When | | ------ | ----------------------------------------------------------------------------- | | `400` | A size that is not a size, a date already past | | `409` | A duplicate value, a mail domain outside the authorised set, a guarded delete | A duplicate value answers `code: "unique"` with the `attribute` and the `value` refused, and a mail domain outside the authorised set answers `code: "mailDomain"` with them and the authorised `domains`, each listed once: a client explains them in its own words, under the field at fault. The holder of a duplicate value is never named. See [the error format](../../../client-development/api/rest-api.md#error-response-format). ## See also - [flat-generic](flat-generic.md) — the schema format these markers extend - [account-lifecycle](account-lifecycle.md) — status changes and password resets - [organizations](organizations.md) — the tree the domain rule walks up linagora-ldap-rest-a4bb78e/docs/usage/plugins/ldap/external-users.md000066400000000000000000000251271527331160600256500ustar00rootroot00000000000000# LDAP External Users in Groups Plugin The `externalUsersInGroups` plugin automatically creates external user entries on-the-fly when they are added to groups. This is particularly useful for mailing lists that include external contacts. ## Overview This plugin intercepts group member validation and automatically creates missing user entries in a dedicated external branch. This allows you to: - Add external email addresses to groups/mailing lists without pre-creating users - Keep external contacts separate from internal users - Prevent creation of external users with managed domains - Automatically manage external user lifecycle ## Configuration ### CLI Arguments ```bash --plugin core/ldap/externalUsersInGroups \ --external-members-branch "ou=contacts,dc=example,dc=com" \ --external-branch-class top,inetOrgPerson \ --mail-domain example.com,company.org ``` ### Configuration Options | Argument | Environment Variable | Default | Description | | ------------------------------------------------------- | ---------------------------- | ------------------------------- | -------------------------------------------------------- | | `--external-members-branch` | `DM_EXTERNAL_MEMBERS_BRANCH` | `ou=contacts,dc=example,dc=com` | Branch for external user entries | | `--external-branch-class` / `--external-branch-classes` | `DM_EXTERNAL_BRANCH_CLASSES` | `["top", "inetOrgPerson"]` | Object classes for external users | | `--mail-domain` / `--mail-domains` | `DM_MAIL_DOMAIN` | `[]` | Managed mail domains (cannot be used for external users) | ### Important Notes - **Dependencies**: This plugin requires the `core/ldap/groups` plugin - **DN Format**: External members are in the format `mail={email},{external_members_branch}`, the address escaped as any DN value (RFC 4514): `mail=a\+b@partner.com,…` creates an entry whose `mail` is `a+b@partner.com`. Only the direct children of the branch are external members. - **Domain Protection**: External users cannot be created if their mail is in a managed domain, compared case-insensitively. The managed domains are those of `--mail-domain` and those the directory declares in its domain branches: the entries of a flat schema declaring the `domainName` role, or else `ou=domains,ou=nomenclature,` with `--enterprise-domain-name-attribute` (`associatedDomain`). A subdomain of a managed domain is not managed: `user@sub.example.com` is external unless `sub.example.com` is declared itself - **Automatic Creation**: Users are created automatically when added to a group if they don't exist ### Config API `GET /api/v1/config` tells a client whether external members are accepted, and where to send them: ```json "externalUsersInGroups": { "enabled": true, "branch": "ou=contacts,dc=example,dc=com", "rdnAttribute": "mail", "managedDomains": ["example.com"] } ``` A client turns an address into a member with `rdnAttribute=,`, and can refuse a `managedDomains` address before sending it. The list holds the domains declared in the directory as well as those of `--mail-domain`. ## How It Works ### Member Validation Hook The plugin hooks into the `ldapgroupvalidatemembers` event: 1. **Check DN Format**: Validates member DN matches `mail={email},{external_members_branch}` 2. **Search Existing**: Checks if the user already exists in LDAP 3. **Domain Validation**: Ensures email domain is not in managed domains list 4. **Auto-Create**: If user doesn't exist, creates it with minimal attributes 5. **Custom Hooks**: Allows modification via `externaluserentry` and `externaluseradded` hooks ### Created Entry Structure When an external user is created, the following attributes are set: ```javascript { objectClass: ["top", "inetOrgPerson"], // From config mail: "external@example.org", cn: "external@example.org", // Using ldap_groups_main_attribute sn: "external@example.org" } ``` ## Usage Examples ### Example 1: Add External User to Mailing List ```bash curl -X POST http://localhost:8081/api/v1/ldap/groups \ -H "Content-Type: application/json" \ -d '{ "cn": "newsletter", "description": "Company Newsletter", "member": [ "uid=john.doe,ou=users,dc=example,dc=com", "mail=external@partner.com,ou=contacts,dc=example,dc=com" ] }' ``` **What happens:** 1. Group "newsletter" is created 2. Internal user `john.doe` is validated (must exist) 3. External user `external@partner.com` is checked: - Not found in LDAP - Domain `partner.com` is not in managed domains - User entry is automatically created in `ou=contacts,dc=example,dc=com` 4. Both members are added to the group ### Example 2: Add Multiple External Members ```bash curl -X POST http://localhost:8081/api/v1/ldap/groups/newsletter/members \ -H "Content-Type: application/json" \ -d '{ "member": [ "mail=contact1@external.org,ou=contacts,dc=example,dc=com", "mail=contact2@external.org,ou=contacts,dc=example,dc=com" ] }' ``` Both external users will be created automatically if they don't exist. ### Example 3: Domain Protection ```bash # Assuming --mail-domain example.com,company.org curl -X POST http://localhost:8081/api/v1/ldap/groups/newsletter/members \ -H "Content-Type: application/json" \ -d '{ "member": "mail=someone@example.com,ou=contacts,dc=example,dc=com" }' ``` **Result:** Error - `Cannot create external user with managed domain: someone@example.com` This prevents accidentally creating external entries for internal users. ## Hooks The plugin emits custom hooks for integration: ### externaluserentry Called before creating an external user entry. Allows modification of DN and attributes. ```javascript hooks: { externaluserentry: async ([dn, entry]) => { // Add custom attributes entry.displayName = entry.mail; entry.description = 'External contact'; // Modify DN if needed const newDn = dn; // or modify return [newDn, entry]; }; } ``` ### externaluseradded Called after an external user is successfully created. ```javascript hooks: { externaluseradded: async (dn, entry) => { console.log('External user created:', dn); console.log('Attributes:', entry); // Send notification, update external system, etc. }; } ``` ## Integration with Groups Plugin The plugin works seamlessly with the groups plugin: ### Normal Group Operations ```bash # Create group with mix of internal and external users POST /api/v1/ldap/groups { "cn": "project-team", "member": [ "uid=internal1,ou=users,dc=example,dc=com", "mail=external@partner.com,ou=contacts,dc=example,dc=com" ] } # Add external member to existing group POST /api/v1/ldap/groups/project-team/members { "member": "mail=another@partner.com,ou=contacts,dc=example,dc=com" } ``` ### Member Cleanup When an external user is deleted, the groups plugin automatically removes them from all groups (via the `ldapdeleterequest` hook). ## Advanced Configuration ### Custom Object Classes Use different object classes for external users: ```bash --external-branch-class top,person,organizationalPerson,inetOrgPerson ``` ### Multiple Managed Domains Protect multiple domains from external user creation: ```bash --mail-domain example.com,company.org,subsidiary.net ``` The domains declared in the directory are protected without being listed here. ### Custom Attributes via Hooks Add organization-specific attributes to external users: ```javascript // In your plugin configuration hooks: { externaluserentry: async ([dn, entry]) => { entry.o = 'External Partners'; entry.userType = 'external'; entry.employeeType = 'contact'; return [dn, entry]; }; } ``` ## Troubleshooting ### External User Not Created **Problem:** Member addition fails but external user is not created **Solutions:** 1. Verify DN format: `mail={email},{external_members_branch}` 2. Check external branch exists in LDAP 3. Ensure plugin is registered after groups plugin 4. Check LDAP permissions for creating entries in external branch ### Managed Domain Error **Problem:** `Cannot create external user with managed domain: user@example.com` **Solutions:** 1. Use the correct branch for internal users: `ou=users,dc=example,dc=com` 2. Remove the domain from `--mail-domain`, or from the directory's domain branches, if it should allow external users 3. This is expected behavior for internal domains - create user in users branch instead ### Malformed Member DN **Problem:** `Malformed member mail=user,ou=contacts,dc=example,dc=com` **Solution:** Use correct format with email address: ``` mail=user@domain.com,ou=contacts,dc=example,dc=com ``` ### Missing Attributes **Problem:** External users are created but lack required attributes **Solution:** Use `externaluserentry` hook to add custom attributes: ```javascript hooks: { externaluserentry: async ([dn, entry]) => { // Extract name from email const [localPart] = entry.mail.split('@'); entry.givenName = localPart; entry.displayName = localPart; return [dn, entry]; }; } ``` ## Use Cases ### Mailing Lists Create mailing lists that mix internal employees and external partners: ```bash curl -X POST http://localhost:8081/api/v1/ldap/groups \ -d '{ "cn": "partners-list", "description": "Partner Communications", "member": [ "uid=employee1,ou=users,dc=example,dc=com", "mail=partner1@acme.com,ou=contacts,dc=example,dc=com", "mail=partner2@vendor.org,ou=contacts,dc=example,dc=com" ] }' ``` ### Project Collaboration Add external consultants to project groups: ```bash curl -X POST http://localhost:8081/api/v1/ldap/groups/project-alpha/members \ -d '{ "member": [ "mail=consultant@consulting.com,ou=contacts,dc=example,dc=com" ] }' ``` ### Newsletter Management Manage newsletter subscriptions with automatic contact creation: ```bash # Add new subscriber (auto-creates contact) curl -X POST http://localhost:8081/api/v1/ldap/groups/newsletter/members \ -d '{"member": "mail=subscriber@gmail.com,ou=contacts,dc=example,dc=com"}' # Unsubscribe (removes from group) curl -X DELETE \ "http://localhost:8081/api/v1/ldap/groups/newsletter/members/mail%3Dsubscriber%40gmail.com%2Cou%3Dcontacts%2Cdc%3Dexample%2Cdc%3Dcom" ``` ## See Also - [LDAP Groups Plugin](./ldapGroups.md) - Core groups management (required dependency) - [LDAP Flat Plugin](./ldapFlat.md) - For managing internal users - [Schema Examples](../static/schemas/) - Schema examples for groups and users linagora-ldap-rest-a4bb78e/docs/usage/plugins/ldap/flat-generic.md000066400000000000000000000523721527331160600252310ustar00rootroot00000000000000# LDAP Flat Generic Plugin Generic plugin to manage flat LDAP entities using JSON schema files. Automatically creates API endpoints based on schema metadata. ## Overview The `ldapFlatGeneric` plugin provides a schema-driven approach to managing LDAP flat entities (users, positions, or any custom entity type). Instead of writing custom plugins, you define entity schemas in JSON files, and the plugin automatically generates: - REST API endpoints (GET, POST, PUT, DELETE) - Schema validation - LDAP CRUD operations - Search functionality - Hooks for customization ## Configuration ```bash --plugin core/ldap/flatGeneric \ --ldap-flat-schema ./static/schemas/twake/users.json \ --ldap-flat-schema ./static/schemas/twake/positions.json ``` **Environment Variable:** ```bash DM_LDAP_FLAT_SCHEMA="./static/schemas/twake/users.json,./static/schemas/twake/positions.json" ``` ## Schema Format Each schema file must include an `entity` metadata section and attribute definitions. ### Basic Schema Structure ```json { "entity": { "name": "twakeUser", "mainAttribute": "uid", "objectClass": ["top", "twakeAccount", "twakeWhitePages"], "singularName": "user", "pluralName": "users", "base": "ou=users,{ldap_base}", "defaultAttributes": {} }, "strict": true, "attributes": { "objectClass": { "type": "array", "items": { "type": "string" }, "default": ["top", "twakeAccount"], "required": true, "fixed": true }, "uid": { "type": "string", "test": "^[a-zA-Z0-9._-]{1,255}$", "required": true }, "cn": { "type": "string", "required": true }, "mail": { "type": "string", "test": "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", "required": false } } } ``` ### Entity Metadata - **name**: Internal name used for hooks (e.g., `ldap{name}add`) - **mainAttribute**: DN attribute (e.g., `uid`, `cn`) - **objectClass**: the classes of every entry created, whatever `objectClass` the request carried; the schema's `objectClass` attribute only describes them - **singularName**: Used in API paths and messages (e.g., "user") - **pluralName**: Used in API paths (e.g., "users") - **base**: LDAP base DN, supports config placeholders like `{ldap_base}` - **defaultAttributes**: Default values for optional attributes ### Attribute Types #### String ```json { "cn": { "type": "string", "test": "^[a-zA-Z ]+$", "required": true } } ``` #### Array ```json { "objectClass": { "type": "array", "items": { "type": "string" }, "default": ["top", "person"], "required": true } } ``` #### Pointer (DN Reference) ```json { "manager": { "type": "pointer", "branch": ["ou=users,dc=example,dc=com"], "required": false } } ``` Pointer validation ensures the DN exists and is within allowed branches. The DN is looked for as the server, whoever writes: a caller may point at an entry it cannot read, and learns from the answer whether one is there, never what it holds. #### Fixed Attributes ```json { "objectClass": { "type": "array", "fixed": true, "default": ["top", "person"] } } ``` Fixed attributes: - Auto-provisioned with default value on creation; `objectClass` takes the classes of the entity metadata instead - Cannot be modified after creation - Cannot be deleted ### Naming things, in the reader's language `label` is what a client shows instead of the attribute name, `group` the form section it shows the attribute in, and `entity.label` / `entity.singularLabel` are what it calls the collection and one of its entries. Each, and `hint` below, may be a plain string, or a map keyed by language tag: ```json { "entity": { "name": "twakeUser", "singularName": "user", "pluralName": "users", "label": { "en": "Users", "fr": "Utilisateurs" }, "singularLabel": { "en": "user", "fr": "utilisateur" } }, "attributes": { "twakeDepartmentLink": { "type": "pointer", "role": "organizationLink", "label": { "en": "Department", "fr": "Organisation" } }, "mailQuotaSize": { "type": "string", "group": { "en": "Mailbox Settings", "fr": "Paramètres de messagerie" }, "label": { "en": "Mailbox size", "fr": "Taille de boîte" } } } } ``` These words name _your_ entities and attributes, so their translations belong in your schemas rather than in a catalogue shipped with the product — which is also why an interface can be fully translated without the product learning what you call a department. A client picks the exact tag, then its language, then English, then whatever the map does hold; with no label at all it falls back to the attribute name made readable. A nomenclature names its values too. `entity.valueLabels` maps the main attribute value of an entry to what a client shows for it, so a group whose type is `cn=teamMailbox,…` reads _Shared mailbox_ — or _Boîte partagée_ — wherever a pointer lands on it: ```json { "entity": { "name": "twakeMailboxType", "mainAttribute": "cn", "valueLabels": { "group": { "en": "Group", "fr": "Groupe" }, "teamMailbox": { "en": "Shared mailbox", "fr": "Boîte partagée" } } } } ``` A value the map does not hold is shown as it is stored: a directory with a value of its own keeps working, and names it by adding it to its schema. ### Explaining a pattern: `hint` A `test` tells the server what to refuse. A `hint` tells the person what to type, before they get it wrong: ```json { "telephoneNumber": { "type": "string", "test": "^\\(\\+99\\)\\s\\d{3}\\s\\d{4}$", "hint": { "en": "Expected pattern (+99) 999 9999", "fr": "Format attendu : (+99) 999 9999" } } } ``` The hint travels with the schema, so a client shows it under the field and the server repeats it when it rejects a value — the pattern and its explanation cannot drift apart, and neither is hardcoded in the interface. A translated hint is picked like a label; the server's messages are in English, so it quotes the English text. ### Explaining an attribute: `help` A label names an attribute; it does not always say what it is. `help` does, for a client to show on demand — a tooltip next to the label — rather than under every field: ```json { "employeeNumber": { "type": "string", "label": { "en": "Employee ID", "fr": "Matricule" }, "help": { "en": "The person's registration number, as the personnel records hold it.", "fr": "Le matricule de la personne, tel que le tient la gestion du personnel." } } } ``` It is picked like a label. The server never shows it. ### Attributes a client may not send | Marker | Meaning | | ----------- | ------------------------------------------------------------------------------- | | `generated` | The server computes the value. A request that carries it is refused with a 400. | | `readOnly` | The value is derived elsewhere — `memberOf` is driven from the group side. | Both restrict the **request body** only: the core and its plugins still write these attributes. The read of one entry (`GET /api/v1/ldap/{resource}/{id}`) asks the directory for every attribute the schema declares besides the usual ones, so an operational attribute such as `memberOf` is returned when the directory computes it — with OpenLDAP, when the `memberof` overlay is configured for the group class and member attribute in use (`groupOfNames` and `member` for the Twake schemas). Lists return the usual attributes only. ### Attributes a client never receives `neverReturn` strips an attribute from every API answer while leaving it writable. It is what makes "a manager may reset a password without being able to read it" a projection rather than an authorization engine: ```json { "userPassword": { "type": "string", "role": "password", "neverReturn": true }, "pwdReset": { "type": "boolean", "role": "passwordReset", "neverReturn": true } } ``` ### Deriving the identifier When the RDN is not something a person chooses, `generatedFrom` says where it comes from. The client then posts without it: ```json { "uid": { "type": "string", "role": "identifier", "generated": true, "generatedFrom": { "attribute": "mail", "extract": "^([^@]+)@", "lowercase": true, "onCollision": "suffix" } } } ``` `onCollision` decides what happens when the value is taken — two directories' worth of `jean.dupont@a.example` and `jean.dupont@b.example` map to the same local part. `suffix` appends `-2`, `-3`, …; `error` (the default) refuses the creation with a 409. ### Semantic roles `role` says what an attribute _means_, so core code and plugins can find it without knowing its name. `roleAttribute(schema, 'accountStatus')` returns `twakeAccountStatus` in a Twake directory and whatever another deployment chose in its own. The roles the core looks for: | Role | Meaning | | -------------------------------------- | --------------------------------------------- | | `identifier`, `displayName` | RDN value, human-readable name | | `primaryEmail`, `emailAliases` | Mail addresses of the entry | | `emailQuota` | Mailbox size limit, in bytes | | `organizationLink`, `organizationPath` | Organization the entry belongs to | | `members`, `owners` | Members of a group, and who may write to it | | `accountStatus`, `accountExpiry` | Lifecycle state and scheduled removal | | `password`, `passwordReset` | Credential and "change it at next login" flag | | `domainLink`, `domainName` | Mail domains an organization may use | An attribute may carry several roles (`"role": ["identifier", "displayName"]`), and a deployment is free to add its own for its plugins. Clients look for more: `givenName` and `surname` name the parts of a person's name, which a form composes the `displayName` from. The shipped user schemas mark `givenName` and `sn` with them. ### Which attributes a search covers: `searchable` `GET /{pluralName}?match=…&attribute=…` takes one attribute name or several separated by commas, and an entry matching any of them is returned. Which ones a client should offer is a property of the deployment rather than of the code: a substring filter on an attribute the directory has not indexed scans the branch. `"searchable": true` says so. A schema that marks even one attribute has declared its list, and that list is what a client searches; a schema that marks none leaves the client to guess — everything returnable, single-valued and not a DN, which suits a small branch and scans a large one. The shipped schemas mark the attributes an operator looks people up by: ```json "sn": { "type": "string", "searchable": true, "label": { … } } ``` Align it with your indexes. In OpenLDAP that means `index sn,cn,mail sub` for the attributes you mark. The remaining markers — `unique`, `normalize`, `states`, `referentialIntegrity`, `deleteGuard`, `mailDomainScope` — are read by [enterprise-rules](enterprise-rules.md) and [account-lifecycle](account-lifecycle.md). ## Generated APIs For each schema, the plugin generates REST API endpoints: ### List Entries ```bash GET /api/v1/ldap/{pluralName}?match={value}&attribute={attrs}&attributes={attrs}&limit={n} ``` **Example:** ```bash curl 'http://localhost:8081/api/v1/ldap/users?match=smith&attribute=uid&attributes=uid,cn,mail&limit=50' ``` **Response:** ```json { "jsmith": { "dn": "uid=jsmith,ou=users,dc=example,dc=com", "uid": "jsmith", "cn": "John Smith", "mail": "jsmith@example.com" } } ``` #### Long lists A directory lists at most a set number of entries per search for the account LDAP-Rest binds as (OpenLDAP: `olcSizeLimit`, default 500, or `olcLimits`; `size.prtotal` bounds paged searches). OpenLDAP refuses a longer list as a whole rather than cutting it short: - **Without `limit`**, the answer is every match or `422`: ```json { "error": "Too many entries to list at once: the directory's size limit was exceeded. Narrow the search, or pass `limit`." } ``` - **With `limit`** (a positive integer, `400` otherwise), at most `limit` entries come back, with the header `X-Result-Truncated: true` when others were left out. When the directory refuses the list, LDAP-Rest asks again for `limit + 1` entries and answers what it gets, at most `limit` — possibly fewer, if the account's own limit is lower — always with the header: the directory does not say whether that answer was cut, so a shorter one is not taken for complete. A request with `limit` is never answered `422`. The header is absent from a complete answer. LDAP-Rest sets no CORS headers: a browser client on another origin sees `X-Result-Truncated` only if the proxy answering for it lists the header in `Access-Control-Expose-Headers`. A small `limit` costs a page of the directory's answer. A `limit` larger than the directory's own limit costs a walk up to that limit, then the second search: memory stays bounded by the directory, not by `limit`. Only the lists of the entities this plugin serves honour `limit`, `X-Result-Truncated` and the `422` for now. The groups list (`GET /api/v1/ldap/groups`) ignores `limit` and still fails with `500`. ### Get Entry ```bash GET /api/v1/ldap/{pluralName}/{id} ``` **Example:** ```bash curl http://localhost:8081/api/v1/ldap/users/jsmith ``` ### Create Entry ```bash POST /api/v1/ldap/{pluralName} Content-Type: application/json { "uid": "newuser", "cn": "New User", "sn": "User", "mail": "newuser@example.com" } ``` The identifier may also be the whole DN of the entry, in the entity's branch: `"uid": "uid=Smith\\,John,ou=users,dc=example,dc=com"` creates the entry `Smith,John`, whose DN spells the name with its escapes. The DN must use the escapes RFC 4514 defines; an escape it does not define (`\q`) is refused with a `400`. Send the bare value to have the server escape it. The name written is the value the RDN gives under the folding the directory applies (Unicode NFKC, and its insignificant spaces): `uid=\ zoë` and `uid=zoë` name one entry, written `uid=zoë`, and a name sent decomposed is stored composed. ### Modify Entry ```bash PUT /api/v1/ldap/{pluralName}/{id} Content-Type: application/json { "replace": { "cn": "Updated Name" }, "add": { "telephoneNumber": "+1234567890" }, "delete": ["description"] } ``` #### Entries created by other tools An entry written by another tool may lack an object class the schema declares, and the directory then refuses an attribute that class carries: a bare `inetOrgPerson` in the users branch cannot take `twakeDepartmentLink`, which `twakeWhitePages` brings. When a modify through LDAP-Rest (this route, `/move`, a state change…) writes such an attribute and the directory refuses it, the write is sent again with the **auxiliary** class declared for the branch that allows it, provided the entry holds, or receives, that class's mandatory attributes. Only what the write needs is added, and a write the directory would accept is never changed: several entities may share a branch (`users`, `posixAccounts` and `sshPublicKeys` all live in `ou=users`) without every entry being given all their classes. A missing **structural** class, such as `twakeAccount`, cannot be added to an existing entry: the write answers `409`, and the reason is logged once per entry. The same applies to groups under `--ldap-group-base` (when it is set) with `--group-class`, and to the organization tree with `--ldap-organization-class`, and to the accounts the Twake instance and Cloudery provisioning plugins write, with `--user-class`. Nothing is added when the change names `objectClass` itself. The repair reads the directory schema, kept for `--ldap-raw-schema-cache-ttl`, and the entry's classes only for a write the directory refuses: a write it takes costs nothing more. That refused write shows in the directory's logs (`err=65`) before the one adding the class. `DM_LDAP_FLAT_AUTO_REPAIR=false` turns the repair off. ### Delete Entry ```bash DELETE /api/v1/ldap/{pluralName}/{id} ``` ## Schema Examples ### Example 1: Simple Users ```json { "entity": { "name": "standardUser", "mainAttribute": "uid", "objectClass": ["top", "inetOrgPerson"], "singularName": "user", "pluralName": "users", "base": "ou=users,{ldap_base}" }, "strict": true, "attributes": { "objectClass": { "type": "array", "items": { "type": "string" }, "default": ["top", "inetOrgPerson"], "required": true, "fixed": true }, "uid": { "type": "string", "test": "^[a-z][a-z0-9_-]{2,32}$", "required": true }, "cn": { "type": "string", "required": true }, "sn": { "type": "string", "required": true }, "mail": { "type": "string", "test": "^[^@]+@[^@]+\\.[^@]+$", "required": false } } } ``` ### Example 2: Positions/Roles ```json { "entity": { "name": "twakePosition", "mainAttribute": "cn", "objectClass": ["top", "twakePosition"], "singularName": "position", "pluralName": "positions", "base": "ou=positions,{ldap_base}" }, "strict": true, "attributes": { "objectClass": { "type": "array", "default": ["top", "twakePosition"], "required": true, "fixed": true }, "cn": { "type": "string", "test": "^[a-zA-Z0-9 &/,.-]+$", "required": true }, "description": { "type": "string", "required": false } } } ``` ### Example 3: With Pointers ```json { "entity": { "name": "employee", "mainAttribute": "uid", "objectClass": ["top", "inetOrgPerson", "employee"], "singularName": "employee", "pluralName": "employees", "base": "ou=employees,{ldap_base}" }, "attributes": { "uid": { "type": "string", "required": true }, "cn": { "type": "string", "required": true }, "sn": { "type": "string", "required": true }, "manager": { "type": "pointer", "branch": ["ou=employees,{ldap_base}"], "required": false }, "departmentNumber": { "type": "pointer", "branch": ["ou=departments,{ldap_base}"], "required": true } } } ``` ## Hooks The plugin generates hooks for each entity type: - `ldap{Name}add` - Before adding entry - `ldap{Name}adddone` - After adding entry - `ldap{Name}modify` - Before modifying entry - `ldap{Name}modifydone` - After modifying entry - `ldap{Name}delete` - Before deleting entry - `ldap{Name}deletedone` - After deleting entry **Example:** For an entity named `twakeUser`, hooks are: - `ldaptwakeUseradd` - `ldaptwakeUseradddone` - etc. ## Validation ### Schema Validation All operations are validated against the schema: - **Required attributes**: Must be present - **Type checking**: String, array, etc. - **Pattern matching**: Regex validation via `test` property - **Pointer validation**: DN must exist and be in allowed branch - **Fixed attributes**: Cannot be modified or deleted ### Strict Mode When `"strict": true`, only attributes defined in the schema are allowed. Additional attributes are rejected. When `"strict": false`, additional attributes are permitted. ## Configuration Placeholders Schema bases can use configuration placeholders: ```json { "entity": { "base": "ou=users,{ldap_base}" } } ``` Placeholders are replaced with config values: - `{ldap_base}` → `--ldap-base` value - `{ldap_top_organization}` → `--ldap-top-organization` value - Any config key in lowercase with underscores ## Multiple Schemas Load multiple schemas to manage different entity types: ```bash --ldap-flat-schema ./schemas/users.json \ --ldap-flat-schema ./schemas/positions.json \ --ldap-flat-schema ./schemas/contractors.json ``` Each schema creates separate API endpoints under its `pluralName`. ## Integration with Other Plugins ### With onChange ```bash --plugin core/ldap/onChange \ --plugin core/ldap/flatGeneric \ --ldap-flat-schema ./schemas/users.json \ --mail-attribute mail ``` Generates `onLdapMailChange` hooks when mail attribute changes. ### With James (Twake Mail) ```bash --plugin core/ldap/onChange \ --plugin core/ldap/flatGeneric \ --plugin twake/james \ --ldap-flat-schema ./schemas/twake/users.json \ --mail-attribute mail \ --james-webadmin-url http://localhost:8000 ``` Automatically syncs mail changes to James mail server. ## Comparison with ldapFlat (Deprecated) | Feature | ldapFlat | ldapFlatGeneric | | ------------------ | ----------------------- | ---------------------------- | | **Configuration** | Hardcoded plugin | JSON schema files | | **Flexibility** | Limited to users/groups | Any entity type | | **Validation** | Basic | Full schema validation | | **Maintenance** | Manual code changes | Update JSON files | | **Multiple Types** | Separate plugins | One plugin, multiple schemas | **Migration:** Replace `ldapFlat` plugins with `ldapFlatGeneric` and schema files. ## See Also - [ldapFlat.md](ldapFlat.md) - Legacy flat LDAP plugin - [ldapGroups.md](ldapGroups.md) - Group management - [ldapOrganizations.md](ldapOrganizations.md) - Organization tree linagora-ldap-rest-a4bb78e/docs/usage/plugins/ldap/groups.md000066400000000000000000000354071527331160600242100ustar00rootroot00000000000000# LDAP Groups Plugin The `ldapGroups` plugin provides comprehensive management of LDAP groups and their members, with automatic member validation and cleanup when users are deleted. ## Overview This plugin manages groups stored as `groupOfNames` or similar object classes in a flat LDAP branch. It provides: - Group CRUD operations (Create, Read, Update, Delete) - Member management (add/remove members) - Automatic member validation (ensures members exist) - Automatic cleanup (removes deleted users from all groups) - Schema validation support - Hook integration for lifecycle events ## Configuration ### CLI Arguments ```bash --plugin core/ldap/groups \ --ldap-group-base "ou=groups,dc=example,dc=com" \ --ldap-groups-main-attribute cn \ --group-class top,groupOfNames \ --group-schema ./static/schemas/twake/groups.json \ --group-dummy-user "cn=fakeuser" \ --groups-allow-unexistent-members false ``` ### Configuration Options | Argument | Environment Variable | Default | Description | | ----------------------------------- | ------------------------------- | ------------------------------------ | -------------------------------- | | `--ldap-group-base` | `DM_LDAP_GROUP_BASE` | `{ldap_base}` | Base DN for groups branch | | `--ldap-groups-main-attribute` | `DM_LDAP_GROUPS_MAIN_ATTRIBUTE` | `cn` | Main attribute (RDN) for groups | | `--group-class` / `--group-classes` | `DM_GROUP_CLASSES` | `["top", "groupOfNames"]` | Object classes for groups | | `--group-schema` | `DM_GROUP_SCHEMA` | `./static/schemas/twake/groups.json` | Path to JSON schema | | `--group-default-attributes` | `DM_GROUP_DEFAULT_ATTRIBUTES` | `{}` | Default attributes (JSON) | | `--group-dummy-user` | `DM_GROUP_DUMMY_USER` | `cn=fakeuser` | Dummy member DN for empty groups | | `--groups-allow-unexistent-members` | `DM_ALLOW_UNEXISTENT_MEMBERS` | `false` | Allow non-existent members | ### Important Notes - **Empty Groups**: `groupOfNames` requires at least one member. The plugin uses a dummy member (configurable via `--group-dummy-user`) for empty groups. That dummy member is hidden from `member` on both `GET /ldap/groups` and `GET /ldap/groups/{cn}`, so a client never sees it as a real one. - **Member Validation**: By default, the plugin validates that all members exist in LDAP before adding them. Set `--groups-allow-unexistent-members true` to disable validation. - **Automatic Cleanup**: When a user is deleted, the plugin automatically removes them from all groups via the `ldapdeleterequest` hook. ## REST API ### List Groups ```http GET /api/v1/ldap/groups ``` **Query Parameters:** - `match` (optional): Value to look for - `attribute` (optional): Attribute(s) `match` is looked for in, as a substring, several separated by commas — the same semantics as the flat entity lists. Without it, `match` is either a raw LDAP filter (when it contains `=`) or an exact group name. A raw filter may only hold letters, digits, `_ * = ( ) & | , space - . @ '`, which covers a DN such as `match=(member=uid=john.doe,ou=users,dc=example,dc=com)` to list the groups of an account. Anything else is refused with 400, notably `\` (it escapes in a filter, so a DN needing escapes cannot be expressed), `+`, `/` and `!`. It is not combined with `attribute`, which builds a substring filter that `member` cannot answer. - `attributes` (optional): Comma-separated list of attributes to return **Example:** ```bash curl "http://localhost:8081/api/v1/ldap/groups?match=admin&attribute=cn,mail&attributes=cn,member,description" ``` **Response (200):** ```json { "admins": { "dn": "cn=admins,ou=groups,dc=example,dc=com", "cn": "admins", "member": [ "uid=john.doe,ou=users,dc=example,dc=com", "uid=jane.smith,ou=users,dc=example,dc=com" ], "description": "System administrators" }, "admin-backup": { "dn": "cn=admin-backup,ou=groups,dc=example,dc=com", "cn": "admin-backup", "member": ["uid=backup.admin,ou=users,dc=example,dc=com"] } } ``` ### Get Group by cn or DN ```http GET /api/v1/ldap/groups/{cn} ``` **Path Parameter:** - `cn`: Group cn value (e.g., `developers`) OR full DN (URL-encoded if DN) **Examples:** ```bash # Get by cn curl "http://localhost:8081/api/v1/ldap/groups/developers" # Get by full DN (URL-encoded) curl "http://localhost:8081/api/v1/ldap/groups/cn%3Ddevelopers%2Cou%3Dgroups%2Cdc%3Dexample%2Cdc%3Dcom" ``` **Response (200):** ```json { "dn": "cn=developers,ou=groups,dc=example,dc=com", "cn": "developers", "description": "Development team", "member": [ "uid=john.doe,ou=users,dc=example,dc=com", "uid=jane.smith,ou=users,dc=example,dc=com" ] } ``` **Response (404):** ```json { "error": "Group not found" } ``` ### Create Group ```http POST /api/v1/ldap/groups ``` **Request Body:** ```json { "cn": "developers", "description": "Development team", "member": [ "uid=john.doe,ou=users,dc=example,dc=com", "uid=jane.smith,ou=users,dc=example,dc=com" ] } ``` **Notes:** - `cn` is required; it is a name, or the group's DN in the groups branch, whose first RDN gives the name, folded as the directory folds it. The DN form follows the same [escape rule as a flat entity's identifier](flat-generic.md#create-entry) - `member` can be a string (single member) or array (multiple members) - If no members provided, a dummy member is automatically added, and it is left out of `member` when the group is read back, on both the list and the single-group endpoint - Additional attributes can be included based on schema **Response (200):** ```json { "success": true } ``` ### Modify Group ```http PUT /api/v1/ldap/groups/{cn} ``` **Path Parameter:** - `cn`: Group common name **Request Body:** ```json { "replace": { "description": "Updated development team" }, "add": { "businessCategory": "IT" }, "delete": ["ou"] } ``` **Important:** - Cannot modify `cn` attribute directly (use rename endpoint instead) - Cannot modify `member` attribute here (use member management endpoints instead) - Supports `add`, `replace`, and `delete` operations **Response (200):** ```json { "success": true } ``` ### Rename Group ```http POST /api/v1/ldap/groups/{cn}/rename ``` **Path Parameter:** - `cn`: Current group common name **Request Body:** ```json { "newCn": "new-group-name" } ``` **Response (200):** ```json { "success": true } ``` **Description:** Renames a group by changing its `cn` attribute. This performs an LDAP modifyDN operation to change the RDN of the group entry. ### Move Group ```http POST /api/v1/ldap/groups/{cn}/move ``` **Path Parameter:** - `cn`: Group common name **Request Body:** ```json { "targetOrgDn": "ou=NewDepartment,ou=organization,dc=example,dc=com" } ``` **Response (200):** ```json { "success": true } ``` **Description:** Moves a group to a different organization by updating its `twakeDepartmentLink` and `twakeDepartmentPath` attributes. **Authorization:** When using the `authzPerBranch` plugin, moving a group requires: - **Read** permission on the source organization - **Write** permission on the destination organization **Requirements:** - Target organization must exist - Group cannot be moved to the same organization - Requires `twakeDepartmentLink` and `twakeDepartmentPath` attributes in config ### Delete Group ```http DELETE /api/v1/ldap/groups/{cn} ``` **Path Parameter:** - `cn`: Group common name **Response (200):** ```json { "success": true } ``` ### Add Members ```http POST /api/v1/ldap/groups/{cn}/members ``` **Path Parameter:** - `cn`: Group common name **Request Body:** ```json { "member": "uid=new.user,ou=users,dc=example,dc=com" } ``` Or multiple members: ```json { "member": [ "uid=user1,ou=users,dc=example,dc=com", "uid=user2,ou=users,dc=example,dc=com" ] } ``` **Validation:** - By default, members must exist in LDAP (configurable) - Duplicate members are ignored - Member DNs are validated **Response (200):** ```json { "success": true } ``` ### Remove Member ```http DELETE /api/v1/ldap/groups/{cn}/members/{member} ``` **Path Parameters:** - `cn`: Group common name - `member`: Member DN (URL-encoded) **Example:** ```bash curl -X DELETE "http://localhost:8081/api/v1/ldap/groups/developers/members/uid%3Djohn.doe%2Cou%3Dusers%2Co%3Dgov%2Cc%3Dmu" ``` **Notes:** - If removing the last real member, the dummy member (`--group-dummy-user`) takes its place in the same change, so the group stays valid and reads back with no member; with no dummy member configured, the removal is refused with a 409, as a `groupOfNames` needs at least one member - Removing the dummy member itself is refused with a 400, and a DN that is not a member of the group with a 404 - Member DN must be URL-encoded in the path **Response (200):** ```json { "success": true } ``` ## Schema Support The plugin supports JSON schema validation for group attributes. Example schema: ```json { "strict": true, "attributes": { "objectClass": { "type": "array", "default": ["top", "groupOfNames"], "required": true }, "cn": { "type": "string", "test": "^[a-zA-Z0-9._-]+$", "required": true }, "member": { "type": "array", "items": { "type": "string" }, "required": true }, "description": { "type": "string", "required": false } } } ``` A `pointer` attribute is checked as in [flat schemas](flat-generic.md#pointer-dn-reference): the DN must name an entry in the allowed branches, looked for as the server, whoever writes. A caller may point at an entry it cannot read, and learns from the answer whether one is there, never what it holds. ## Hooks The plugin emits and listens to lifecycle hooks: ### Emitted Hooks - `ldapgroupaddrequest` - Before creating a group (can modify dn, attributes, members) - `ldapgroupadddone` - After group created successfully - `ldapgroupmodify` - Before modifying group - `ldapgroupmodifydone` - After group modified - `ldapgroupdelete` - Before deleting group - `ldapgroupdeletedone` - After group deleted - `ldapgroupaddmember` - Before adding members - `ldapgroupaddmemberdone` - After members added - `ldapgroupdeletemember` - Before removing members - `ldapgroupdeletememberdone` - After members removed ### Listened Hooks - `ldapdeleterequest` - Automatically removes deleted users from all groups **Example Hook:** ```javascript hooks: { ldapgroupaddrequest: async ([dn, entry, members, op]) => { // Validate or modify before group creation console.log('Creating group:', dn); console.log('Initial members:', members); // Add automatic attributes entry.businessCategory = 'Auto-generated'; return [dn, entry, members, op]; }, ldapgroupadddone: async ([dn, op]) => { console.log('Group created successfully:', dn); } } ``` ## Member Validation The plugin includes sophisticated member validation: ### Default Behavior (Validation Enabled) ```javascript // This will succeed if uid=john.doe exists await addGroupMembers('developers', 'uid=john.doe,ou=users,dc=example,dc=com'); // This will fail if uid=nonexistent doesn't exist await addGroupMembers( 'developers', 'uid=nonexistent,ou=users,dc=example,dc=com' ); // Error: Member does not exist ``` ### Disable Validation ```bash --groups-allow-unexistent-members true ``` With validation disabled, any DN can be added as a member (useful for external references or cross-domain groups). ## Automatic Cleanup When a user is deleted from LDAP, the plugin automatically: 1. Detects the deletion via `ldapdeleterequest` hook 2. Searches all groups for the deleted user 3. Removes the user from all groups 4. Adds dummy member if group becomes empty This ensures referential integrity without manual intervention. ## Examples ### Example 1: Create Development Team Group ```bash curl -X POST http://localhost:8081/api/v1/ldap/groups \ -H "Content-Type: application/json" \ -d '{ "cn": "dev-team", "description": "Development Team", "businessCategory": "Engineering", "member": [ "uid=alice,ou=users,dc=example,dc=com", "uid=bob,ou=users,dc=example,dc=com" ] }' ``` ### Example 2: Add Member to Existing Group ```bash curl -X POST http://localhost:8081/api/v1/ldap/groups/dev-team/members \ -H "Content-Type: application/json" \ -d '{ "member": "uid=charlie,ou=users,dc=example,dc=com" }' ``` ### Example 3: Update Group Description ```bash curl -X PUT http://localhost:8081/api/v1/ldap/groups/dev-team \ -H "Content-Type: application/json" \ -d '{ "replace": { "description": "Senior Development Team" } }' ``` ### Example 4: Remove Member ```bash curl -X DELETE "http://localhost:8081/api/v1/ldap/groups/dev-team/members/uid%3Dalice%2Cou%3Dusers%2Co%3Dgov%2Cc%3Dmu" ``` ### Example 5: List All Groups ```bash curl "http://localhost:8081/api/v1/ldap/groups?attributes=cn,member,description" ``` ## Integration with Other Plugins ### With Users Plugin When a user is deleted: ```bash DELETE /api/v1/ldap/users/john.doe ``` The groups plugin automatically removes `john.doe` from all groups. ### With Organizations Plugin Groups can reference users from different organizations: ```json { "cn": "cross-dept-team", "member": [ "uid=user1,ou=dept1,ou=org,dc=example,dc=com", "uid=user2,ou=dept2,ou=org,dc=example,dc=com" ] } ``` ## Troubleshooting ### Empty Group Error **Problem:** `Object class 'groupOfNames' requires attribute 'member'` **Solution:** The plugin automatically handles this by adding a dummy member. Ensure `--group-dummy-user` is properly configured: ```bash --group-dummy-user "cn=placeholder,ou=users,dc=example,dc=com" ``` ### Member Validation Fails **Problem:** `Member does not exist in LDAP` **Solutions:** 1. Ensure the user exists before adding them to the group 2. use `core/ldap/externalUsersInGroups` plugin 3. Disable validation: `--groups-allow-unexistent-members true` ### Cannot Modify Members via PUT **Problem:** Attempting to modify `member` attribute via PUT endpoint **Solution:** Use dedicated member management endpoints: - Add: `POST /api/v1/ldap/groups/{cn}/members` - Remove: `DELETE /api/v1/ldap/groups/{cn}/members/{member}` ## See Also - [LDAP Flat Plugin](./ldapFlat.md) - For managing flat LDAP entities (users, positions, nomenclature) through schema-driven approach - [LDAP Organizations Plugin](./ldapOrganizations.md) - For managing hierarchical organizational structures with validation - [Schema Examples](../static/schemas/) - Group schema examples for Twake, Standard, and Active Directory linagora-ldap-rest-a4bb78e/docs/usage/plugins/ldap/on-change.md000066400000000000000000000107321527331160600245220ustar00rootroot00000000000000# LDAP onChange Plugin Publish the state of an entry before and after each write made through LDAP-Rest, and hooks for the attributes other plugins follow. ## Configuration ```bash --plugin core/ldap/onChange \ --mail-attribute mail \ --quota-attribute mailQuotaSize ``` ## Hooks `onLdapEntryChange(dn, before, after, context)` fires after every add, modify, rename and delete, with the entry as the directory held it on each side: | Operation | `before` | `after` | | --------- | ------------------------ | ------------------------ | | add | `null` | the new entry | | modify | the entry before | the entry after | | rename | the entry, at its old DN | the entry, at its new DN | | delete | the entry before | `null` | `dn` is the entry's DN after the write, its old one on a delete. `context` says who made the write, and through which door: - `actor`: the caller's `userName`, or its `user` when it has none; - `requestId`: the answer's `X-Request-Id`, the same for every write of one request, see [operations](../../operations.md#request-id); - `source`: `scim` for the SCIM API, `rest` otherwise. A write no request is behind, such as a scheduled task's, gets `{}`. The LDAP `ldap*done` hooks receive the same context after their arguments. The other hooks are derived from it: | Hook | Fires when | Parameters | | ------------------------- | --------------------------------------------------------------------------------------- | -------------------------------------- | | `onLdapChange` | any attribute changed | `(dn, changes)` | | `onLdapMailChange` | `--mail-attribute` changed | `(dn, oldMail, newMail)` | | `onLdapQuotaChange` | `--quota-attribute` changed | `(dn, mail, oldQuota, newQuota)` | | `onLdapAliasChange` | `--alias-attribute` changed | `(dn, mail, oldAliases, newAliases)` | | `onLdapForwardChange` | `--forward-attribute` changed | `(dn, mail, oldForwards, newForwards)` | | `onLdapDriveQuotaChange` | `--drive-quota-attribute` changed | `(dn, oldQuota, newQuota)` | | `onLdapDisplayNameChange` | the name from `--display-name-attribute`, else `cn`, else `givenName` and `sn`, changed | `(dn, oldName, newName)` | A delete fires `onLdapChange` and `onLdapMailChange`, whose `newMail` is `null`, and none of the others: the entry has nothing left to apply, and the services fed by these hooks keep the data of a deleted user. The hooks given a `mail` fire only while the entry has one: a write removing the mail attribute does not fire them. `changes` maps each attribute that changed to `[oldValues, newValues]`, the full values on each side, `null` where the attribute is absent: ```text member was [a, b], the request added c: { member: [['a', 'b'], ['a', 'b', 'c']] } ``` ## Operational attributes A search returns no operational attribute unless asked by name, so neither side holds `pwdAccountLockedTime` or its kind by default, and a write changing only such an attribute fires no hook. A plugin that follows some declares them: ```typescript followedOperationalAttributes = ['pwdAccountLockedTime']; ``` The entry is then read with `*` and every attribute a loaded plugin declares. ## What counts as a change Values are compared as sets: a single value and a one-element array are equal, and so are two orderings of the same values. A write that leaves every value as it was, such as a `replace` with the current value, fires no hook. ## Timing and errors Hooks run after the directory has accepted the write, and the API answers without waiting for them. A hook that throws is logged; the write stays. ## Limitations - Only writes made through LDAP-Rest are seen; a direct LDAP client's are not. - Entries moved by the [trash](trash.md) plugin are not seen, nor the entries under a renamed container, which move with it. - Each write reads the entry before and after it: one search on each side. linagora-ldap-rest-a4bb78e/docs/usage/plugins/ldap/organizations.md000066400000000000000000000533661527331160600255640ustar00rootroot00000000000000# LDAP Organizations Plugin The `ldapOrganizations` plugin provides comprehensive management of hierarchical LDAP organizational structures, with automatic validation of organizational links and paths. ## Overview This plugin manages hierarchical organizations stored as `organizationalUnit` (ou) entries in LDAP. Unlike flat entities managed by the ldapFlat plugin, organizations form a tree structure where each organization can contain sub-organizations. It provides: - Organization CRUD operations (Create, Read, Update, Delete) - Hierarchical tree navigation - Automatic validation of organizational links (for users/groups) - Automatic validation of organizational paths - Protection against deleting non-empty organizations - Hook integration for lifecycle events ## Configuration ### CLI Arguments ```bash --plugin core/ldap/organization \ --ldap-top-organization "dc=example,dc=com" \ --ldap-organization-class top,organizationalUnit,twakeDepartment \ --ldap-organization-link-attribute twakeDepartmentLink \ --ldap-organization-path-attribute twakeDepartmentPath \ --ldap-organization-path-separator " / " ``` ### Configuration Options | Argument | Environment Variable | Default | Description | | ----------------------------------------------------------- | ------------------------------------- | ---------------------------------------- | --------------------------------------------------- | | `--ldap-top-organization` | `DM_LDAP_TOP_ORGANIZATION` | _Required_ | DN of the top-level organization | | `--ldap-organization-class` / `--ldap-organization-classes` | `DM_LDAP_ORGANIZATION_CLASSES` | `top,organizationalUnit,twakeDepartment` | Object classes for organizations | | `--ldap-organization-link-attribute` | `DM_LDAP_ORGANIZATION_LINK_ATTRIBUTE` | `twakeDepartmentLink` | Attribute for linking users/groups to organizations | | `--ldap-organization-path-attribute` | `DM_LDAP_ORGANIZATION_PATH_ATTRIBUTE` | `twakeDepartmentPath` | Attribute for the organizational path | | `--ldap-organization-path-separator` | `DM_LDAP_ORGANIZATION_PATH_SEPARATOR` | `" / "` | Separator for path components | ### Important Notes - **Hierarchical Structure**: Organizations are organized hierarchically using LDAP's DN structure (e.g., `ou=SubDept,ou=Dept,dc=example,dc=com`) - **Top Organization Required**: You must specify a top-level organization DN - **Link vs Hierarchy**: Organizations themselves use LDAP hierarchy (DN), not the link attribute. The link attribute is used by users/groups to reference their parent organization - **Path Validation**: Organizational paths are validated to ensure they match the actual LDAP hierarchy ## Organizational Structure ### Organizations Organizations are hierarchical LDAP entries: - Use LDAP DN structure for hierarchy - Have a `twakeDepartmentPath` attribute showing their position in the tree - The path reads from the root down, the entry's own name last, and stops below the top organization: a top-level organization's path is its own name - Example: `ou=IT,ou=Departments,ou=organization,dc=example,dc=com` - Path: `Departments / IT` ### Users and Groups Users and groups reference organizations via: - `twakeDepartmentLink`: DN of the parent organization (required) - `twakeDepartmentPath`: Human-readable path (required) - Example user: ```json { "uid": "john.doe", "twakeDepartmentLink": "ou=IT,ou=Departments,ou=organization,dc=example,dc=com", "twakeDepartmentPath": "Departments / IT" } ``` ## REST API ### Get Top Organization ```http GET /api/v1/ldap/organizations/top ``` Returns the top-level organization configured in `--ldap-top-organization`. **Example:** ```bash curl "http://localhost:8081/api/v1/ldap/organizations/top" ``` **Response (200):** ```json { "dn": "dc=example,dc=com", "o": "gov", "description": "Government Organization" } ``` ### Get Organization by DN ```http GET /api/v1/ldap/organizations/:dn ``` **Path Parameter:** - `dn`: Organization DN (URL-encoded) **Example:** ```bash curl "http://localhost:8081/api/v1/ldap/organizations/ou%3DIT%2Co%3Dgov%2Cc%3Dmu" ``` **Response (200):** ```json { "dn": "ou=IT,dc=example,dc=com", "ou": "IT", "description": "Information Technology Department", "twakeDepartmentPath": "Government / IT" } ``` ### Get Organization Subnodes ```http GET /api/v1/ldap/organizations/:dn/subnodes?objectClass={class} ``` Returns all entries (users, groups, or sub-organizations) that reference this organization via their `twakeDepartmentLink` attribute. Results are automatically paginated to handle large numbers of entries. **Path Parameter:** - `dn`: Organization DN (URL-encoded) **Query Parameters:** - `objectClass` (optional): Filter results by LDAP objectClass (e.g., `twakeAccount`, `groupOfNames`, `organizationalUnit`) **Examples:** ```bash # Get all subnodes (users, groups, and sub-OUs) curl "http://localhost:8081/api/v1/ldap/organizations/ou%3DIT%2Co%3Dgov%2Cc%3Dmu/subnodes" # Get only users curl "http://localhost:8081/api/v1/ldap/organizations/ou%3DIT%2Co%3Dgov%2Cc%3Dmu/subnodes?objectClass=twakeAccount" # Get only groups curl "http://localhost:8081/api/v1/ldap/organizations/ou%3DIT%2Co%3Dgov%2Cc%3Dmu/subnodes?objectClass=groupOfNames" # Get only sub-organizations curl "http://localhost:8081/api/v1/ldap/organizations/ou%3DIT%2Co%3Dgov%2Cc%3Dmu/subnodes?objectClass=organizationalUnit" ``` **Response (200):** ```json [ { "dn": "uid=john.doe,ou=users,dc=example,dc=com", "uid": "john.doe", "cn": "John Doe", "twakeDepartmentLink": "ou=IT,dc=example,dc=com" }, { "dn": "cn=it-admins,ou=groups,dc=example,dc=com", "cn": "it-admins", "twakeDepartmentLink": "ou=IT,dc=example,dc=com" } ] ``` **Where the attached entries are looked for** Below the parent of `--ldap-top-organization`, as the caller. A caller an authorization plugin does not let search there — `authzPerBranch` judging by DN, a token of `authzDynamic` — gets the entries attached within the organization's own branch; those attached from elsewhere are not listed. `/subnodes/search` looks the same way. **When the answer is partial** Two things bound what comes back, and both are announced in the list rather than left for the client to guess: - The attached entries are capped at `--ldap-organization-max-subnodes` (default 50). Child organizations are not capped. - The directory has a size limit of its own, and a branch holding more entries than it will list in one answer makes it refuse rather than truncate. The endpoint then asks again for a bounded number and returns what it gets, writing a `warn` line naming the node. Either way the list ends with a row that is not an entry: ```json { "dn": "more-ou=IT,dc=example,dc=com", "cn": ["... 412 more elements"], "objectClass": ["moreIndicator"], "_isMoreIndicator": "true", "_totalCount": "462", "_displayedCount": "50" } ``` Its DN answers nothing — `more-` for the attached entries, `more-organizations-` for the child organizations — and a client should drop any row carrying `_isMoreIndicator` before treating the rest as entries. `_totalCount` is present only when the directory listed everything and the cap did the trimming; when the directory itself refused, nothing knows the total. A refusal is not emptiness: a node whose children cannot be listed answers its first fifty and says there are more, and any other directory failure is raised rather than returned as `[]`. Raise the size limit for the account ldap-rest binds as (`olcLimits`, or `olcSizeLimit`) to see a whole branch. ### Create Organization ```http POST /api/v1/ldap/organizations ``` **Request Body:** ```json { "ou": "IT", "parentDn": "dc=example,dc=com", "description": "Information Technology Department", "twakeDepartmentPath": "Government / IT" } ``` **Notes:** - `ou` is required (organizational unit name) - `parentDn` is optional (defaults to top organization) - Organizations are created under the specified parent DN - The resulting DN will be `ou={ou},{parentDn}` **Response (200):** ```json { "success": true } ``` ### Modify Organization ```http PUT /api/v1/ldap/organizations/:dn ``` **Path Parameter:** - `dn`: Organization DN (URL-encoded) **Request Body:** ```json { "replace": { "description": "Updated IT Department" }, "add": { "telephoneNumber": "+1234567890" }, "delete": ["l"] } ``` **Important:** - Cannot delete `twakeDepartmentPath` attribute - Path modifications are validated against LDAP hierarchy - Cannot modify `ou` attribute (this would require a rename operation) **Response (200):** ```json { "success": true } ``` ### Move Organization ```http POST /api/v1/ldap/organizations/:dn/move ``` **Path Parameter:** - `dn`: Organization DN to move (URL-encoded) **Request Body:** ```json { "targetOrgDn": "ou=NewParent,dc=example,dc=com" } ``` **Notes:** - Moves an organization to a different parent organization - The organization will become a child of the target organization - All sub-organizations and linked entities (users/groups) move with it. With `core/ldap/departmentSync` loaded, the path of the organization and of every sub-organization is recomputed, then the linked entities take the new link and path; without it, those paths keep naming the former parent - Cannot move an organization into itself or its own descendants (circular reference prevention) - Cannot move to the same parent (no-op) - Target must be a valid organizational unit **Authorization:** When using the `authzPerBranch` plugin, moving an organization requires: - **Read** permission on the source organization (current parent) - **Write** permission on the destination organization (new parent) **Example:** ```bash curl -X POST "http://localhost:8081/api/v1/ldap/organizations/ou%3DRecruitment%2Cou%3DHR%2Co%3Dgov%2Cc%3Dmu/move" \ -H "Content-Type: application/json" \ -d '{ "targetOrgDn": "ou=Operations,dc=example,dc=com" }' ``` This moves `ou=Recruitment,ou=HR,dc=example,dc=com` to `ou=Recruitment,ou=Operations,dc=example,dc=com` **Response (200):** ```json { "newDn": "ou=Recruitment,ou=Operations,dc=example,dc=com" } ``` **Error Examples:** - **Circular move (500):** `Cannot move organization into itself or its descendant` - **Invalid target (500):** `Target ou=InvalidOU,dc=example,dc=com is not an organizational unit` - **Same location (500):** `Organization is already in the target location` ### Delete Organization ```http DELETE /api/v1/ldap/organizations/:dn ``` **Path Parameter:** - `dn`: Organization DN (URL-encoded) **Notes:** - Organization must be empty (no users, groups, or sub-organizations linked to it) - Validation happens via hooks before deletion **Response (200):** ```json { "success": true } ``` **Error (500) - Non-empty:** ```json { "error": "Organization ou=IT,dc=example,dc=com is not empty" } ``` ## Validation Rules The plugin enforces several validation rules through hooks: ### For Organizations 1. **Path Validation**: The `twakeDepartmentPath` must: - End with the organization's own `ou` name, preceded by the path separator - Be preceded by the stored path of an existing organization - Be its own name alone only when the entry hangs directly from the top organization, which its DN has to say - A path written the other way round, the entry's own name first and the top organization's name last, is accepted as it stands: it is what directories written before this convention hold. The server never computes one. 2. **Deletion Protection**: Organizations can only be deleted if: - No users have `twakeDepartmentLink` pointing to it - No groups have `twakeDepartmentLink` pointing to it - No sub-organizations exist under it 3. **Path Immutability**: The `twakeDepartmentPath` attribute cannot be deleted ### For Users and Groups 1. **Link Validation**: The `twakeDepartmentLink` must: - Point to an existing organization DN - Be within the top organization branch - Not be deleted (link is mandatory) 2. **Path Validation**: The `twakeDepartmentPath` must: - Match an existing organizational hierarchy - Not be deleted (path is mandatory) ## Hooks The plugin emits and listens to lifecycle hooks: ### Listened Hooks - `ldapaddrequest` - Validates organization link and path before creating any entry - `ldapmodifyrequest` - Validates organization link and path modifications - `ldapdeleterequest` - Ensures organizations are empty before deletion - `ldaprenamerequest` - Passes through rename requests **Hook Behavior:** ```javascript // Before adding a user hooks: { ldapaddrequest: async ([dn, entry]) => { // If entry is a user/group (not an organization), // validate twakeDepartmentLink points to existing org if (!isOrganization(entry)) { await checkDeptLink(entry); // Validates link exists } // If entry is an organization, validate path against the DN it is // written at: that is what says where the entry hangs from if (isOrganization(entry)) { await checkDeptPath(entry, dn); // Validates path matches hierarchy } return [dn, entry]; }; } ``` ## Examples ### Example 1: Create Top-Level Department ```bash curl -X POST http://localhost:8081/api/v1/ldap/organizations \ -H "Content-Type: application/json" \ -d '{ "ou": "HR", "description": "Human Resources Department", "twakeDepartmentPath": "HR / Government" }' ``` This creates: `ou=HR,dc=example,dc=com` ### Example 2: Create Sub-Department ```bash curl -X POST http://localhost:8081/api/v1/ldap/organizations \ -H "Content-Type: application/json" \ -d '{ "ou": "Recruitment", "parentDn": "ou=HR,ou=organization,dc=example,dc=com", "description": "Recruitment Team" }' # `twakeDepartmentPath` is computed by the server: `HR / Recruitment` ``` This creates: `ou=Recruitment,ou=HR,ou=organization,dc=example,dc=com` ### Example 3: Get Organization Hierarchy ```bash # Get top organization curl "http://localhost:8081/api/v1/ldap/organizations/top" # Get specific organization curl "http://localhost:8081/api/v1/ldap/organizations/ou%3DHR%2Co%3Dgov%2Cc%3Dmu" # Get all users/groups/sub-OUs in HR department curl "http://localhost:8081/api/v1/ldap/organizations/ou%3DHR%2Co%3Dgov%2Cc%3Dmu/subnodes" # Get only users in HR department curl "http://localhost:8081/api/v1/ldap/organizations/ou%3DHR%2Co%3Dgov%2Cc%3Dmu/subnodes?objectClass=twakeAccount" ``` ### Example 4: Update Organization Description ```bash curl -X PUT "http://localhost:8081/api/v1/ldap/organizations/ou%3DHR%2Co%3Dgov%2Cc%3Dmu" \ -H "Content-Type: application/json" \ -d '{ "replace": { "description": "Human Resources & Administration" } }' ``` ### Example 5: Move Organization to Different Parent ```bash # Move Recruitment from HR to Operations department curl -X POST "http://localhost:8081/api/v1/ldap/organizations/ou%3DRecruitment%2Cou%3DHR%2Co%3Dgov%2Cc%3Dmu/move" \ -H "Content-Type: application/json" \ -d '{ "targetOrgDn": "ou=Operations,dc=example,dc=com" }' ``` This changes the DN from `ou=Recruitment,ou=HR,dc=example,dc=com` to `ou=Recruitment,ou=Operations,dc=example,dc=com`. All users, groups, and sub-organizations linked to Recruitment will automatically move with it. ### Example 6: Delete Empty Organization ```bash # First verify organization is empty curl "http://localhost:8081/api/v1/ldap/organizations/ou%3DRecruitment%2Cou%3DOperations%2Co%3Dgov%2Cc%3Dmu/subnodes" # If empty, delete it curl -X DELETE "http://localhost:8081/api/v1/ldap/organizations/ou%3DRecruitment%2Cou%3DOperations%2Co%3Dgov%2Cc%3Dmu" ``` ## Integration with Other Plugins ### With Users Plugin (via ldapFlat) When a user is created with organization attributes: ```bash curl -X POST http://localhost:8081/api/v1/ldap/users \ -H "Content-Type: application/json" \ -d '{ "uid": "john.doe", "cn": "John Doe", "sn": "Doe", "mail": "john.doe@example.com", "twakeDepartmentLink": "ou=IT,dc=example,dc=com", "twakeDepartmentPath": "Government / IT" }' ``` The organizations plugin automatically validates: - `twakeDepartmentLink` points to existing `ou=IT,dc=example,dc=com` - `twakeDepartmentPath` matches the organizational hierarchy ### With Groups Plugin Same validation applies when creating groups: ```bash curl -X POST http://localhost:8081/api/v1/ldap/groups \ -H "Content-Type: application/json" \ -d '{ "cn": "it-admins", "description": "IT Administrators", "twakeDepartmentLink": "ou=IT,dc=example,dc=com", "twakeDepartmentPath": "Government / IT", "member": ["uid=john.doe,ou=users,dc=example,dc=com"] }' ``` ### Organizational Cleanup When deleting an organization, the plugin ensures it's empty: ```bash # This will fail if any users/groups reference the organization DELETE /api/v1/ldap/organizations/ou=IT,dc=example,dc=com # Error: Organization ou=IT,dc=example,dc=com is not empty ``` You must first: 1. Move or delete all users with `twakeDepartmentLink` to this org 2. Move or delete all groups with `twakeDepartmentLink` to this org 3. Delete all sub-organizations ## Path Validation Details ### Path Format The path attribute follows this pattern, the top organization excluded: ``` {parent_of_parent_ou} / {parent_ou} / {current_ou} ``` ### Validation Process 1. **Verify Last Component**: Must match the organization's own `ou` 2. **Validate Parent Path**: What precedes it must be the stored path of an existing organization 3. **Verify Hierarchy**: An organization whose path is its own name alone must hang directly from the top organization 4. **Accept The Old Form**: A path reading the other way round, ending in the top organization's own name, is left alone — the directories that hold them predate this convention and their entries have to stay writable ### Example Path Validation For organization: `ou=Recruitment,ou=HR,ou=organization,dc=example,dc=com` Valid path: `HR / Recruitment` - it ends with `Recruitment`, the entry's own `ou` - what precedes it, `HR`, must be the stored path of an existing organization Invalid path: `IT / Recruitment` - no organization holds the path `IT` Invalid path: `Recruitment / HR` - it ends with `HR`, not with the entry's own name Invalid path: `Recruitment` - it names no parent, and the entry does not hang from the top organization Accepted path: `Recruitment / HR / organization` - the old form, the top organization last: read, not written ## Troubleshooting ### Invalid Organization Link **Problem:** `Organization ou=IT,dc=example,dc=com does not exist` **Solution:** Ensure the organization exists before creating users/groups that reference it: ```bash # Create organization first curl -X POST http://localhost:8081/api/v1/ldap/organizations \ -H "Content-Type: application/json" \ -d '{"ou": "IT"}' # the path is computed by the server # Then create user with link curl -X POST http://localhost:8081/api/v1/ldap/users \ -d '{"uid": "user", "twakeDepartmentLink": "ou=IT,dc=example,dc=com", ...}' ``` ### Invalid Organization Path **Problem:** `Invalid organization path: IT / Recruitment / Government` **Solutions:** 1. Ensure parent path exists and matches LDAP hierarchy 2. Verify separator matches configured separator (default: `" / "`) 3. Check that the path ends with the organization's own `ou` name ### Cannot Delete Organization **Problem:** `Organization ou=IT,dc=example,dc=com is not empty` **Solutions:** 1. List subnodes to see what's linked: ```bash curl "http://localhost:8081/api/v1/ldap/organizations/ou%3DIT%2Co%3Dgov%2Cc%3Dmu/subnodes" ``` 2. Remove or reassign all users/groups referencing this organization 3. Delete all sub-organizations first (bottom-up approach) ### Cannot Delete Path Attribute **Problem:** `An organization path cannot be deleted` **Solution:** The path attribute is required and cannot be deleted. To change it, use `replace` instead: ```bash curl -X PUT "http://localhost:8081/api/v1/ldap/organizations/ou%3DIT%2Co%3Dgov%2Cc%3Dmu" \ -d '{ "replace": { "twakeDepartmentPath": "IT / NewParent / Government" } }' ``` ## Schema Support The plugin validates organizations against configured object classes. Example schema: ```json { "strict": true, "attributes": { "objectClass": { "type": "array", "default": ["top", "organizationalUnit", "twakeDepartment"], "required": true }, "ou": { "type": "string", "test": "^[a-zA-Z0-9._-]{1,255}$", "required": true }, "twakeDepartmentPath": { "type": "string", "test": "^[\\w\\s/,]+$", "required": true }, "description": { "type": "string", "required": false } } } ``` A `pointer` attribute is checked as in [flat schemas](flat-generic.md#pointer-dn-reference): the DN must name an entry in the allowed branches, looked for as the server, whoever writes. A caller may point at an entry it cannot read, and learns from the answer whether one is there, never what it holds. A pointer that grants rights follows the same rule: an organization administrator may name, in `twakeLocalAdminLink`, an account they cannot read, and so hand the administration of their organization to it. ## See Also - [LDAP Flat Plugin](./ldapFlat.md) - For managing flat LDAP entities (users, positions, nomenclature) through schema-driven approach - [LDAP Groups Plugin](./ldapGroups.md) - For managing groups with members, automatic validation and cleanup - [Schema Examples](../static/schemas/) - Organization schema examples for Twake, Standard, and Active Directory linagora-ldap-rest-a4bb78e/docs/usage/plugins/ldap/password-policy.md000066400000000000000000000432211527331160600260210ustar00rootroot00000000000000# Password Policy Plugin The `passwordPolicy` plugin provides a REST API for managing OpenLDAP password policies (ppolicy overlay). It allows administrators to edit the policies, monitor password expiration, manage locked accounts, and optionally validate password complexity. ## Overview This plugin acts as an administration interface for OpenLDAP's ppolicy overlay. It provides: - Password status monitoring (expiration, lockout, grace logins) - Account unlock functionality - List users with expiring passwords - List locked accounts - [Policy management](#managing-policies): list, create, edit, delete, and assign to accounts - Optional local password complexity validation **Note:** ldap-rest is an administration backend. Users authenticate directly against OpenLDAP, not through ldap-rest. This plugin exposes ppolicy attributes via REST API for administrative purposes. ## Prerequisites - OpenLDAP configured with the **ppolicy overlay** - A password policy entry (pwdPolicy objectClass) in your LDAP directory ## Configuration ### CLI Arguments ```bash --plugin core/ldap/passwordPolicy \ --ppolicy-default-dn "cn=default,ou=policies,dc=example,dc=com" \ --ppolicy-warn-days 14 ``` ### Configuration Options | Argument | Environment Variable | Default | Description | | ---------------------- | ----------------------- | ------------- | ------------------------------------------------- | | `--ppolicy-default-dn` | `DM_PPOLICY_DEFAULT_DN` | (auto-detect) | DN of the default password policy entry | | `--ppolicy-base` | `DM_PPOLICY_BASE` | `{ldap_base}` | Where policies are looked for and created | | `--ppolicy-warn-days` | `DM_PPOLICY_WARN_DAYS` | `14` | Days before expiration to flag as "expiring soon" | | `--ldap-users-base` | `DM_LDAP_USERS_BASE` | `{ldap_base}` | Base DN for user searches | ### Which policy applies An account is judged by the policy its `pwdPolicySubentry` names, else by the default one, as slapd does. The default policy is set in slapd's configuration (`olcPPolicyDefault`), which this server cannot read: give it with `--ppolicy-default-dn`. Without it, the plugin takes the only policy under `--ppolicy-base`; when there are several, it takes none and logs a warning, and accounts without `pwdPolicySubentry` are reported without expiry. The bind user must be able to read `pwdPolicySubentry`: hidden by an ACL, it reads as absent and every account is judged by the default policy (see [Cannot Read Operational Attributes](#cannot-read-operational-attributes)). ### Optional Complexity Validation Enable local password complexity validation (for client-side feedback before submitting to LDAP): ```bash --ppolicy-validate-complexity true \ --ppolicy-min-length 12 \ --ppolicy-require-uppercase true \ --ppolicy-require-lowercase true \ --ppolicy-require-digit true \ --ppolicy-require-special true ``` | Argument | Environment Variable | Default | Description | | ------------------------------- | -------------------------------- | ------- | ---------------------------------- | | `--ppolicy-validate-complexity` | `DM_PPOLICY_VALIDATE_COMPLEXITY` | `false` | Enable /password/validate endpoint | | `--ppolicy-min-length` | `DM_PPOLICY_MIN_LENGTH` | `12` | Minimum password length | | `--ppolicy-require-uppercase` | `DM_PPOLICY_REQUIRE_UPPERCASE` | `true` | Require uppercase letter | | `--ppolicy-require-lowercase` | `DM_PPOLICY_REQUIRE_LOWERCASE` | `true` | Require lowercase letter | | `--ppolicy-require-digit` | `DM_PPOLICY_REQUIRE_DIGIT` | `true` | Require digit | | `--ppolicy-require-special` | `DM_PPOLICY_REQUIRE_SPECIAL` | `true` | Require special character | ## REST API ### Get Password Policy Configuration ```http GET /api/v1/password-policy ``` Returns the current ppolicy configuration from LDAP. The caller must be allowed to read the policy entry: an authorization plugin keeping it from them answers `403`, as for [`/password-policies`](#managing-policies). **Response (200):** ```json { "dn": "cn=default,ou=policies,dc=example,dc=com", "pwdMaxAge": 7776000, "pwdMinAge": 0, "pwdInHistory": 5, "pwdCheckQuality": 2, "pwdMinLength": 12, "pwdMaxFailure": 5, "pwdLockout": true, "pwdLockoutDuration": 900, "pwdGraceAuthNLimit": 3, "pwdExpireWarning": 604800, "pwdMustChange": true, "pwdAllowUserChange": true } ``` ### Get User Password Status ```http GET /api/v1/users/{id}/password-status ``` **Path Parameter:** - `id`: User uid or full DN (URL-encoded if DN) **Examples:** ```bash # Get by uid curl "http://localhost:8081/api/v1/users/john.doe/password-status" # Get by full DN curl "http://localhost:8081/api/v1/users/uid%3Djohn.doe%2Cou%3Dusers%2Cdc%3Dexample%2Cdc%3Dcom/password-status" ``` **Response (200):** ```json { "dn": "uid=john.doe,ou=users,dc=example,dc=com", "passwordSet": true, "lastChanged": "2024-01-15T10:30:00.000Z", "expiresAt": "2024-04-14T10:30:00.000Z", "daysUntilExpiration": 45, "isExpired": false, "isExpiringSoon": false, "mustChange": false, "isLocked": false, "lockedAt": null, "failureCount": 0, "graceLoginsUsed": 0, "policy": "cn=default,ou=policies,dc=example,dc=com" } ``` **Response (404):** ```json { "error": "User not found: john.doe" } ``` A `pwdMaxAge` large enough to put the expiry beyond the representable date range is reported as no expiry: `expiresAt` and `daysUntilExpiration` are `null`, and `isExpired` stays `false`. ### Unlock User Account ```http POST /api/v1/users/{id}/unlock ``` Unlocks a locked account by removing `pwdAccountLockedTime` and `pwdFailureTime` attributes. **Path Parameter:** - `id`: User uid or full DN **Example:** ```bash curl -X POST "http://localhost:8081/api/v1/users/john.doe/unlock" ``` **Response (200):** ```json { "success": true, "message": "Account unlocked" } ``` ### List Users with Expiring Passwords ```http GET /api/v1/password-policy/expiring-soon ``` **Query Parameters:** - `days` (optional): Number of days to look ahead (default: value of `--ppolicy-warn-days` or 14) **Example:** ```bash curl "http://localhost:8081/api/v1/password-policy/expiring-soon?days=7" ``` **Response (200):** ```json { "warningDays": 7, "users": [ { "dn": "uid=alice,ou=users,dc=example,dc=com", "uid": "alice", "displayName": "Alice Smith", "mail": "alice@example.com", "expiresAt": "2024-02-20T14:30:00.000Z", "daysUntilExpiration": 3 }, { "dn": "uid=bob,ou=users,dc=example,dc=com", "uid": "bob", "displayName": "Bob Jones", "mail": "bob@example.com", "expiresAt": "2024-02-22T09:15:00.000Z", "daysUntilExpiration": 5 } ] } ``` ### List Locked Accounts ```http GET /api/v1/password-policy/locked-accounts ``` **Example:** ```bash curl "http://localhost:8081/api/v1/password-policy/locked-accounts" ``` **Response (200):** ```json { "accounts": [ { "dn": "uid=charlie,ou=users,dc=example,dc=com", "uid": "charlie", "displayName": "Charlie Brown", "mail": "charlie@example.com", "lockedAt": "2024-02-15T08:45:00.000Z", "failureCount": 5 } ] } ``` ### Validate Password Complexity Only available when `--ppolicy-validate-complexity true` is set. ```http POST /api/v1/password/validate ``` **Request Body:** ```json { "password": "MySecureP@ssw0rd!" } ``` **Response (200) - Valid:** ```json { "valid": true, "errors": [] } ``` **Response (200) - Invalid:** ```json { "valid": false, "errors": [ "Minimum 12 characters required", "At least one special character required" ] } ``` **Response (400):** ```json { "error": "password required" } ``` ## Managing policies ```http GET /api/v1/password-policies # List POST /api/v1/password-policies # Create GET /api/v1/password-policies/{cn} # Read PATCH /api/v1/password-policies/{cn} # Change DELETE /api/v1/password-policies/{cn} # Delete PUT /api/v1/users/{id}/password-policy # Assign to an account ``` Policies are found by `cn` under `--ppolicy-base`. A policy is served with the attributes it holds, typed: durations and counts as integers, `TRUE` / `FALSE` as booleans, and `isDefault` marking the default one: ```json { "dn": "cn=software,ou=policies,dc=example,dc=com", "cn": "software", "isDefault": false, "pwdAttribute": "userPassword", "pwdMaxFailure": 10, "pwdLockout": true, "pwdMustChange": false } ``` **Changing a policy:** `PATCH` sets the attributes it names; `null` removes one. Sending back what `GET` served is accepted as long as `dn`, `cn`, `isDefault` and the read-only attributes keep their value. ```bash curl -X PATCH "http://localhost:8081/api/v1/password-policies/default" \ -H 'Content-Type: application/json' \ -d '{"pwdMaxAge": 31536000, "pwdExpireWarning": null}' ``` - `pwdAttribute` and `pwdCheckModule` are read-only. Before OpenLDAP 2.6, `pwdCheckModule` is the path of a library slapd loads; set it in slapd. - Durations and counts are integers from 0 to 9007199254740991 (2⁵³ − 1), the largest a JSON number holds exactly: a larger value, or one sent as an exponent such as `1e21`, is refused with a `400`. - `pwdCheckModuleArg`, the configuration of the quality-check module (ppm, for instance), is a string, usually of several lines. Writing it, or `pwdUseCheckModule`, gives the entry the `pwdPolicyChecker` class. Both need OpenLDAP 2.6; an older directory refuses them with a `400`. - An attribute the directory's schema does not know is refused with a `400` naming it. The list answers, next to `policies`, the `attributes` a policy can be given: those of the above that the directory's schema defines. OpenLDAP 2.4 lacks `pwdMaxLength`, `pwdMaxIdle`, `pwdMinDelay` and `pwdMaxDelay`. The schema is read once per `--ldap-raw-schema-cache-ttl`: after an upgrade of the directory, the list follows within that delay, or at the next restart. **Creating a policy:** `POST` takes `cn` and the attributes to set. The entry is created under `--ppolicy-base` as a `person` holding `pwdPolicy`, as in the OpenLDAP administrator's guide, applied to `userPassword`. A policy of the same name answers `409`. `--ppolicy-base` must exist: otherwise creating answers `404` and listing finds nothing, with a warning in the log. **Who manages them:** writes go through the authorization plugins like any other. With `authzLinid1`, nobody writes outside the organization tree, and every administrator reads there: name `--ppolicy-base` in `--authz-global-branches` so that only the administrators of the whole directory read and change the policies ([global branches](../auth/authz-linid1.md#global-branches)). `GET /authz/scope` answers `passwordPolicies: true` to a caller who may list the policies, and `false` to the others, so that a client offers them without asking for them and being refused. It is the judgement the authorization plugin describing the scope makes of a search of `--ppolicy-base`, which is the search the listing makes; the field is absent when this plugin is not loaded. **Deleting a policy** is refused (`409`) while it is the default one or an entry's `pwdPolicySubentry` names it. The default one is only known through `--ppolicy-default-dn` when there are several: without it, nothing stops deleting the policy slapd applies by default. Looking for the entries that name the policy searches the whole of `--ldap-base`; unless `pwdPolicySubentry` is indexed, that is a full scan on each delete. **Assigning a policy:** `PUT /users/{id}/password-policy` with `{"policy": "software"}` sets the account's `pwdPolicySubentry`; with `{"policy": null}` the account gets the default policy again. ## OpenLDAP ppolicy Attributes The plugin reads these operational attributes from user entries: | Attribute | Description | | ---------------------- | --------------------------------------------------- | | `pwdChangedTime` | When password was last changed | | `pwdAccountLockedTime` | When account was locked (000001010000Z = permanent) | | `pwdFailureTime` | Timestamps of failed authentication attempts | | `pwdGraceUseTime` | Timestamps of grace logins used | | `pwdReset` | TRUE if password was reset by admin (must change) | | `pwdPolicySubentry` | DN of applied password policy | And these configuration attributes from the ppolicy entry: | Attribute | Description | | -------------------- | ------------------------------------------- | | `pwdMaxAge` | Password expiration time in seconds | | `pwdMinAge` | Minimum password age in seconds | | `pwdInHistory` | Number of passwords kept in history | | `pwdCheckQuality` | Password quality check level (0-2) | | `pwdMinLength` | Minimum password length | | `pwdMaxFailure` | Failed attempts before lockout | | `pwdLockout` | TRUE to enable account lockout | | `pwdLockoutDuration` | Lockout duration in seconds (0 = permanent) | | `pwdGraceAuthNLimit` | Grace logins after password expires | | `pwdExpireWarning` | Warning period in seconds | | `pwdMustChange` | TRUE to require password change after reset | | `pwdAllowUserChange` | TRUE to allow users to change password | ## Examples ### Example 1: Monitor Expiring Passwords (cron job) ```bash #!/bin/bash # Send alerts for passwords expiring within 7 days USERS=$(curl -s "http://localhost:8081/api/v1/password-policy/expiring-soon?days=7" | jq -r '.users[] | "\(.mail) expires in \(.daysUntilExpiration) days"') if [ -n "$USERS" ]; then echo "$USERS" | mail -s "Password Expiration Warning" admin@example.com fi ``` ### Example 2: Unlock Multiple Accounts ```bash #!/bin/bash # Unlock all currently locked accounts LOCKED=$(curl -s "http://localhost:8081/api/v1/password-policy/locked-accounts" | jq -r '.accounts[].uid') for uid in $LOCKED; do echo "Unlocking $uid..." curl -X POST "http://localhost:8081/api/v1/users/$uid/unlock" done ``` ### Example 3: Pre-validate Password in UI ```javascript async function validatePassword(password) { const response = await fetch('/api/v1/password/validate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }), }); const result = await response.json(); if (!result.valid) { showErrors(result.errors); return false; } return true; } ``` ## Troubleshooting ### Empty Policy Response **Problem:** `GET /password-policy` returns `{}` **Solution:** With several policies, set `--ppolicy-default-dn` (see [Which policy applies](#which-policy-applies)). Otherwise the ppolicy overlay may not be configured in OpenLDAP, or the ppolicy entry doesn't exist. Configure the overlay and create a pwdPolicy entry, through [`POST /password-policies`](#managing-policies) or: ```ldif dn: cn=default,ou=policies,dc=example,dc=com objectClass: pwdPolicy objectClass: person cn: default sn: default pwdAttribute: userPassword pwdMaxAge: 7776000 pwdLockout: TRUE pwdMaxFailure: 5 pwdLockoutDuration: 900 ``` ### User Not Found **Problem:** `GET /users/john.doe/password-status` returns 404 **Solution:** Check that: 1. The user exists in LDAP 2. `--ldap-users-base` is correctly configured 3. The uid matches exactly (case-sensitive) ### Cannot Read Operational Attributes **Problem:** Password status shows null for all dates **Solution:** Ensure the LDAP bind user has permissions to read operational attributes. These require special ACLs in OpenLDAP: ``` access to attrs=pwdChangedTime,pwdAccountLockedTime,pwdFailureTime,pwdGraceUseTime,pwdReset,pwdPolicySubentry by dn="cn=admin,dc=example,dc=com" read by self read by * none ``` ## Config API Integration The plugin automatically exports its configuration via the `configApi` plugin. When loaded with `core/ldap/passwordPolicy`, the configuration is available at `GET /api/v1/config`: ```json { "apiPrefix": "/api", "ldapBase": "dc=example,dc=com", "features": { "ldapPasswordPolicy": { "name": "ldapPasswordPolicy", "enabled": true, "endpoints": { "getPolicy": "/api/v1/password-policy", "getUserStatus": "/api/v1/users/:id/password-status", "unlockUser": "/api/v1/users/:id/unlock", "getExpiringSoon": "/api/v1/password-policy/expiring-soon", "getLockedAccounts": "/api/v1/password-policy/locked-accounts", "listPolicies": "/api/v1/password-policies", "policy": "/api/v1/password-policies/:cn", "assignPolicy": "/api/v1/users/:id/password-policy", "validatePassword": "/api/v1/password/validate" }, "config": { "warnDays": 14, "validateComplexity": true, "complexityRules": { "minLength": 12, "requireUppercase": true, "requireLowercase": true, "requireDigit": true, "requireSpecial": true } } } } } ``` The policies themselves are not part of it: read them through [`GET /password-policy`](#get-password-policy-configuration), which authorization plugins judge. ## See Also - [OpenLDAP ppolicy overlay](https://www.openldap.org/doc/admin24/overlays.html#Password%20Policies) - [LDAP Users Plugin](./flat-generic.md) - For managing user entries - [Rate Limiting](../auth/rate-limit.md) - For API rate limiting (separate from LDAP lockout) - [Config API](../../configuration.md) - For API configuration discovery linagora-ldap-rest-a4bb78e/docs/usage/plugins/ldap/raw.md000066400000000000000000000255441527331160600234630ustar00rootroot00000000000000# LDAP Raw Plugin (low-level browsing) Read-only, low-level access to the directory itself: root DSE, schema, and entries addressed by their DN. Where the other LDAP plugins expose business objects (users, groups, organizations), this one exposes the tree as it is stored — the server side of a phpLDAPadmin-like interface. The companion browser library is [`ldap-browser`](../../../client-development/browser/libraries.md#ldapbrowser). ## Features - **Root DSE**: naming contexts, supported controls and extensions - **Schema**: object classes, attribute types, syntaxes and matching rules, parsed from the subschema entry (RFC 4512) and cached - **Entry read**: every attribute, operational ones included, binary values base64-encoded - **Tree navigation**: direct children of a DN, with an optional "has children" probe - **Raw search**: arbitrary base, scope and LDAP filter - **Read-only**: this plugin registers no write route ## Configuration ### Environment Variables - `DM_LDAP_RAW_BASE`: subtrees to expose, separated by `;` (default: `--ldap-base`) - `DM_LDAP_RAW_HIDDEN_ATTRIBUTES`: comma-separated attributes never returned, on top of the credential ones (default: none) - `DM_LDAP_RAW_SHOW_SECRETS`: serve credential attributes instead of hiding them (default: `false`) - `DM_LDAP_RAW_MAX_RESULTS`: maximum entries returned by a search or a children listing (default: `200`) - `DM_LDAP_RAW_SCHEMA_CACHE_TTL`: schema cache lifetime in seconds (default: `3600`) ### Command Line ```bash ldap-rest \ --plugin core/ldap/raw \ --ldap-raw-base "ou=users,dc=example,dc=com" \ --ldap-raw-base "ou=groups,dc=example,dc=com" \ --ldap-raw-hidden-attribute employeeNumber \ --ldap-raw-max-results 500 ``` With no `--ldap-raw-base`, the plugin exposes `--ldap-base`. ## API Endpoints ``` GET /api/v1/ldap/raw/bases # Exposed subtrees (roots of the tree) GET /api/v1/ldap/raw/rootdse # Server capabilities GET /api/v1/ldap/raw/schema # Parsed directory schema GET /api/v1/ldap/raw/entry/{dn} # One entry, operational attributes included GET /api/v1/ldap/raw/children/{dn} # Direct children of an entry GET /api/v1/ldap/raw/search # Arbitrary search ``` `{dn}` is the URL-encoded fully-qualified DN. ### Entry format Attribute values are always arrays. A `binary` flag tells whether the values are base64-encoded octets rather than text: ```json { "dn": "uid=alice,ou=users,dc=example,dc=com", "attributes": { "objectClass": { "values": ["top", "inetOrgPerson"], "binary": false }, "uid": { "values": ["alice"], "binary": false }, "cn": { "values": ["Alice Smith"], "binary": false }, "jpegPhoto": { "values": ["/9j/4AAQSkZJRg…"], "binary": true } } } ``` A value is flagged binary when the directory returned octets that are not valid UTF-8, or when the attribute type is binary. Credential attributes are **not** part of the response by default — see [Hidden attributes](#hidden-attributes). ### Children ```bash curl "$URL/api/v1/ldap/raw/children/$(urlencode 'dc=example,dc=com')?children=1" ``` ```json { "children": [ { "dn": "ou=users,dc=example,dc=com", "rdn": "ou=users", "objectClass": ["organizationalUnit"], "hasChildren": true } ], "truncated": false } ``` `hasChildren` is only computed when the `children` query parameter is set: it costs one extra search per child. At most `--ldap-raw-max-results` children are returned. `truncated` is true when the branch holds more, so a browsing UI can say so instead of silently showing a partial list — a branch of 5000 users must not look like a branch of 200. ### Search | Parameter | Default | Description | | ------------ | ------------------------ | ---------------------------------------------- | | `base` | first exposed base | Search base, must be inside an exposed subtree | | `scope` | `sub` | `base`, `one` or `sub` | | `filter` | `(objectClass=*)` | LDAP filter | | `attributes` | `*` | Comma-separated attribute list | | `limit` | `--ldap-raw-max-results` | Capped by `--ldap-raw-max-results` | ```bash curl "$URL/api/v1/ldap/raw/search?base=ou=users,dc=example,dc=com&filter=(mail=*@example.com)&attributes=uid,mail" ``` ```json { "entries": [...], "truncated": false } ``` `truncated` is true when the limit cut the result set short. The filter is parsed before the search runs, so a malformed one comes back as a 400 naming it rather than as a server error — a search box receives whatever the user typed, and `gov` is a typo, not a server fault: ```json { "error": "Invalid LDAP filter \"gov\": Invalid expression: gov. A filter must be parenthesised, e.g. (cn=foo) or (|(cn=*foo*)(ou=*foo*))" } ``` ### Schema ```json { "objectClasses": [ { "oid": "2.5.6.6", "names": ["person"], "desc": "RFC2256: a person", "kind": "STRUCTURAL", "sup": ["top"], "must": ["sn", "cn"], "may": ["userPassword", "telephoneNumber", "seeAlso", "description"], "obsolete": false } ], "attributeTypes": [ { "oid": "2.5.4.3", "names": ["cn", "commonName"], "sup": "name", "singleValue": false, "noUserModification": false, "usage": "userApplications", "obsolete": false, "collective": false } ], "syntaxes": [...], "matchingRules": [...] } ``` Inheritance is **not** flattened: a client resolving the attributes of an entry must walk the `sup` chains. `SchemaView` (browser) and `SchemaIndex` (`src/lib/ldapSchema.ts`, server) do it for you. ## Hidden attributes Attributes holding credential material are stripped from **every** response — entry reads and searches alike — before it leaves the server. Asking for one explicitly (`?attributes=uid,userPassword`) does not get around the filter: the removal happens when the entry is serialised, not when the query is built. The built-in list covers the usual OpenLDAP, Samba, Kerberos and Active Directory spellings: ``` userPassword, authPassword, pwdHistory, sambaNTPassword, sambaLMPassword, sambaPasswordHistory, krbPrincipalKey, krbExtraData, krbPwdHistory, unicodePwd, dbcsPwd, lmPwdHistory, ntPwdHistory, supplementalCredentials, userPKCS12 ``` A password hash served over HTTP is an offline cracking target, and browsing a directory does not require reading one — hence hidden by default, visible only on request: ```bash --ldap-raw-show-secrets true # logs a warning at startup ``` Site-specific attributes are added with `--ldap-raw-hidden-attribute`, which stacks on top of the built-in list rather than replacing it: ```bash --ldap-raw-hidden-attribute twakeApiKey --ldap-raw-hidden-attribute employeeNumber ``` A hidden attribute is resolved to its directory type, so the type's OID and every alias it declares are hidden too — asking for `2.5.4.35` or `homePhone` does not serve `userPassword` or a configured `homeTelephoneNumber`. A search filter that tests a hidden type, at any depth and under any of its names, is refused with a 400: the search runs with the service account's rights, so leaving it through would answer the same question one match at a time. An assertion on a supertype of a hidden type is refused as well, since the directory evaluates an attribute's subtypes. An extensible match without an attribute (`(:octetStringMatch:=…)`) runs over every attribute and is refused too while attributes are hidden. When the directory schema cannot be read, an entry read or a search is refused with a 503 rather than answered with only the configured names compared, which could serve an alias or an OID. The root DSE still answers: it carries no entry values and is how a client discovers the schema. Note that hiding an attribute here keeps it out of the API, not out of the directory: an account able to bind to LDAP directly still reads it. This is a guard against casual exposure through the browsing UI, not an access control — use [authz-per-branch](../auth/authz-per-branch.md) or directory ACLs for that. ## Security Access is restricted in three independent layers: 1. **Exposed bases** — any DN outside `--ldap-raw-base` is refused with a 403, whatever the caller's rights. 2. **Authorization hooks** — every entry read, children listing and search goes through `server.ldap`, so the authorization plugins ([authz-per-branch](../auth/authz-per-branch.md), authz-dynamic, …) apply exactly as on the high-level APIs. 3. **Hidden attributes** — credential attributes, plus anything in `--ldap-raw-hidden-attribute`, are removed from every response, including searches, under every name their type answers to; a filter that tests one is refused with a 400. See [Hidden attributes](#hidden-attributes). Two exceptions, both deliberate: the **root DSE** and the **schema** are read with the service account, not with the caller's rights. They are directory metadata and carry no user data; reading them with a restricted account would leave the UI unable to name and type attributes. Do not expose this plugin publicly if your directory publishes sensitive information in its root DSE. Combine with an authentication plugin ([token](../auth/token.md), [oidc](../auth/oidc.md), …) — this plugin adds no authentication of its own. ## How It Works - **Schema fetch**: the root DSE is read for `subschemaSubentry` (falling back to `cn=Subschema`), then the subschema entry is fetched and parsed once per `--ldap-raw-schema-cache-ttl`. Concurrent callers share a single fetch, and so does the [object class repair](flat-generic.md#entries-created-by-other-tools), which loads it even without this plugin. - **Operational attributes**: entries are read with `*` and `+`. When the server rejects `+`, the read is retried with `*` alone. - **Missing entries**: the LDAP "no such object" result (code 32) is mapped to a 404. A search whose base the caller cannot read answers the same 404 as one whose base does not exist. ## Limitations - **Read-only.** Creating, modifying, renaming and deleting entries is not exposed yet. - **No LDIF** import or export. - **No paging**: searches and children listings are capped by `--ldap-raw-max-results` and flagged as `truncated`; there is no cursor to fetch the rest. Use a narrower search to reach entries beyond the cap. ## Usage Example ```bash ldap-rest \ --plugin core/auth/token --auth-token "$TOKEN" \ --plugin core/ldap/raw \ --plugin core/static ``` The demo page ([`examples/web/ldap-browser.html`](../../../../examples/web/ldap-browser.html)) is then served at `/static/examples/web/ldap-browser.html`. ## See Also - [ldap-browser client library](../../../client-development/browser/libraries.md#ldapbrowser) - [organizations plugin](organizations.md) — high-level tree of organizational units - [authz-per-branch plugin](../auth/authz-per-branch.md) — branch-level access control linagora-ldap-rest-a4bb78e/docs/usage/plugins/ldap/trash.md000066400000000000000000000200751527331160600240050ustar00rootroot00000000000000# LDAP Trash Plugin Plugin to intercept LDAP delete operations and move entries to a trash branch instead of permanently deleting them. ## Features - **Soft Delete**: Moves entries to trash instead of permanent deletion - **Configurable Branches**: Watch specific LDAP branches or all branches - **Metadata Tracking**: Automatically adds deletion timestamp and original DN - **Auto-create Trash**: Creates trash branch automatically if it doesn't exist - **Atomic Move**: Uses LDAP modifyDN for safe, atomic operations - **Overwrite Protection**: Automatically removes old trash entries when moving new ones ## Configuration ### Environment Variables - `DM_TRASH_BASE`: LDAP base DN for trash (default: `ou=trash,dc=example,dc=com`) - `DM_TRASH_WATCHED_BASES`: branches to watch, each a full DN, separated by `;` (default: all branches except trash) - `DM_TRASH_ADD_METADATA`: Add metadata to trashed entries (default: `true`) - `DM_TRASH_AUTO_CREATE`: Auto-create trash branch if missing (default: `true`) ### Command Line ```bash --plugin core/ldap/trash \ --trash-base "ou=trash,dc=example,dc=com" \ --trash-watched-bases "ou=users,dc=example,dc=com;ou=groups,dc=example,dc=com" \ --trash-add-metadata true \ --trash-auto-create true ``` ## How It Works When a delete operation occurs: 1. **Check if DN is watched**: Plugin checks if the DN is in a configured watched branch 2. **Ensure trash exists**: Creates trash branch if needed (when `auto-create` is enabled) 3. **Remove old trash entry**: If an entry with the same RDN exists in trash, it's deleted first 4. **Atomic move**: Uses LDAP `modifyDN` to move the entry to trash 5. **Add metadata**: Adds deletion timestamp and original DN as `description` attribute 6. **Cancel native delete**: The original delete operation is cancelled ### DN Transformation Original DN is transformed to trash DN by replacing the parent branch: ``` Before: uid=john,ou=users,dc=example,dc=com After: uid=john,ou=trash,dc=example,dc=com ``` ## Usage Examples ### Watch All Branches (Default) ```bash ldap-rest \ --plugin core/ldap/trash \ --trash-base "ou=trash,dc=example,dc=com" ``` This watches all LDAP branches except the trash branch itself. ### Watch Specific Branches Only ```bash ldap-rest \ --plugin core/ldap/trash \ --trash-base "ou=trash,dc=example,dc=com" \ --trash-watched-bases "ou=users,dc=example,dc=com;ou=groups,dc=example,dc=com" ``` This only intercepts deletes from `ou=users` and `ou=groups`. Deletes from other branches proceed normally. With `twake/james`, keep the mail domains out of the trash: a domain moved there skips the check that it is still in use, see [Trash](../integrations/james-mail.md#trash). ### Disable Metadata ```bash ldap-rest \ --plugin core/ldap/trash \ --trash-base "ou=trash,dc=example,dc=com" \ --trash-add-metadata false ``` Entries are moved to trash without adding deletion metadata. ### Manual Trash Branch If you want to create the trash branch manually: ```bash # Create trash branch manually ldapadd -x -D "cn=admin,dc=example,dc=com" -W </`, and the schema routes, which replace the placeholders, are built on that name. To show an application at `/`, have the reverse proxy redirect `/` to it, for instance to `/static/index.html`. ### Example 3: Schema Deployment ```bash --plugin core/static \ --static-path ./static \ --static-name static \ --ldap-base "dc=example,dc=com" ``` **Schema file (`./static/schemas/config.json`):** ```json { "ldapBase": "__LDAP_BASE__", "apiEndpoint": "__API_PREFIX__/v1/ldap" } ``` **Served as:** ```json { "ldapBase": "dc=example,dc=com", "apiEndpoint": "/api/v1/ldap" } ``` ### Example 4: Multiple Static Plugins One plugin serves several applications from sub-directories: ``` ./static/ docs/ index.html app/ index.html schemas/ users.json ``` ``` /static/docs/index.html /static/app/index.html /static/schemas/users.json ``` A second directory takes a second instance, loaded under a name of its own with its own `static_name` and `static_path` ([`module:name:{json}`](../auth/README.md#serving-several-populations-from-one-server)): ```bash --plugin core/static --static-path ./static \ --plugin 'core/static:console:{"static_name":"console","static_path":"./console"}' ``` Give them paths that do not nest: `console` and `console/app` would have the first one answer requests meant for the second. The plugins that announce a schema URL (groups, organizations, flat entities, SCIM) ask every instance, the one registered as `static` first, and leave out one whose path lies under an earlier one's, or holds it. ## Security Considerations ### Path Traversal The plugin uses Express's `express.static()` which prevents path traversal attacks: ```bash # These are blocked: GET /static/../../../etc/passwd GET /static/%2e%2e%2f%2e%2e%2fetc/passwd ``` ### Sensitive Files **Do not serve** sensitive files: - Configuration files with secrets - Private keys - Database files - .env files Use a dedicated static directory with only public files. ### Access Control The static plugin has **no authentication**. All files are publicly accessible. For restricted access: 1. Use authentication plugins 2. Implement custom middleware 3. Use reverse proxy with access controls ### Security headers The files served here are applications, an administration console among them. So that no other origin frames one signed in (clickjacking), another sub-domain or port of the same site included, the responses under the prefix carry: ``` Content-Security-Policy: frame-ancestors 'self' X-Frame-Options: SAMEORIGIN ``` `X-Frame-Options` is for browsers that ignore `frame-ancestors`. A page that must be framed by another origin names it: ```bash --static-csp "frame-ancestors 'self' https://portal.example.com" --static-frame-options off ``` A full policy can be given the same way, for instance `default-src 'self'; frame-ancestors 'none'`, as long as the applications served allow it: the policy applies to every file under the prefix. One written on several lines, as a YAML block holds it, is sent as one line; a value a header cannot hold stops the server at startup. An empty value keeps the default, as a compose file expands an unset `${VAR:-}` to one; `off` sends no header (`false` in a plugin's JSON override), when the reverse proxy sets its own. Keeping both is harmless as long as they agree. A redirect or a 404 that Express answers itself (a directory without its slash, a missing file) carries `Content-Security-Policy: default-src 'none'` instead; `X-Frame-Options`, unless set to `off`, still keeps it from being framed, and it shows nothing to click. Every response of the server, API routes included, also carries `X-Content-Type-Options: nosniff`, and none says `X-Powered-By: Express`. ## MIME Types Express automatically sets Content-Type based on file extension: | Extension | Content-Type | | --------- | ------------------------ | | `.html` | `text/html` | | `.css` | `text/css` | | `.js` | `application/javascript` | | `.json` | `application/json` | | `.png` | `image/png` | | `.jpg` | `image/jpeg` | | `.svg` | `image/svg+xml` | ## Caching Express `static` middleware supports HTTP caching headers: ```bash # Browser caches for 1 day Cache-Control: public, max-age=86400 ``` For production, consider using a CDN or reverse proxy with caching. ## Integration with ldap-tree-viewer The LDAP tree viewer web interface uses the static plugin: ```bash --plugin core/static \ --plugin core/ldap/organization \ --static-path ./static \ --ldap-organization-max-subnodes 50 ``` Files: ``` ./static/ ldap-tree-viewer.html # Web interface schemas/ # JSON schemas with placeholders ``` The viewer fetches schemas with resolved LDAP configuration: ```javascript const response = await fetch('/static/schemas/organizations.json'); const schema = await response.json(); // schema.entity.base = "ou=organization,dc=example,dc=com" (resolved) ``` ## Troubleshooting ### Problem: 404 Not Found **Symptoms:** ``` GET /static/index.html → 404 ``` **Solutions:** 1. Verify file exists: ```bash ls ./static/index.html ``` 2. Check static path configuration: ```bash --static-path ./static ``` 3. Verify that the name matches the first segment of the URL: ```bash --static-name static ``` ### Problem: Placeholders Not Replaced **Symptoms:** Schema still contains `__LDAP_BASE__` after fetching. **Solutions:** 1. Ensure file is in `schemas/` subdirectory 2. Check file extension is `.json` 3. Verify config parameter is set: ```bash --ldap-base "dc=example,dc=com" ``` ### Problem: CORS Errors **Symptoms:** Browser blocks requests from different origin. **Solutions:** 1. Use reverse proxy to serve from same origin 2. Add CORS headers via custom middleware 3. Configure browser to allow CORS (development only) ## See Also - [ldapOrganizations.md](ldapOrganizations.md) - LDAP tree viewer integration - [ldapFlatGeneric.md](ldapFlatGeneric.md) - Schema-driven LDAP management linagora-ldap-rest-a4bb78e/docs/usage/plugins/utilities/storage.md000066400000000000000000000165311527331160600254250ustar00rootroot00000000000000# Storage Plugin Keyed storage for whoever needs to keep something with a deadline. ## Overview `core/storage` gives other plugins a key, a value and an instant past which the value stops being returned. It knows nothing about why a record exists: Back-Channel Logout tombstones were the first use, a refresh-token lock and a shared counter are the ones in sight. One plugin, one backend, named by `--storage-backend`. Consumers ask this one for its store and work through a single interface; where the records actually live is a deployment decision, not theirs. Having exactly one is the point. The alternative — each consumer loading the backend it likes — puts the same record in two places, and makes whichever plugin was registered first decide what happens when one of them is unwell. Loading a second instance is refused rather than left to registration order, which is invisible in a configuration. ## Configuration ```bash --plugin core/storage --storage-backend ldap \ --storage-ldap-base ou=Records,dc=example,dc=com ``` | Option | Environment | Default | Description | | ----------------------------- | ------------------------------ | -------------------- | ------------------------------------------------------------------------------- | | `--storage-backend` | `DM_STORAGE_BACKEND` | _(none)_ | `ldap`, `file`, `postgres` or `valkey`. Empty means the plugin refuses to start | | `--storage-sweep-interval` | `DM_STORAGE_SWEEP_INTERVAL` | `600` | Seconds between two passes dropping expired records | | `--storage-ldap-base` | `DM_STORAGE_LDAP_BASE` | _(none)_ | Branch the `ldap` backend writes to | | `--storage-ldap-object-class` | `DM_STORAGE_LDAP_OBJECT_CLASS` | `applicationProcess` | Object class of the entries it writes | | `--storage-file-directory` | `DM_STORAGE_FILE_DIRECTORY` | _(none)_ | Directory the `file` backend writes to | | `--storage-postgres-url` | `DM_STORAGE_POSTGRES_URL` | _(none)_ | Connection string of the `postgres` backend | | `--storage-postgres-table` | `DM_STORAGE_POSTGRES_TABLE` | `ldap_rest_storage` | Table it writes to, `schema.table` accepted | | `--storage-valkey-url` | `DM_STORAGE_VALKEY_URL` | _(none)_ | URL of the `valkey` backend (`redis://…` or `rediss://…`) | | `--storage-valkey-prefix` | `DM_STORAGE_VALKEY_PREFIX` | `ldap-rest:` | Prefix of the keys it writes | `core/storage` is in the priority list, so it is registered before the plugins that consume it whatever order they are written in. A consumer that declares it as a dependency — `core/bcl` does — also loads it on its own when the configuration does not name it. ## Backends ### `ldap` One entry per record under `--storage-ldap-base`. The branch holds records and nothing else, so it belongs outside the branches the directory serves — the plugin does not create it, and will not write into a branch that is not there. Suited to a deployment that already replicates its directory and wants the records to follow, and to one where no other store is available. ### `file` One file per record under `--storage-file-directory`. A write goes to a temporary file and is renamed into place, so a reader never sees half a record; the directory and its files are created `0700` and `0600`. The directory is the plugin's own: nothing else should be writing there, and the sweeper removes what it finds expired. It is local to one process, so it suits a single instance, not a cluster sharing one verdict. ### `postgres` One row per record in `--storage-postgres-table`, with the consumer's namespace and key kept verbatim beside the value and an `expires_at` column, so a `SELECT` tells what a record is about. Needs the optional `pg` package. The table is created at the first connection when it is not there. A role that may not create tables works once it exists: ```sql CREATE TABLE ldap_rest_storage ( namespace text NOT NULL, key text NOT NULL, value text NOT NULL, expires_at timestamptz NOT NULL, PRIMARY KEY (namespace, key) ); CREATE INDEX ldap_rest_storage_expires_at ON ldap_rest_storage (expires_at); GRANT SELECT, INSERT, UPDATE, DELETE ON ldap_rest_storage TO ldap_rest; ``` Several instances can share the table and sweep it at once. PostgreSQL refuses a NUL in `text`, so a key holding one cannot be kept: the write fails. ### `valkey` One key per record, `--storage-valkey-prefix` followed by the namespace and the key, expiring with the record: nothing is left to sweep. Works with Redis too. Needs the optional `iovalkey` package. **The instance must not evict.** An instance configured as a cache (`allkeys-lru`, `volatile-lru` and the like) drops keys under memory pressure, and every key this backend writes has a TTL: a Back-Channel Logout mark evicted early reopens the session it closed. Use an instance with `maxmemory-policy noeviction`, which answers a full memory with a failed write — reported to the identity provider — rather than a silent loss. A connection lost while running is retried in the background; meanwhile, reads answer "absent" with a warning and writes fail at once instead of waiting. `postgres` and `valkey` connect at startup, and report there an address that does not answer or a package that is not installed; the server starts anyway, and every operation tries to connect again. ## Readiness `GET /readyz` lists the plugin as `degraded` when the backend does not answer; the `ldap` backend keeps its records in the directory the probe checks on its own, see [operations](../../operations.md#what-the-shipped-plugins-report). ## Expiry Two rules the backends do not get to reinterpret: - **Expiry is enforced on read.** A record past its deadline is answered as absent whatever the sweeper has done, so a sweeper running late is a storage cost, never a wrong answer. Expiry is judged by this server's clock, the sweep and the Valkey TTL included, so a database clock running ahead cannot drop a record early. - **The deadline belongs to the consumer.** How long a record lives is the policy of whoever wrote it. The store is told an instant and respects it. Keys are namespaced per consumer, so two of them cannot collide in one branch, directory, table or instance. ## For Plugin Authors ```ts const storage = this.requirePlugin('storage'); await storage.store.set('myplugin', key, value, Date.now() + 3600_000); const kept = await storage.store.get('myplugin', key); // null once expired await storage.store.delete('myplugin', key); ``` `requirePlugin` warns and answers `null` when storage is absent, which is right for a feature that can be skipped. A plugin making a **security** decision must not read "no storage" as "nothing to check": it refuses to load instead, as [Back-Channel Logout](../auth/back-channel-logout.md) does. ## See Also - [Back-Channel Logout](../auth/back-channel-logout.md) — the first consumer - [OpenID Connect](../auth/oidc.md) linagora-ldap-rest-a4bb78e/docs/usage/plugins/utilities/weblogs.md000066400000000000000000000242671527331160600254300ustar00rootroot00000000000000# Web Logs Plugin HTTP request logging middleware for debugging and monitoring. ## Overview The `weblogs` plugin logs all incoming HTTP requests and responses with timing information, status codes, and authenticated user details. ## Configuration ```bash --plugin core/weblogs ``` No additional configuration required. ## Log Output ### Request Logging Each line carries the `requestId` of the answer's `X-Request-Id`, see [operations](../../operations.md#request-id). Each request is logged when it arrives: ``` [debug] Incoming request: GET /api/v1/ldap/users ``` ### Response Logging Each response is logged when completed at **notice** level (between info and warn): ```json { "level": "notice", "message": { "requestId": "6effd260-b079-4335-b727-544189ed33a6", "method": "GET", "url": "/api/v1/ldap/users", "status": 200, "duration": 45, "ip": "10.0.9.12", "user": "admin" }, "requestId": "6effd260-b079-4335-b727-544189ed33a6", "timestamp": "2026-10-26T18:07:03.286Z" } ``` ## Log Fields | Field | Description | Example | | ----------- | ------------------------------- | ------------------------------ | | `user` | Authenticated user (if any) | `admin` | | `requestId` | The answer's `X-Request-Id` | `6effd260-b079-…` | | `method` | HTTP method | `GET`, `POST`, `PUT`, `DELETE` | | `url` | Full URL including query params | `/api/v1/ldap/users?match=*` | | `status` | HTTP status code | `200`, `404`, `500` | | `duration` | Request duration, in ms | `45`, `123` | | `error` | Error message (if applicable) | `Connection timeout` | ## Enriching the Log Line From Another Plugin The standard fields say _that_ a request happened, not _what_ was requested. For `POST`/`DELETE` routes carrying their parameters in the body, the URL alone is not enough for auditing. Any plugin can attach extra context to the access log entry by setting `req.logDetails` in its handler. Those fields are merged into the single `notice` entry emitted when the response completes — no second log line, no correlation by timestamp: ```typescript app.post('/api/_getldapentries', (req, res) => { req.logDetails = { uids: req.body }; // ... }); ``` Output: ```json { "level": "notice", "message": { "uids": ["jdoe", "asmith"], "requestId": "6effd260-b079-4335-b727-544189ed33a6", "method": "POST", "url": "/api/_getldapentries", "status": 200, "duration": 9, "ip": "10.0.9.12", "user": "admin" } } ``` Notes: - Core fields (`requestId`, `method`, `url`, `status`, `duration`, `ip`, `user`, `error`) always take precedence: a plugin cannot overwrite them. - The value must be set **before** the response completes (i.e. before `res.json()` / `res.end()`), since the entry is written on the `finish` event. - Only log what identifies the request (uids, mail addresses, target user, action). Do **not** put credentials or free-text personal data there — this line is written at `notice` level, i.e. in production. - Keep lists bounded: a request with thousands of uids produces an unreadable log line. Truncate and log the real count alongside. ## Use Cases ### 1. Request Monitoring Track all API usage: ```bash --plugin core/weblogs \ --log-level info ``` Output: ``` [info] POST /api/v1/ldap/users 201 123ms {"user":"admin"} [info] GET /api/v1/ldap/users/jdoe 200 23ms {"user":"admin"} [info] PUT /api/v1/ldap/users/jdoe 200 89ms {"user":"admin"} [info] DELETE /api/v1/ldap/users/test 200 45ms {"user":"admin"} ``` ### 2. Performance Analysis Identify slow endpoints: ```bash --plugin core/weblogs \ --log-level info | grep -E '[0-9]{3,}ms' ``` Shows requests taking >100ms: ``` [info] GET /api/v1/ldap/organizations/top 200 345ms [info] POST /api/v1/ldap/users 201 523ms ``` ### 3. Security Auditing Track who accesses what: ```bash --plugin core/auth/token \ --plugin core/weblogs ``` Output: ```json { "level": "info", "message": "DELETE /api/v1/ldap/users/jdoe 200 45ms", "user": "admin", "method": "DELETE", "url": "/api/v1/ldap/users/jdoe" } ``` ### 4. Error Tracking Monitor failed requests: ```bash --plugin core/weblogs | grep -E ' (4|5)[0-9]{2} ' ``` Shows 4xx and 5xx responses: ``` [info] GET /api/v1/ldap/users/notfound 404 12ms [info] POST /api/v1/ldap/users 400 8ms {"error":"Attribute \"cn\" is required"} [info] GET /api/v1/ldap/users 500 234ms {"error":"LDAP connection timeout"} ``` ## Integration ### With Authentication Plugins ```bash --plugin core/auth/token \ --plugin core/weblogs \ --auth-token "secret-token" ``` Logs include authenticated user: ```json { "user": "token number 0", "message": "GET /api/v1/ldap/users 200 45ms" } ``` ### With Authorization Plugin ```bash --plugin core/auth/token \ --plugin core/auth/authzPerBranch \ --plugin core/weblogs ``` Track authorization failures: ```json { "user": "limited_user", "message": "GET /api/v1/ldap/users 403 5ms", "error": "User does not have read permission" } ``` ## Log Levels Configure log level to control verbosity: ```bash --log-level debug # All requests + debug info --log-level info # Completed requests + info messages --log-level notice # Completed requests (web logs) only --log-level warn # Only warnings and errors (no web logs) --log-level error # Only errors ``` The log level hierarchy (following syslog convention): - **error** (0) - Error messages only - **warn** (1) - Warnings and errors - **notice** (2) - Web access logs, warnings and errors - **info** (3) - General info messages, web logs, warnings and errors - **debug** (4) - All messages including debug output ### Debug Level ```bash --plugin core/weblogs \ --log-level debug ``` Output: ``` [debug] Incoming request: POST /api/v1/ldap/users [debug] Request body: {"uid":"newuser","cn":"New User",...} [info] POST /api/v1/ldap/users 201 123ms ``` ### Notice Level (Recommended) ```bash --plugin core/weblogs \ --log-level notice ``` Output: ``` [notice] POST /api/v1/ldap/users 201 123ms {"user":"admin"} [notice] GET /api/v1/ldap/users 200 45ms {"user":"admin"} ``` This level shows only web access logs without general info messages, ideal for production. ### Info Level ```bash --plugin core/weblogs \ --log-level info ``` Output: ``` [info] LDAP connection pool initialized: size=5, ttl=60s [info] Plugin loaded: core/weblogs [notice] POST /api/v1/ldap/users 201 123ms {"user":"admin"} [notice] GET /api/v1/ldap/users 200 45ms {"user":"admin"} ``` This level includes general info messages along with web logs. ## Event Handling The plugin logs three types of events: ### 1. Normal Completion Request completes successfully: ```javascript res.on('finish', () => { // Log: "GET /api/v1/ldap/users 200 45ms" }); ``` ### 2. Response Error Error occurs during response: ```javascript res.on('error', err => { // Log: "GET /api/v1/ldap/users 500 234ms" {"error":"Connection timeout"} }); ``` ### 3. Connection Closed Client closes connection before response: ```javascript res.on('close', () => { // Log: "GET /api/v1/ldap/users - - {"error":"Connection closed before response was sent"} }); ``` ## Log Parsing ### JSON Format Logs are in JSON format for easy parsing: ```bash cat app.log | jq 'select(.message.status? >= 400)' ``` Filters 4xx and 5xx responses: ```json { "level": "notice", "message": { "requestId": "6effd260-b079-4335-b727-544189ed33a6", "method": "GET", "url": "/api/v1/ldap/users/notfound", "status": 404, "duration": 12, "ip": "10.0.9.12" }, "requestId": "6effd260-b079-4335-b727-544189ed33a6", "timestamp": "2026-10-26T18:07:03.286Z" } ``` The lines of one request, the access line and what the server logged while serving it: ```bash cat app.log | jq 'select(.requestId == "6effd260-b079-4335-b727-544189ed33a6")' ``` ### Statistics Generate request statistics: ```bash cat app.log | jq -r 'select(.message.method?) | .message.method' | sort | uniq -c ``` Output: ``` 45 DELETE 123 GET 67 POST 34 PUT ``` ### Response Time Analysis Average response time per endpoint: ```bash cat app.log | jq -r 'select(.message.duration?) | "\(.message.url) \(.message.duration)"' | \ awk '{sum[$1]+=$2; count[$1]++} END {for (url in sum) print url, sum[url]/count[url] "ms"}' ``` ## Performance Impact The weblogs plugin adds minimal overhead: - **Request logging**: ~0.1ms - **Response logging**: ~0.5ms - **Total overhead**: <1ms per request For high-traffic deployments: 1. Use `--log-level warn` to reduce logging 2. Log to file instead of console 3. Use log aggregation service ## Log Rotation Rotate the log file with external tools: ### Writing to a file ```bash --logger file --log-file ./logs/ldap-rest.log ``` The server does not rotate the file itself. ### Using logrotate (Linux) ``` /var/log/ldap-rest/*.log { daily rotate 7 compress delaycompress notifempty create 0640 ldap-rest ldap-rest sharedscripts postrotate systemctl reload ldap-rest endscript } ``` ## Troubleshooting ### Problem: No Logs Appearing **Solutions:** 1. Ensure plugin is loaded: ```bash --plugin core/weblogs ``` 2. Check log level: ```bash --log-level info # or debug ``` 3. Verify logs go to stdout/stderr: ```bash ldap-rest --plugin core/weblogs 2>&1 | tee app.log ``` ### Problem: Too Many Logs **Solutions:** 1. Increase log level: ```bash --log-level warn # Only warnings and errors ``` 2. Filter specific endpoints: ```bash ldap-rest --plugin core/weblogs 2>&1 | grep -v '/health' ``` 3. Use log aggregation with filtering ### Problem: User Field Empty **Symptoms:** ```json { "message": "GET /api/v1/ldap/users 200 45ms" } ``` No `user` field in logs. **Solutions:** 1. Enable authentication plugin: ```bash --plugin core/auth/token \ --plugin core/weblogs ``` 2. Verify authentication is working: ```bash curl -H "Authorization: Bearer token" http://localhost:8081/api/v1/ldap/users ``` ## See Also - [Authentication Plugins](authentication.md) - User authentication - [Winston Logger](https://github.com/winstonjs/winston) - Logging library used linagora-ldap-rest-a4bb78e/docs/usage/troubleshooting.md000066400000000000000000000035061527331160600235120ustar00rootroot00000000000000# Troubleshooting Guide for resolving common issues. ## Connection Issues ### Plugin not found **Symptom:** "Plugin not found" error at startup **Solutions:** 1. Verify exact plugin name 2. Use full path (e.g., `core/ldap/flatGeneric`) 3. Check that plugin has no missing dependencies ### LDAP connection failed **Symptom:** Unable to connect to LDAP server **Solutions:** 1. Verify LDAP URL (`--ldap-url`) 2. Verify bind DN (`--ldap-dn`) 3. Verify password (`--ldap-pwd`) 4. Test with `ldapsearch`: ```bash ldapsearch -x -H ldap://localhost:389 -D "cn=admin,dc=example,dc=com" -W -b "dc=example,dc=com" ``` ### Schema validation failed **Symptom:** Schema validation error **Solutions:** 1. Verify JSON schema syntax 2. Check required fields 3. Enable debug for more details: ```bash --log-level debug ``` ## API Issues ### 404 on API endpoint **Symptom:** API endpoint returns 404 **Solutions:** 1. Verify that the plugin providing the endpoint is loaded 2. Check exact endpoint URL 3. Check logs for registered routes ### 401 Unauthorized **Symptom:** Authentication refused **Solutions:** 1. Verify `Authorization: Bearer {token}` header format 2. Verify token is in configured list 3. For TOTP, verify clock synchronization ### 403 Forbidden **Symptom:** Access denied despite authentication **Solutions:** 1. Check authorization rules (`authzPerBranch`) 2. Verify IP is not blocked by CrowdSec 3. Check logs for blocking reason ## Debug Mode Enable detailed logging: ```bash ldap-rest --log-level debug ... ``` Or via environment variable: ```bash DM_LOG_LEVEL=debug ldap-rest ``` ## Testing See [Tests and checks](../../CONTRIBUTING.md#tests-and-checks). ## Support - **Issues:** https://github.com/linagora/ldap-rest/issues - **Documentation:** https://github.com/linagora/ldap-rest/tree/master/docs linagora-ldap-rest-a4bb78e/docs/usage/upgrading.md000066400000000000000000002610141527331160600222430ustar00rootroot00000000000000# Upgrading What to check before deploying, newest first. Only releases that need a decision or a configuration change appear here; see the [CHANGELOG](../../CHANGELOG.md) for everything else. ## From 2.0.3 ### An `authzDynamic` flag must be a JSON boolean **Who is affected:** a deployment loading `core/auth/authzDynamic` whose token entries hold a `read`, `write` or `delete` flag in `description` as a string or a number (`"true"`, `"yes"`, `1`) instead of the boolean `true`. That flag grants nothing after the upgrade: the permission is refused on the branch, and each reload logs `… not a boolean: permission refused`, naming the token entry and the branch. Write the JSON boolean `true` for the permissions the token holds. A flag written `"false"`, `"no"` or `"0"` refuses too, as it was meant to. ### A malformed trusted proxy stops the server **Who is affected:** a deployment whose `--trusted-proxy` / `DM_TRUSTED_PROXIES` holds something that is neither an address nor an `address/prefix` network, such as `10.0.0.0/2.`, `10.0.0.0/24x` or `:::`. `core/auth/trustedProxy` refuses it at startup (`Invalid trusted proxy address "…"`) instead of reading its leading digits and trusting another network: `10.0.0.0/2.` was read as `10.0.0.0/2`, a network covering `0.0.0.0/2`. An IPv4-mapped address remains accepted. A trusted entry carrying a zone index (`fe80::1%eth0`) is refused too: the same link-local address names another host on another link. Fix the entry. ## From 2.0.2 ### A console on another origin must be trusted for logout **Who is affected:** a deployment whose console is served from an origin other than `--base-url`, for example a front-end calling the API with its own `apiBaseUrl`. `/logout` refuses a request a browser reports as coming from another site. List the console's origin in `--oidc-logout-origins` (or `DM_OIDC_LOGOUT_ORIGINS`, comma-separated); otherwise its sign-out link answers `403` and the session stays. A console served by ldap-rest itself, from `--base-url`, needs nothing. ### The organization tree is held to branches **Who is affected:** a deployment using `core/auth/authzLinid1`, or `core/auth/authzPerBranch` with `--authz-filter-attached-entries`. An administrator no longer reads the organizations of another branch: a single read answers `404`, and a subnode listing or a search returns only the branches they hold. The tree names every branch, its description and its local administrators, which an administrator of one branch has no business reading. `organizations/top` still answers one of their branches — or the top organization for a caller named in no `twakeLocalAdminLink` — and the transit branch's own entry stays readable so that the console can open its card from the scope banner. Naming the tree top directly is refused like any organization outside their branches. A console drawing the whole tree from an administrator's session draws their branches only. A client that listed the tree to let one administrator choose another branch as a destination loses that choice; the write was refused on the destination branch anyway. ## From 1.3.0 ### A plugin with its own `service` member **Who is affected:** a plugin built outside this repository that declares a field, getter or method named `service`. `DmPlugin` now has `service(token)`, which returns the instance another plugin provides under a service token. Rename the plugin's own `service` member. See [plugin services](../plugin-development/services.md). ### A plugin with its own `provides` member **Who is affected:** a plugin built outside this repository that declares a field named `provides`. `DmPlugin` now has `provides`, the services a plugin offers to others. Rename the plugin's own `provides` member, or it becomes the list of services the plugin declares. See [plugin services](../plugin-development/services.md#providing-a-service). ### A path parameter is percent-decoded once **Who is affected:** a client that encoded a path parameter twice to reach an identifier holding `%`, sending `/users/100%2525` for the entry `100%`. The parameter is decoded once now, as RFC 3986 spells it: an identifier holding `%` goes out as `100%25`. A parameter still encoded twice names the escaped text, so `/users/100%2525` addresses the entry `100%25` ([details](../client-development/api/rest-api.md#url-encoding)). ### A paged search opens its connection on the first page **Who is affected:** a plugin calling `ldap.search()` with paging, leaving the returned generator unread and expecting a connection to have been opened, or expecting a connection or hook error from the call itself. A paged search takes its pooled connection when the first page is read, not when `search()` is called. A generator that is never walked holds no connection, and the next request can use the slot. A connection or bind failure, the 30 s wait for a pooled connection and an `ldapsearchresult` hook that refuses are answered on the first page, not by the call; a read that must fail when it is made uses `paged: false`. ### A deletion date written in another shape **Who is affected:** a deployment whose writer writes the lifecycle deletion date as something other than an ISO 8601 date or a GeneralizedTime with its zone — for example `date '+%F %T'` (`2025-01-01 12:00:00`), an RFC 2822 date, or a US-style `01/02/2025`. The deletion date is read only in those two shapes now, where the default `iso8601` mode handed anything else to `new Date()`. An unreadable date makes `erase` answer `409` without `force`, leaves `deletedAt` out of the events, and a rule whose payload holds only `$changed.` publishes nothing. Write an ISO 8601 date, such as with `date -u '+%Y-%m-%dT%H:%M:%SZ'`, or a GeneralizedTime with its zone, and see [lifecycle events](plugins/integrations/lifecycle-events.md#configuration). ## From 1.2.1 ### A plugin written against the old hooks API **Who is affected:** a deployment loading a plugin built outside this repository that calls `launchHooks` / `launchHooksChained`, or subscribes to a `*done` hook with its arguments packed in one tuple. Hooks are raised through `this.hookBus`, and every hook is declared in `src/hooks.ts`: a plugin raising or subscribing to a hook the server does not declare no longer compiles. A notified hook — `ldapadddone`, `ldapaddafter`, `ldapmodifydone`, `ldaprenamedone`, `ldapgroupadddone`, `ldapgroupmodifydone`, `ldapgrouprenamedone`, `ldapexternaluseradded` — now gets its arguments one by one: `(dn, entry, context) => …`, not `([dn, entry], context) => …`. The packed form still compiles — parameters are bivariant — and then reads the DN's first character, so a plugin left as it was stops reacting without saying so. `ldapgroupdeletemember` threads `[cn, member]`, a single member DN, as it always handed one. A test waiting for a hook to land waits on `await server.hookBus.settled()` rather than sleeping. See [hooks](../plugin-development/hooks.md#the-bus). ### A plugin with its own `options` or `optionDefinitions` member **Who is affected:** a plugin built outside this repository that declares a field, getter or method named `options`, or a static field named `optionDefinitions`. `DmPlugin` now has `options()`, which returns the values a plugin declared through `optionDefinitions`. Rename the plugin's own `options` member; a plugin that does not declare options keeps reading `this.config` as before. ### A plugin extending `TwakePlugin` **Who is affected:** a plugin built outside this repository whose class extends `TwakePlugin`. The base constructor takes the WebAdmin URL, token and concurrency instead of the names of the options holding them: ```ts import { jamesOptionsOf } from 'ldap-rest/plugin-twake-james.options'; const options = jamesOptionsOf(server.config); super(server, { url: options.james_webadmin_url, token: options.james_webadmin_token, concurrency: options.james_concurrency, urlOption: 'james_webadmin_url', tokenOption: 'james_webadmin_token', concurrencyOption: 'james_concurrency', }); ``` The three `*Option` fields only name the option in the startup error and the missing-token warning. `concurrency` must be a positive integer; the base refuses anything else, naming `concurrencyOption`. ### A plugin building SCIM's exported helpers **Who is affected:** a plugin built outside this repository that constructs `ScimUsers`, `ScimGroups`, `ScimBulk`, `ScimDiscovery` or `BaseResolver`, or calls `scimBodyParser`. These take the SCIM options instead of a plain `Config`: ```ts import { scimOptionsOf } from 'ldap-rest/plugin-scim-scim.options'; const options = scimOptionsOf(config); ``` `BaseResolver` keeps its `(config, options)` form; the others take the checked values beside what they already require. ### An invalid SCIM or Twake value **Who is affected:** a deployment setting a SCIM limit to zero or a negative number, giving a per-instance override of the wrong JSON type, or setting the James, Calendar, Drive or migration proxy concurrency to the wrong type. These options now refuse a value below 1 when the plugin loads: a zero used to be silently replaced by the default, and a negative value used as it was. Remove the option to keep the default, or give it a positive number. A per-instance override (`--plugin core/scim:name:{...}`) must also use the option's JSON type: `{"scim_max_results": "50"}` and `{"scim_user_base": null}` are refused, where the quoted number used to be coerced and the null treated as unset. The applicative accounts options refuse the same: `{"max_app_accounts": "5"}` used to be kept as the string, and `{"applicative_account_attribute": "cn"}` was spread into `['c', 'n']`. The James, Calendar, Drive and migration proxy concurrency used to replace a value that was not a number with `10`, and now refuses it. A zero or a negative value was already refused by `p-limit`, and the message now names the option. The other Twake options also refuse a value of the wrong JSON type in an override. A zero `--twake-group-max-page-limit`, `--twake-instance-timeout`, `--cloudery-workflow-poll-interval-ms` or `--cloudery-workflow-max-attempts` used to be replaced by the default and is now refused. The authentication and authorization options are checked the same way. A zero `--rate-limit-window-ms`, `--rate-limit-max`, `--crowdsec-cache-ttl`, `--authz-per-branch-cache-ttl` or `--authz-dynamic-cache-ttl` used to be replaced by the default and is now refused. A zero `--auth-totp-step` or `--auth-hmac-window` was accepted and broke the plugin — every counter was `Infinity`, every signature not made in the same millisecond was refused — and is now refused at startup. A zero `--ldap-organization-max-subnodes`, `--ldap-raw-max-results`, `--ppolicy-warn-days`, `--ppolicy-min-length` or `--bcl-retention` used to be replaced by the default and is now refused. `ConfigParser` keeps an empty string for a string option. A plugin reads the declared default instead when that default is not empty, as its `|| default` used to: `DM_COZY_ADMIN_USER=` gives `admin`, `DM_CLOUDERY_OFFER=` gives `b2b_twake_default`. An option whose empty value means something says so in its declaration (`--cozy-apps`, `--cozy-user-deleted-routing-key`, `--twake-instance-cozy-apps`, `--twake-instance-cozy-context`, `--twake-instance-cloudery-offer`) and keeps it; an option whose default is empty keeps the empty string. ### The group member check option **Who is affected:** a deployment passing `--group-allow-unexistent-members`. That spelling was stored under a key the groups plugin never read, so it never disabled member validation. The option is now `--groups-allow-unexistent-members` and `DM_ALLOW_UNEXISTENT_MEMBERS`, which the plugin reads; the old spelling is gone. ### A config key a plugin did not declare **Who is affected:** a plugin built outside this repository reading an option through `this.config.` without declaring it. `Config` no longer has an index signature, so such a read does not compile. Declare the option with `defineOptions` and read it through `this.options()` (see [plugin options](../plugin-development/options.md)); the declaration also makes the option known to the startup report and its `env` variable read. ### Node 22 or later is required **Who is affected:** a deployment running on Node 20 (end of life 2026-04-30). A deployment of the published image is not: it is built on Node 24. `engines` declares `>=22`, and the CI runs the suite on 22, 24 and 26. Node 20 is neither tested nor supported. ### Values past 4096 characters, and an address with a trailing or empty label **Who is affected:** a deployment whose schemas accept a value longer than 4096 characters, or an address such as `a@b.c.` or `a@b..c` in an attribute whose pattern came with the shipped schemas. A schema `test` pattern is no longer run on a value longer than 4096 characters; the value is answered 400, naming the length, before the pattern sees it, so that a pattern cannot be made to walk several kilobytes. This holds for every attribute in every schema which carries a pattern, a deployment's own included, and none of the shipped schemas declares a longer limit. An attribute holding longer values declares its own `maxLength`, a whole number of characters, or the server refuses to load the schema: ```json { "sshPublicKey": { "type": "array", "items": { "type": "string", "maxLength": 16384, "test": "^[^\\r\\n]*$" } } } ``` The address patterns shipped with the schemas no longer accept a trailing dot or an empty label: `a@b.c.`, `a@b..c` and `a@.b.c` are refused. Entries already holding such a value stay as they are; the value is refused the next time that attribute is written. A deployment that copied one of these patterns keeps its own copy, unbounded only if it declares no `maxLength`; the patterns to use are in the [schema reference](../../docs/client-development/schemas/README.md#test-optional). ### A `null` under a name the schema does not declare **Who is affected:** a client sending `null` to an entity whose schema does not declare the attribute, or to an entity without a schema — the `dhcpHosts` and `dnsRecords` schemas shipped with `"strict": false` among them. Such a write answered 500, with nothing written. It now succeeds: the key is left out of a creation or an `add`, and clears the attribute in a `replace`. No action is required. A strict schema still answers 400 for such a name, and the value is written as if the key were left out, so a client relying on the 500 to notice a misspelt attribute no longer hears about it — see [what a value must look like](../client-development/schemas/README.md#what-a-value-must-look-like). ## From 1.2.0 to 1.2.1 ### `{org}` once in a Twake branch pattern **Who is affected:** a deployment whose `--twake-group-base`, `--twake-group-user-base` or `--twake-space-base` holds `{org}` more than once. None is known. The server refuses to start on such a pattern. It filled the first `{org}` only and read the organization back from the first one, so a second occurrence never worked; now that every occurrence is filled, the two readings would disagree. ## From 1.1 to 1.2.0 ### The process stops on an uncaught exception **Who is affected:** every deployment; a supervisor, Kubernetes or systemd, restarts the process, as it should. An exception nothing caught was logged and the process kept serving in a state nobody knew. The binary now shuts down and exits with 1. On `SIGTERM` it also drains what is in flight within `--shutdown-timeout` (20 s) and closes its connections before the signal ends it, where the signal ended the process at once, the broker connection closed first when `core/rabbitmq` was loaded. A host assembling the server in its own process gets neither unless it asks: `run()` no longer installs the handlers that logged an uncaught exception or an unhandled rejection and kept the process alive, so without `exitOnCrash()` or handlers of its own, Node's action applies and the process ends on either. See [shutdown](operations.md#shutdown). ### The readiness of a pod follows the directory **Who is affected:** a deployment of the Helm chart, whose probes now call `GET /healthz` and `GET /readyz` instead of opening the listen port. A pod leaves the endpoints while the directory does not answer, where it kept receiving requests and answering 500; a rollout started during a directory outage waits for the directory. Both routes are served without authentication: an Ingress routing `/` exposes them, keep the two paths to the cluster. See [operations](operations.md#health-and-readiness). ### `--ldap-dn` and `--ldap-pwd` are required **Who is affected:** a deployment that left one of them unset, and so bound as `cn=admin,dc=example,dc=com` or with the password `admin`; the published Docker image carried both in its `ENV`, and the Helm chart's example only set the password. The server now stops at startup naming the missing option. Set `DM_LDAP_DN` and `DM_LDAP_PWD` (or `--ldap-dn` and `--ldap-pwd`) to the values that were implied. ### Every entity route checks the shape of a value **Who is affected:** a script or connector writing through `/api/v1/ldap/`, `/api/v1/ldap/groups` or `/api/v1/ldap/organizations` text that is not a number for a `number` or `integer`, a `delete` of a `required` attribute as a whole, or a change naming an attribute a strict schema does not declare; one writing through `/api/v1/ldap/` several values for an attribute the schema does not type `array`; and one changing a `fixed` attribute of a group, which the server dropped from the change and answered 200. The server now answers 400, naming the attribute. A group creation sending a value under a `fixed` attribute now writes the schema's default, or the value when the schema gives none, where it wrote nothing. The shipped LSC connector sends shapes that stay accepted, a list of one in a `replace` and one value for an `array` on a creation; its whole-attribute delete is refused for a `required` attribute, as any, and a group synchronisation that forces `objectClass` or another `fixed` attribute now has the whole update refused: keep those attributes out of the group updates. Send one value for a single-valued attribute, a list for an `array` (one value, or a list of one, is taken either way), numbers as numbers or digits, and `null` or `[]` in a `replace` to clear an attribute. The rules are in [what a value must look like](../client-development/schemas/README.md#what-a-value-must-look-like). ### `--logger`: `console` or `file` A `DM_LOGGER` set to anything else wrote `error.log` in the working directory; the server now stops at startup. Set `DM_LOGGER=file` to keep that file, and `DM_LOG_FILE` to name another one. An empty `DM_LOGGER` is `console`. ## From 1.0 to 1.1.0 ### The static files can no longer be framed by another origin `core/static` sends `Content-Security-Policy: frame-ancestors 'self'` and `X-Frame-Options: SAMEORIGIN`. A page of another origin, another sub-domain or port of the same site included, that showed one of its applications in an `